reorganize repo: move shell sources into shell/, test scripts into test/

This commit is contained in:
Damocles 2026-04-17 18:29:40 +02:00
parent 344c1f8512
commit d6cd2f173a
60 changed files with 2 additions and 2 deletions

View file

@ -0,0 +1,217 @@
import QtQuick
import QtQuick.Effects
import Quickshell
import Quickshell.Wayland
import "." as M
PanelWindow {
id: root
required property var screen
color: "transparent"
WlrLayershell.layer: WlrLayer.Background
WlrLayershell.exclusiveZone: -1
WlrLayershell.namespace: "nova-background-overlay"
mask: Region {}
anchors.top: true
anchors.left: true
anchors.right: true
anchors.bottom: true
SystemClock {
id: clock
precision: SystemClock.Seconds
}
Column {
anchors.centerIn: parent
spacing: 12
// Neon clock
Item {
anchors.horizontalCenter: parent.horizontalCenter
width: clockRow.width
height: clockRow.height
// Glow layer rendered offscreen, only its neon halo is visible
Row {
id: glowSource
visible: false
anchors.centerIn: parent
Text {
text: Qt.formatDateTime(clock.date, "HH")
color: M.Theme.base0D
font.pixelSize: 72
font.family: M.Theme.fontFamily
font.bold: true
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: ":"
color: colon._colors[colon._colorIdx % colon._colors.length]
Behavior on color {
enabled: !M.Theme.reducedMotion
ColorAnimation {
duration: 500
}
}
font.pixelSize: 72
font.family: M.Theme.fontFamily
font.bold: true
opacity: colon.opacity
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: Qt.formatDateTime(clock.date, "mm")
color: M.Theme.base0E
font.pixelSize: 72
font.family: M.Theme.fontFamily
font.bold: true
anchors.verticalCenter: parent.verticalCenter
}
}
MultiEffect {
source: glowSource
anchors.fill: glowSource
shadowEnabled: true
shadowColor: M.Theme.base0D
shadowBlur: 1.0
shadowVerticalOffset: 0
shadowHorizontalOffset: 0
}
// Actual visible text on top of the glow
Row {
id: clockRow
anchors.centerIn: parent
Text {
text: Qt.formatDateTime(clock.date, "HH")
color: M.Theme.base0D
opacity: 0.85
font.pixelSize: 72
font.family: M.Theme.fontFamily
font.bold: true
anchors.verticalCenter: parent.verticalCenter
}
Text {
id: colon
text: ":"
font.pixelSize: 72
font.family: M.Theme.fontFamily
font.bold: true
opacity: 0.85
anchors.verticalCenter: parent.verticalCenter
property int _colorIdx: 0
readonly property var _colors: [M.Theme.base08, M.Theme.base09, M.Theme.base0A, M.Theme.base0B, M.Theme.base0C, M.Theme.base0D, M.Theme.base0E, M.Theme.base05]
color: _colors[_colorIdx % _colors.length]
Behavior on color {
enabled: !M.Theme.reducedMotion
ColorAnimation {
duration: 500
}
}
// Fired once per second in sync with the clock tick
SequentialAnimation {
id: colonAnim
NumberAnimation {
target: colon
property: "opacity"
to: 0.1
duration: 150
easing.type: Easing.InOutSine
}
NumberAnimation {
target: colon
property: "opacity"
to: 0.85
duration: 150
easing.type: Easing.InOutSine
}
}
Connections {
target: clock
function onDateChanged() {
if (M.Theme.reducedMotion)
return;
colon._colorIdx++;
colonAnim.restart();
}
}
}
Text {
text: Qt.formatDateTime(clock.date, "mm")
color: M.Theme.base0E
opacity: 0.85
font.pixelSize: 72
font.family: M.Theme.fontFamily
font.bold: true
anchors.verticalCenter: parent.verticalCenter
}
}
}
// Date with subtle glow
Text {
id: dateText
anchors.horizontalCenter: parent.horizontalCenter
text: Qt.formatDateTime(clock.date, "dddd, dd MMMM yyyy")
color: M.Theme.base05
opacity: 0.5
font.pixelSize: 18
font.family: M.Theme.fontFamily
font.letterSpacing: 4
layer.enabled: true
layer.effect: MultiEffect {
shadowEnabled: true
shadowColor: M.Theme.base0D
shadowBlur: 0.4
shadowVerticalOffset: 0
shadowHorizontalOffset: 0
}
}
// Seconds as a thin animated progress bar
Item {
anchors.horizontalCenter: parent.horizontalCenter
width: clockRow.width
height: 2
Rectangle {
anchors.fill: parent
color: M.Theme.base02
radius: 1
opacity: 0.3
}
Rectangle {
width: parent.width * (clock.date.getSeconds() / 59)
height: parent.height
color: colon._colors[colon._colorIdx % colon._colors.length]
Behavior on color {
enabled: !M.Theme.reducedMotion
ColorAnimation {
duration: 500
}
}
radius: 1
opacity: 0.6
Behavior on width {
enabled: !M.Theme.reducedMotion
NumberAnimation {
duration: 50
}
}
}
}
}
}

186
shell/modules/Backlight.qml Normal file
View file

@ -0,0 +1,186 @@
import QtQuick
import Quickshell
import Quickshell.Io
import "." as M
M.BarSection {
id: root
spacing: M.Theme.moduleSpacing
opacity: M.Modules.backlight.enable && percent > 0 ? 1 : 0
visible: opacity > 0
tooltip: ""
property int percent: 0
property bool _osdActive: false
property bool _percentInit: false
readonly property bool _showPanel: root._hovered || hoverPanel.panelHovered || _osdActive
onPercentChanged: {
if (!_percentInit) {
_percentInit = true;
return;
}
if (percent > 0)
_flashPanel();
}
function _flashPanel() {
_osdActive = true;
_osdTimer.restart();
}
Timer {
id: _osdTimer
interval: 1500
onTriggered: root._osdActive = false
}
Process {
id: adjProc
property string cmd: ""
command: ["sh", "-c", cmd]
onRunningChanged: if (!running && cmd !== "")
current.reload()
}
function adjust(delta) {
const step = M.Modules.backlight.step || 5;
adjProc.cmd = delta > 0 ? "light -A " + step : "light -U " + step;
adjProc.running = true;
}
function setPercent(pct) {
adjProc.cmd = "light -S " + Math.round(Math.max(0, Math.min(100, pct)));
adjProc.running = true;
}
property string _blDev: ""
Process {
id: detectBl
running: true
command: ["sh", "-c", "ls /sys/class/backlight/ 2>/dev/null | head -1"]
stdout: StdioCollector {
onStreamFinished: {
const dev = text.trim();
if (dev)
root._blDev = "/sys/class/backlight/" + dev;
}
}
}
FileView {
id: current
path: root._blDev ? root._blDev + "/brightness" : ""
watchChanges: true
onFileChanged: reload()
onLoaded: root._update()
}
FileView {
id: max
path: root._blDev ? root._blDev + "/max_brightness" : ""
onLoaded: root._update()
}
function _update() {
const c = parseInt(current.text());
const m = parseInt(max.text());
if (m > 0)
root.percent = Math.round((c / m) * 100);
}
M.BarIcon {
icon: "\uF185"
anchors.verticalCenter: parent.verticalCenter
}
M.BarLabel {
label: root.percent + "%"
minText: "100%"
anchors.verticalCenter: parent.verticalCenter
}
WheelHandler {
onWheel: event => root.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
Item {
width: parent.width
height: 36
Text {
id: blIcon
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "\uF185"
color: root.accentColor
font.pixelSize: M.Theme.fontSize + 2
font.family: M.Theme.iconFontFamily
}
Item {
id: slider
anchors.left: blIcon.right
anchors.leftMargin: 8
anchors.right: blLabel.left
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
height: 6
Rectangle {
anchors.fill: parent
color: M.Theme.base02
radius: 3
}
Rectangle {
width: parent.width * root.percent / 100
height: parent.height
color: root.accentColor
radius: 3
Behavior on width {
NumberAnimation {
duration: 80
}
}
}
MouseArea {
anchors.fill: parent
anchors.margins: -6
cursorShape: Qt.PointingHandCursor
onPressed: mouse => _set(mouse)
onPositionChanged: mouse => {
if (pressed)
_set(mouse);
}
function _set(mouse) {
root.setPercent(mouse.x / slider.width * 100);
}
}
}
Text {
id: blLabel
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: root.percent + "%"
color: M.Theme.base05
font.pixelSize: M.Theme.fontSize
font.family: M.Theme.fontFamily
width: 30
}
}
}
}

233
shell/modules/Bar.qml Normal file
View file

@ -0,0 +1,233 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Wayland
import "." as M
PanelWindow {
id: bar
required property var screen
color: "transparent"
WlrLayershell.layer: WlrLayer.Bottom
anchors {
top: true
left: true
right: true
}
implicitHeight: M.Theme.barHeight
exclusiveZone: implicitHeight
Rectangle {
anchors.fill: parent
color: M.Theme.base00
opacity: M.Theme.barOpacity
}
Canvas {
anchors.fill: parent
onPaint: {
const ctx = getContext("2d");
const w = width;
const h = height;
const r = M.Theme.screenRadius;
const lw = 3;
const hw = lw / 2;
ctx.clearRect(0, 0, w, h);
// Glow wash behind the border
const glowGrad = ctx.createLinearGradient(0, 0, w, 0);
glowGrad.addColorStop(0, M.Theme.base0C.toString());
glowGrad.addColorStop(1, M.Theme.base09.toString());
ctx.globalAlpha = 0.25;
ctx.fillStyle = glowGrad;
ctx.fillRect(0, 0, w, h);
// Erase glow towards bottom
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = "destination-out";
const glowFade = ctx.createLinearGradient(0, 0, 0, h);
glowFade.addColorStop(0, "transparent");
glowFade.addColorStop(1, "black");
ctx.fillStyle = glowFade;
ctx.fillRect(0, 0, w, h);
ctx.globalCompositeOperation = "source-over";
// Horizontal gradient for the border
const grad = ctx.createLinearGradient(0, 0, w, 0);
grad.addColorStop(0, M.Theme.base0C.toString());
grad.addColorStop(1, M.Theme.base09.toString());
ctx.strokeStyle = grad;
ctx.lineWidth = lw;
// Stroke: top + left side + right side (open bottom)
// Left side down, curve, across top, curve, right side down
ctx.beginPath();
if (r > lw) {
ctx.moveTo(hw, h);
ctx.lineTo(hw, r);
ctx.arc(r, r, r - hw, Math.PI, -Math.PI / 2);
ctx.lineTo(w - r, hw);
ctx.arc(w - r, r, r - hw, -Math.PI / 2, 0);
ctx.lineTo(w - hw, h);
} else {
ctx.moveTo(hw, h);
ctx.lineTo(hw, hw);
ctx.lineTo(w - hw, hw);
ctx.lineTo(w - hw, h);
}
ctx.stroke();
// Fade out the bottom of the side borders
ctx.globalCompositeOperation = "destination-out";
const fadeGrad = ctx.createLinearGradient(0, h * 0.5, 0, h);
fadeGrad.addColorStop(0, "transparent");
fadeGrad.addColorStop(1, "black");
ctx.fillStyle = fadeGrad;
ctx.fillRect(0, h * 0.5, w, h * 0.5);
ctx.globalCompositeOperation = "source-over";
}
}
Item {
anchors.fill: parent
anchors.topMargin: M.Theme.groupSpacing
anchors.leftMargin: M.Theme.groupSpacing
anchors.rightMargin: M.Theme.groupSpacing
// ---- center (declared first so left/right can anchor to it) ----
RowLayout {
id: centerSection
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
spacing: M.Theme.groupSpacing
M.BarGroup {
M.Privacy {}
M.Clock {
visible: M.Modules.clock.enable
}
M.Notifications {
bar: bar
visible: M.Modules.notifications.enable
}
}
}
// ---- left ----
RowLayout {
anchors.left: parent.left
anchors.right: centerSection.left
anchors.verticalCenter: parent.verticalCenter
spacing: M.Theme.groupSpacing
M.BarGroup {
id: workspacesGroup
leftEdge: true
M.Workspaces {
bar: bar
visible: M.Modules.workspaces.enable
}
}
M.BarGroup {
leftEdge: !workspacesGroup.visible
M.Tray {
bar: bar
}
}
M.BarGroup {
id: _windowTitleGroup
Layout.minimumWidth: 0
clip: true
visible: M.Modules.windowTitle.enable && M.NiriIpc.focusedTitle !== ""
M.WindowTitle {
id: _windowTitle
readonly property real _maxWidth: Math.max(0, centerSection.x - _windowTitleGroup.x - 2 * M.Theme.groupPadding - M.Theme.groupSpacing)
width: Math.min(naturalWidth, _maxWidth)
}
}
Item {
Layout.fillWidth: true
}
}
// ---- right ----
RowLayout {
anchors.left: centerSection.right
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: M.Theme.groupSpacing
Item {
Layout.fillWidth: true
}
// Media
M.BarGroup {
M.Mpris {
bar: bar
}
M.Volume {
visible: M.Modules.volume.enable
}
}
// Connectivity
M.BarGroup {
M.Network {
bar: bar
visible: M.Modules.network.enable
}
M.Bluetooth {
bar: bar
}
}
// Controls
M.BarGroup {
M.Backlight {}
M.PowerProfile {
visible: M.Modules.powerProfile.enable
}
M.IdleInhibitor {
visible: M.Modules.idleInhibitor.enable
}
}
// Stats
M.BarGroup {
M.Cpu {
visible: M.Modules.cpu.enable
}
M.Memory {
visible: M.Modules.memory.enable
}
M.Gpu {}
M.Temperature {
visible: M.Modules.temperature.enable
}
M.Weather {
visible: M.Modules.weather.enable
}
M.Disk {
visible: M.Modules.disk.enable
}
}
// Power
M.BarGroup {
rightEdge: true
M.Battery {}
M.Power {
bar: bar
visible: M.Modules.power.enable
}
}
}
}
}

166
shell/modules/BarGroup.qml Normal file
View file

@ -0,0 +1,166 @@
import QtQuick
import QtQuick.Effects
import Quickshell
import "." as M
Item {
id: root
default property alias content: row.children
// Auto-compute border color from top gradient position (base0C base09)
readonly property real _posFrac: {
const scr = QsWindow.window?.screen;
if (!scr)
return 0.5;
const gx = mapToGlobal(width / 2, 0).x - scr.x;
return Math.max(0, Math.min(1, gx / scr.width));
}
property color borderColor: Qt.rgba(M.Theme.base0C.r + (M.Theme.base09.r - M.Theme.base0C.r) * _posFrac, M.Theme.base0C.g + (M.Theme.base09.g - M.Theme.base0C.g) * _posFrac, M.Theme.base0C.b + (M.Theme.base09.b - M.Theme.base0C.b) * _posFrac, 1)
property bool leftEdge: false
property bool rightEdge: false
readonly property real _tlr: leftEdge ? M.Theme.screenRadius : M.Theme.radius
readonly property real _trr: rightEdge ? M.Theme.screenRadius : M.Theme.radius
readonly property real _blr: M.Theme.radius
readonly property real _brr: M.Theme.radius
visible: row.visibleChildren.length > 0
implicitWidth: row.implicitWidth + _pad * 2
implicitHeight: M.Theme.barHeight - 3 - _pad
readonly property int _pad: M.Theme.groupPadding
property bool _hovered: false
HoverHandler {
onHoveredChanged: root._hovered = hovered
}
// Frosted base semi-transparent so the bar background bleeds through
Rectangle {
anchors.fill: parent
topLeftRadius: root._tlr
topRightRadius: root._trr
bottomLeftRadius: root._blr
bottomRightRadius: root._brr
color: Qt.rgba(M.Theme.base01.r, M.Theme.base01.g, M.Theme.base01.b, 0.55)
}
// Frost sheen subtle white highlight, top-heavy
Rectangle {
anchors.fill: parent
topLeftRadius: root._tlr
topRightRadius: root._trr
bottomLeftRadius: root._blr
bottomRightRadius: root._brr
gradient: Gradient {
GradientStop {
position: 0
color: Qt.rgba(1, 1, 1, 0.07)
}
GradientStop {
position: 0.5
color: "transparent"
}
}
}
// Accent tint
Rectangle {
anchors.fill: parent
topLeftRadius: root._tlr
topRightRadius: root._trr
bottomLeftRadius: root._blr
bottomRightRadius: root._brr
gradient: Gradient {
GradientStop {
position: 0
color: Qt.rgba(root.borderColor.r, root.borderColor.g, root.borderColor.b, 0.12)
}
GradientStop {
position: 1
color: "transparent"
}
}
}
// Visible border
Rectangle {
anchors.fill: parent
color: "transparent"
border.color: root.borderColor
border.width: root._hovered ? 2 : 1
topLeftRadius: root._tlr
topRightRadius: root._trr
bottomLeftRadius: root._blr
bottomRightRadius: root._brr
Behavior on border.width {
NumberAnimation {
duration: 120
}
}
layer.enabled: root._hovered
layer.effect: MultiEffect {
shadowEnabled: true
shadowColor: root.borderColor
shadowBlur: 1.0
shadowVerticalOffset: 0
shadowHorizontalOffset: 0
}
}
// Pulsing accent fill hover glow breath
Rectangle {
anchors.fill: parent
topLeftRadius: root._tlr
topRightRadius: root._trr
bottomLeftRadius: root._blr
bottomRightRadius: root._brr
color: Qt.rgba(root.borderColor.r, root.borderColor.g, root.borderColor.b, _pulse)
visible: root._hovered
property real _pulse: 0.08
SequentialAnimation on _pulse {
running: root._hovered
loops: Animation.Infinite
NumberAnimation {
to: 0.22
duration: 700
easing.type: Easing.InOutSine
}
NumberAnimation {
to: 0.06
duration: 700
easing.type: Easing.InOutSine
}
}
}
Row {
id: row
property color accentColor: root.borderColor
anchors.centerIn: parent
spacing: M.Theme.moduleSpacing + 2
}
// Separator lines overlaid between visible row children
Repeater {
model: row.visibleChildren.length > 1 ? row.visibleChildren.length - 1 : 0
delegate: Rectangle {
required property int index
readonly property Item _left: row.visibleChildren[index]
readonly property real _rightEdge: _left ? _left.x + _left.width : 0
x: row.x + _rightEdge + row.spacing / 2
y: row.y
width: 1
height: row.implicitHeight
color: Qt.rgba(root.borderColor.r, root.borderColor.g, root.borderColor.b, 0.4)
}
}
}

74
shell/modules/BarIcon.qml Normal file
View file

@ -0,0 +1,74 @@
import QtQuick
import Quickshell
import "." as M
Text {
id: root
property string icon: ""
property string tooltip: ""
property string minIcon: ""
property color accentColor: parent?.accentColor ?? M.Theme.base05
property bool _hovered: false
property string _displayIcon: icon
property string _pendingIcon: ""
text: _displayIcon
onIconChanged: {
_pendingIcon = icon;
if (!_crossfade.running)
_crossfade.start();
}
SequentialAnimation {
id: _crossfade
NumberAnimation {
target: root
property: "opacity"
to: 0
duration: 60
easing.type: Easing.InQuad
}
ScriptAction {
script: root._displayIcon = root._pendingIcon
}
NumberAnimation {
target: root
property: "opacity"
to: 1
duration: 100
easing.type: Easing.OutQuad
}
}
width: minIcon ? Math.max(implicitWidth, _minIconMetrics.width) : implicitWidth
horizontalAlignment: minIcon ? Text.AlignHCenter : Text.AlignLeft
color: root.accentColor
font.pixelSize: M.Theme.fontSize + 1
font.family: M.Theme.iconFontFamily
verticalAlignment: Text.AlignVCenter
TextMetrics {
id: _minIconMetrics
text: root.minIcon
font.pixelSize: root.font.pixelSize
font.family: root.font.family
}
HoverHandler {
onHoveredChanged: {
root._hovered = hovered;
if (hovered && root.tooltip !== "") {
M.FlyoutState.text = root.tooltip;
M.FlyoutState.itemX = root.mapToGlobal(root.width / 2, 0).x - (QsWindow.window?.screen?.x ?? 0);
M.FlyoutState.screen = QsWindow.window?.screen ?? null;
M.FlyoutState.accentColor = root.accentColor;
M.FlyoutState.visible = true;
} else if (!hovered && root.tooltip !== "") {
M.FlyoutState.visible = false;
}
}
}
onTooltipChanged: if (_hovered && tooltip !== "")
M.FlyoutState.text = tooltip
}

View file

@ -0,0 +1,45 @@
import QtQuick
import Quickshell
import "." as M
Text {
id: root
property string label: ""
property string tooltip: ""
property string minText: ""
property color accentColor: parent?.accentColor ?? M.Theme.base05
property bool _hovered: false
text: label
width: minText ? Math.max(implicitWidth, _minMetrics.width) : implicitWidth
horizontalAlignment: minText ? Text.AlignHCenter : Text.AlignLeft
color: root.accentColor
font.pixelSize: M.Theme.fontSize
font.family: M.Theme.fontFamily
verticalAlignment: Text.AlignVCenter
TextMetrics {
id: _minMetrics
text: root.minText
font.pixelSize: root.font.pixelSize
font.family: root.font.family
}
HoverHandler {
onHoveredChanged: {
root._hovered = hovered;
if (hovered && root.tooltip !== "") {
M.FlyoutState.text = root.tooltip;
M.FlyoutState.itemX = root.mapToGlobal(root.width / 2, 0).x - (QsWindow.window?.screen?.x ?? 0);
M.FlyoutState.screen = QsWindow.window?.screen ?? null;
M.FlyoutState.accentColor = root.accentColor;
M.FlyoutState.visible = true;
} else if (!hovered && root.tooltip !== "") {
M.FlyoutState.visible = false;
}
}
}
onTooltipChanged: if (_hovered && tooltip !== "")
M.FlyoutState.text = tooltip
}

View file

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

419
shell/modules/Battery.qml Normal file
View file

@ -0,0 +1,419 @@
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.UPower
import "." as M
M.BarSection {
id: root
spacing: M.Theme.moduleSpacing
opacity: M.Modules.battery.enable && (UPower.displayDevice?.isLaptopBattery ?? false) ? 1 : 0
visible: opacity > 0
tooltip: ""
readonly property var dev: UPower.displayDevice
readonly property real pct: (dev?.percentage ?? 0) * 100
readonly property bool charging: dev?.state === UPowerDeviceState.Charging
readonly property int _critThresh: M.Modules.battery.critical || 15
readonly property int _warnThresh: M.Modules.battery.warning || 25
readonly property bool _critical: pct < _critThresh && !charging
property color _stateColor: charging ? M.Theme.base0B : _critical ? M.Theme.base09 : pct < _warnThresh ? M.Theme.base0A : root.accentColor
property real _blinkOpacity: 1
SequentialAnimation {
running: root._critical
loops: Animation.Infinite
NumberAnimation {
target: root
property: "_blinkOpacity"
to: 0.45
duration: 400
easing.type: Easing.InOutQuad
}
NumberAnimation {
target: root
property: "_blinkOpacity"
to: 1
duration: 400
easing.type: Easing.InOutQuad
}
onRunningChanged: if (!running)
root._blinkOpacity = 1
}
// Notifications
property bool _warnSent: false
property bool _critSent: false
onChargingChanged: {
_warnSent = false;
_critSent = false;
}
onPctChanged: {
if (charging)
return;
if (pct < _critThresh && !_critSent) {
_critSent = true;
_warnSent = true;
_notif.command = ["notify-send", "--urgency=critical", "--icon=battery-low", "--category=device", "Very Low Battery", "Connect to power now!"];
_notif.running = true;
} else if (pct < _warnThresh && !_warnSent) {
_warnSent = true;
_notif.command = ["notify-send", "--icon=battery-caution", "--category=device", "Low Battery"];
_notif.running = true;
}
}
Process {
id: _notif
}
// History (always-running, 1440 samples @ 60s = 24h)
property var _history: []
Timer {
interval: 60000
running: root.visible
repeat: true
triggeredOnStart: true
onTriggered: {
const h = root._history.concat([root.pct]);
root._history = h.length > 1440 ? h.slice(h.length - 1440) : h;
}
}
// Panel state
property bool _pinned: false
readonly property bool _anyHover: root._hovered || hoverPanel.panelHovered
readonly property bool _showPanel: _anyHover || _pinned
on_AnyHoverChanged: {
if (_anyHover)
_unpinTimer.stop();
else if (_pinned)
_unpinTimer.start();
}
Timer {
id: _unpinTimer
interval: 500
onTriggered: root._pinned = false
}
function _fmtTime(secs) {
if (!secs || secs <= 0)
return "";
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
return h > 0 ? h + "h " + m + "m" : m + "m";
}
// Bar widgets
M.BarIcon {
icon: {
if (root.charging)
return "\uDB80\uDC84";
const icons = ["\uDB80\uDC8E", "\uDB80\uDC7A", "\uDB80\uDC7B", "\uDB80\uDC7C", "\uDB80\uDC7D", "\uDB80\uDC7E", "\uDB80\uDC7F", "\uDB80\uDC80", "\uDB80\uDC81", "\uDB80\uDC82", "\uDB85\uDFE2"];
return icons[Math.min(10, Math.floor(root.pct / 10))];
}
color: root._stateColor
opacity: root._blinkOpacity
font.pixelSize: M.Theme.fontSize + 2
anchors.verticalCenter: parent.verticalCenter
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: root._pinned = !root._pinned
}
}
M.BarLabel {
label: Math.round(root.pct) + "%"
minText: "100%"
color: root._stateColor
opacity: root._blinkOpacity
anchors.verticalCenter: parent.verticalCenter
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: root._pinned = !root._pinned
}
}
// Hover panel
M.HoverPanel {
id: hoverPanel
showPanel: root._showPanel
screen: QsWindow.window?.screen ?? null
anchorItem: root
accentColor: root.accentColor
panelNamespace: "nova-battery"
panelTitle: "Battery"
contentWidth: 240
// Header pct + time
Item {
width: parent.width
height: 28
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: {
const t = root.charging ? root.dev?.timeToFull : root.dev?.timeToEmpty;
const ts = root._fmtTime(t);
return Math.round(root.pct) + "%" + (ts ? " " + ts : "");
}
color: root._stateColor
font.pixelSize: M.Theme.fontSize
font.family: M.Theme.fontFamily
font.bold: true
}
}
// Progress bar
Item {
width: parent.width
height: 14
Item {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
height: 6
Rectangle {
anchors.fill: parent
color: M.Theme.base02
radius: 3
}
Rectangle {
width: parent.width * Math.min(1, root.pct / 100)
height: parent.height
color: root._stateColor
radius: 3
Behavior on width {
enabled: root._showPanel
NumberAnimation {
duration: 300
easing.type: Easing.OutCubic
}
}
}
// Warning threshold marker
Rectangle {
x: parent.width * (root._warnThresh / 100) - 1
width: 1
height: parent.height + 4
anchors.verticalCenter: parent.verticalCenter
color: M.Theme.base0A
opacity: 0.6
}
// Critical threshold marker
Rectangle {
x: parent.width * (root._critThresh / 100) - 1
width: 1
height: parent.height + 4
anchors.verticalCenter: parent.verticalCenter
color: M.Theme.base08
opacity: 0.6
}
}
}
// 24h history sparkline (area chart)
Canvas {
id: _sparkline
anchors.left: parent.left
anchors.leftMargin: 12
anchors.right: parent.right
anchors.rightMargin: 12
height: 44
property var _hist: root._history
property color _col: root._stateColor
on_HistChanged: if (root._showPanel)
requestPaint()
on_ColChanged: if (root._showPanel)
requestPaint()
Connections {
target: root
function on_ShowPanelChanged() {
if (root._showPanel)
_sparkline.requestPaint();
}
}
onPaint: {
const ctx = getContext("2d");
if (!ctx)
return;
ctx.clearRect(0, 0, width, height);
const d = _hist;
if (d.length < 2)
return;
const maxSamples = 1440;
const xScale = width / maxSamples;
const xOffset = (maxSamples - d.length) * xScale;
// Background tint
ctx.fillStyle = Qt.rgba(_col.r, _col.g, _col.b, 0.07).toString();
ctx.fillRect(0, 0, width, height);
// Warning threshold line
const warnY = height - height * (root._warnThresh / 100);
ctx.strokeStyle = M.Theme.base0A.toString();
ctx.globalAlpha = 0.3;
ctx.lineWidth = 1;
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.moveTo(0, warnY);
ctx.lineTo(width, warnY);
ctx.stroke();
// Critical threshold line
const critY = height - height * (root._critThresh / 100);
ctx.strokeStyle = M.Theme.base08.toString();
ctx.beginPath();
ctx.moveTo(0, critY);
ctx.lineTo(width, critY);
ctx.stroke();
ctx.setLineDash([]);
ctx.globalAlpha = 1.0;
// Filled area under the curve
ctx.beginPath();
ctx.moveTo(xOffset, height);
for (let i = 0; i < d.length; i++) {
const x = xOffset + i * xScale;
const y = height - height * (d[i] / 100);
ctx.lineTo(x, y);
}
ctx.lineTo(xOffset + (d.length - 1) * xScale, height);
ctx.closePath();
ctx.fillStyle = Qt.rgba(_col.r, _col.g, _col.b, 0.18).toString();
ctx.fill();
// Top line
ctx.beginPath();
for (let i = 0; i < d.length; i++) {
const x = xOffset + i * xScale;
const y = height - height * (d[i] / 100);
if (i === 0)
ctx.moveTo(x, y);
else
ctx.lineTo(x, y);
}
ctx.strokeStyle = _col.toString();
ctx.lineWidth = 1.5;
ctx.stroke();
}
}
// Footer: thresholds + time label
Item {
width: parent.width
height: 16
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "warn " + root._warnThresh + "% crit " + root._critThresh + "%"
color: M.Theme.base03
font.pixelSize: M.Theme.fontSize - 3
font.family: M.Theme.fontFamily
font.letterSpacing: 0.5
}
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "24h"
color: M.Theme.base03
font.pixelSize: M.Theme.fontSize - 3
font.family: M.Theme.fontFamily
}
}
// Separator
Rectangle {
width: parent.width - 16
height: 1
anchors.horizontalCenter: parent.horizontalCenter
color: M.Theme.base03
}
// Rate + health rows
Item {
width: parent.width
height: 20
visible: (root.dev?.changeRate ?? 0) !== 0
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: root.charging ? "Charging" : "Discharging"
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
}
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: {
const r = Math.abs(root.dev?.changeRate ?? 0);
return r > 0 ? r.toFixed(1) + " W" : "";
}
color: root._stateColor
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
}
}
Item {
width: parent.width
height: 20
visible: root.dev?.healthSupported ?? false
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "Health"
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
}
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: Math.round((root.dev?.healthPercentage ?? 0) * 100) + "%"
color: {
const h = (root.dev?.healthPercentage ?? 1) * 100;
return h < 50 ? M.Theme.base08 : h < 75 ? M.Theme.base0A : M.Theme.base0B;
}
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
}
}
Item {
width: 1
height: 4
}
}
}

103
shell/modules/Bluetooth.qml Normal file
View file

@ -0,0 +1,103 @@
import QtQuick
import Quickshell
import Quickshell.Io
import "." as M
M.BarSection {
id: root
spacing: M.Theme.moduleSpacing
opacity: M.Modules.bluetooth.enable && root.state !== "unavailable" ? 1 : 0
visible: opacity > 0
tooltip: {
if (root.state === "off")
return "Bluetooth: off";
if (root.state === "connected")
return "Bluetooth: " + root.device + (root.batteryPct >= 0 ? "\nBattery: " + root.batteryPct + "%" : "");
return "Bluetooth: on";
}
property string state: "unavailable"
property string device: ""
property int batteryPct: -1
function _parse(text) {
const lines = text.trim().split("\n");
const t = lines[0] || "";
const sep = t.indexOf(":");
root.state = sep === -1 ? t : t.slice(0, sep);
root.device = sep === -1 ? "" : t.slice(sep + 1);
root.batteryPct = -1;
for (let i = 1; i < lines.length; i++) {
if (lines[i].startsWith("bat:"))
root.batteryPct = parseInt(lines[i].slice(4)) || -1;
}
}
Process {
id: proc
running: M.Modules.bluetooth.enable
command: ["sh", "-c", "s=$(bluetoothctl show 2>/dev/null); " + "[ -z \"$s\" ] && echo unavailable && exit; " + "echo \"$s\" | grep -q 'Powered: yes' || { echo off:; exit; }; " + "info=$(bluetoothctl info 2>/dev/null); " + "d=$(echo \"$info\" | awk -F': ' '/\\tName:/{n=$2}/Connected: yes/{c=1}END{if(c)print n}'); " + "[ -n \"$d\" ] && echo \"connected:$d\" || { echo on:; exit; }; " + "bat=$(echo \"$info\" | awk -F': ' '/Battery Percentage.*\\(/{gsub(/[^0-9]/,\"\",$2);print $2}'); " + "[ -n \"$bat\" ] && echo \"bat:$bat\""]
stdout: StdioCollector {
onStreamFinished: root._parse(text)
}
}
// Event-driven: watch BlueZ DBus property changes
Process {
id: btMonitor
running: M.Modules.bluetooth.enable
command: ["sh", "-c", "dbus-monitor --system \"interface='org.freedesktop.DBus.Properties',member='PropertiesChanged',path_namespace='/org/bluez'\" 2>/dev/null"]
stdout: SplitParser {
splitMarker: "\n"
onRead: _debounce.restart()
}
}
Timer {
id: _debounce
interval: 500
onTriggered: proc.running = true
}
Timer {
interval: 60000
running: M.Modules.bluetooth.enable
repeat: true
onTriggered: proc.running = true
}
M.BarIcon {
icon: "\uF294"
color: root.state === "off" ? M.Theme.base04 : root.accentColor
anchors.verticalCenter: parent.verticalCenter
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: {
M.FlyoutState.visible = false;
btLoader.active = true;
}
}
}
M.BarLabel {
visible: root.state === "connected"
label: root.device + (root.batteryPct >= 0 ? " " + root.batteryPct + "%" : "")
anchors.verticalCenter: parent.verticalCenter
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: {
M.FlyoutState.visible = false;
btLoader.active = true;
}
}
}
required property var bar
LazyLoader {
id: btLoader
active: false
M.BluetoothMenu {
accentColor: root.accentColor
screen: QsWindow.window?.screen ?? null
anchorX: root.mapToGlobal(root.width / 2, 0).x - (QsWindow.window?.screen?.x ?? 0)
onDismissed: btLoader.active = false
}
}
}

View file

@ -0,0 +1,188 @@
import QtQuick
import Quickshell
import Quickshell.Io
import "." as M
M.HoverPanel {
id: menuWindow
contentWidth: 250
panelNamespace: "nova-bluetooth"
popupMode: true
panelTitle: "Bluetooth"
titleActionsComponent: Component {
Item {
width: 20
height: 20
Text {
anchors.centerIn: parent
text: "\uF011"
color: menuWindow._btEnabled ? menuWindow.accentColor : M.Theme.base04
font.pixelSize: M.Theme.fontSize
font.family: M.Theme.iconFontFamily
Behavior on color {
ColorAnimation {
duration: 100
}
}
}
HoverHandler {
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: {
powerProc._action = menuWindow._btEnabled ? "off" : "on";
powerProc.running = true;
}
}
}
}
onVisibleChanged: if (visible)
scanner.running = true
property var _devices: []
property bool _btEnabled: true
property Process _scanner: Process {
id: scanner
running: false
command: ["sh", "-c", "bluetoothctl show 2>/dev/null | awk '/Powered:/{print $2; exit}';" + "echo '---DEVICES---';" + "bluetoothctl devices Paired 2>/dev/null | while read -r _ mac name; do " + "info=$(bluetoothctl info \"$mac\" 2>/dev/null); " + "conn=$(echo \"$info\" | grep -c 'Connected: yes'); " + "bat=$(echo \"$info\" | awk -F'[(): ]' '/Battery Percentage/{for(i=1;i<=NF;i++) if($i+0==$i && $i!=\"\") print $i}'); " + "echo \"$mac:$conn:${bat:-}:$name\"; " + "done"]
stdout: StdioCollector {
onStreamFinished: {
const sections = text.split("---DEVICES---");
menuWindow._btEnabled = (sections[0] || "").trim() === "yes";
const devs = [];
for (const line of (sections[1] || "").trim().split("\n")) {
if (!line)
continue;
const i1 = line.indexOf(":");
const i2 = line.indexOf(":", i1 + 1);
const i3 = line.indexOf(":", i2 + 1);
if (i3 < 0)
continue;
devs.push({
"mac": line.slice(0, i1),
"connected": line.slice(i1 + 1, i2) === "1",
"battery": parseInt(line.slice(i2 + 1, i3)) || -1,
"name": line.slice(i3 + 1)
});
}
devs.sort((a, b) => {
if (a.connected !== b.connected)
return a.connected ? -1 : 1;
return a.name.localeCompare(b.name);
});
menuWindow._devices = devs;
}
}
}
property Process _powerProc: Process {
id: powerProc
property string _action: ""
command: ["bluetoothctl", "power", _action]
onRunningChanged: if (!running) {
scanner.running = true;
menuWindow.keepOpen(500);
}
}
property Process _toggleProc: Process {
id: toggleProc
property string action: ""
property string mac: ""
command: ["bluetoothctl", action, mac]
onRunningChanged: if (!running) {
scanner.running = true;
menuWindow.keepOpen(500);
}
}
Repeater {
model: menuWindow._devices
delegate: Item {
id: entry
required property var modelData
required property int index
width: menuWindow.contentWidth
height: 32
Rectangle {
anchors.fill: parent
anchors.leftMargin: 4
anchors.rightMargin: 4
color: entryHover.hovered ? M.Theme.base02 : "transparent"
radius: M.Theme.radius
}
Text {
id: btIcon
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "\uF294"
color: entry.modelData.connected ? menuWindow.accentColor : M.Theme.base04
font.pixelSize: M.Theme.fontSize + 1
font.family: M.Theme.iconFontFamily
}
Text {
anchors.left: btIcon.right
anchors.leftMargin: 8
anchors.right: batLabel.left
anchors.rightMargin: 4
anchors.verticalCenter: parent.verticalCenter
text: entry.modelData.name
color: entry.modelData.connected ? menuWindow.accentColor : M.Theme.base05
font.pixelSize: M.Theme.fontSize
font.family: M.Theme.fontFamily
font.bold: entry.modelData.connected
elide: Text.ElideRight
}
Text {
id: batLabel
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: entry.modelData.battery >= 0 ? entry.modelData.battery + "%" : ""
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize - 1
font.family: M.Theme.fontFamily
width: entry.modelData.battery >= 0 ? implicitWidth : 0
}
HoverHandler {
id: entryHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: {
toggleProc.action = entry.modelData.connected ? "disconnect" : "connect";
toggleProc.mac = entry.modelData.mac;
toggleProc.running = true;
}
}
}
}
Text {
visible: menuWindow._devices.length === 0
width: menuWindow.contentWidth
height: 32
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
text: menuWindow._btEnabled ? "No paired devices" : "Bluetooth is off"
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize
font.family: M.Theme.fontFamily
}
}

14
shell/modules/Clock.qml Normal file
View file

@ -0,0 +1,14 @@
import QtQuick
import Quickshell
import "." as M
M.BarLabel {
SystemClock {
id: clock
precision: SystemClock.Seconds
}
font.pixelSize: M.Theme.fontSize + 1
label: Qt.formatDateTime(clock.date, "ddd, dd. MMM HH:mm")
tooltip: Qt.formatDateTime(clock.date, "dddd, dd. MMMM yyyy\nHH:mm:ss")
}

302
shell/modules/Cpu.qml Normal file
View file

@ -0,0 +1,302 @@
import QtQuick
import Quickshell
import "." as M
M.BarSection {
id: root
spacing: Math.max(1, M.Theme.moduleSpacing - 2)
tooltip: ""
readonly property var _cores: M.SystemStats.cpuCores
readonly property var _coreMaxFreq: M.SystemStats.cpuCoreMaxFreq
readonly property var _coreTypes: M.SystemStats.cpuCoreTypes
property bool _pinned: false
readonly property bool _anyHover: root._hovered || hoverPanel.panelHovered
readonly property bool _showPanel: _anyHover || _pinned
property bool _coreConsumerActive: false
on_ShowPanelChanged: {
if (_showPanel && !_coreConsumerActive) {
_coreConsumerActive = true;
M.SystemStats.coreConsumers++;
} else if (!_showPanel && _coreConsumerActive) {
_coreConsumerActive = false;
M.SystemStats.coreConsumers--;
}
}
property M.ProcessList _procs: M.ProcessList {
sortBy: "cpu"
active: root._showPanel
onProcessesChanged: hoverPanel.keepOpen(300)
}
on_AnyHoverChanged: {
if (_anyHover)
_unpinTimer.stop();
else if (_pinned)
_unpinTimer.start();
}
Timer {
id: _unpinTimer
interval: 500
onTriggered: root._pinned = false
}
function _loadColor(pct) {
const t = Math.max(0, Math.min(100, pct)) / 100;
const a = t < 0.5 ? M.Theme.base0B : M.Theme.base0A;
const b = t < 0.5 ? M.Theme.base0A : M.Theme.base08;
const u = t < 0.5 ? t * 2 : (t - 0.5) * 2;
return Qt.rgba(a.r + (b.r - a.r) * u, a.g + (b.g - a.g) * u, a.b + (b.b - a.b) * u, 1);
}
M.BarIcon {
icon: "\uF2DB"
anchors.verticalCenter: parent.verticalCenter
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: root._pinned = !root._pinned
}
}
M.BarLabel {
label: M.SystemStats.cpuUsage.toString().padStart(2) + "%@" + M.SystemStats.cpuFreqGhz.toFixed(2)
minText: "99%@9.99"
anchors.verticalCenter: parent.verticalCenter
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: root._pinned = !root._pinned
}
}
M.HoverPanel {
id: hoverPanel
showPanel: root._showPanel
screen: QsWindow.window?.screen ?? null
anchorItem: root
accentColor: root.accentColor
panelNamespace: "nova-cpu"
panelTitle: "CPU"
contentWidth: 260
// Per-core rows
Repeater {
model: root._cores.length
delegate: Item {
required property int index
width: hoverPanel.contentWidth
readonly property int _u: root._cores[index]?.usage ?? 0
readonly property real _f: root._cores[index]?.freq_ghz ?? 0
readonly property color _barColor: root._loadColor(_u)
readonly property bool _throttled: {
const maxF = root._coreMaxFreq[index] ?? 0;
return maxF > 0 && _f < maxF * 0.85 && _u >= 60;
}
readonly property bool _isFirstECore: {
const types = root._coreTypes;
if (!types.length || index >= types.length)
return false;
if (types[index] !== "Efficiency")
return false;
return index === 0 || types[index - 1] !== "Efficiency";
}
height: _isFirstECore ? 28 : 20
// P/E-core divider
Rectangle {
visible: parent._isFirstECore
anchors.top: parent.top
anchors.topMargin: 3
anchors.horizontalCenter: parent.horizontalCenter
width: parent.width - 16
height: 1
color: M.Theme.base03
}
// Row content pinned to bottom of delegate
Item {
anchors.bottom: parent.bottom
width: parent.width
height: 20
Text {
id: coreLabel
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: index
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
width: 16
}
Item {
id: coreBar
anchors.left: coreLabel.right
anchors.leftMargin: 6
anchors.right: sparkline.left
anchors.rightMargin: 6
anchors.verticalCenter: parent.verticalCenter
height: 4
Rectangle {
anchors.fill: parent
color: M.Theme.base02
radius: 2
}
Rectangle {
width: parent.width * (parent.parent.parent._u / 100)
height: parent.height
color: parent.parent.parent._barColor
radius: 2
Behavior on width {
enabled: root._showPanel
NumberAnimation {
duration: 150
}
}
}
}
// Sparkline
Canvas {
id: sparkline
anchors.right: freqLabel.left
anchors.rightMargin: 6
anchors.verticalCenter: parent.verticalCenter
width: 32
height: 10
property var _hist: root._cores[parent.parent.index]?.history ?? []
property color _col: parent.parent._barColor
on_HistChanged: if (root._showPanel)
requestPaint()
on_ColChanged: if (root._showPanel)
requestPaint()
Connections {
target: root
function on_ShowPanelChanged() {
if (root._showPanel)
sparkline.requestPaint();
}
}
onPaint: {
const ctx = getContext("2d");
if (!ctx)
return;
ctx.clearRect(0, 0, width, height);
const d = _hist;
if (!d.length)
return;
const bw = width / d.length;
ctx.fillStyle = _col.toString();
for (let i = 0; i < d.length; i++) {
const h = Math.max(1, height * d[i] / 100);
ctx.fillRect(i * bw, height - h, Math.max(1, bw - 0.5), h);
}
}
}
Text {
id: freqLabel
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: parent.parent._f.toFixed(2)
color: parent.parent._throttled ? M.Theme.base08 : M.Theme.base04
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
width: 34
horizontalAlignment: Text.AlignRight
}
}
}
}
// Process list separator
Rectangle {
width: parent.width - 16
height: 1
anchors.horizontalCenter: parent.horizontalCenter
color: M.Theme.base03
}
Item {
width: hoverPanel.contentWidth
height: 18
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "PROCESS"
color: M.Theme.base03
font.pixelSize: M.Theme.fontSize - 3
font.family: M.Theme.fontFamily
font.letterSpacing: 1
}
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "CPU"
color: M.Theme.base03
font.pixelSize: M.Theme.fontSize - 3
font.family: M.Theme.fontFamily
font.letterSpacing: 1
}
}
// Top processes by CPU
Repeater {
model: root._procs.processes
delegate: Item {
required property var modelData
width: hoverPanel.contentWidth
height: 20
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: modelData.cmd
color: M.Theme.base05
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
elide: Text.ElideRight
width: parent.width - 80
}
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: modelData.cpu.toFixed(1) + "%"
color: root._loadColor(modelData.cpu)
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
width: 36
horizontalAlignment: Text.AlignRight
}
}
}
Item {
width: 1
height: 4
}
}
}

145
shell/modules/Disk.qml Normal file
View file

@ -0,0 +1,145 @@
import QtQuick
import Quickshell
import "." as M
M.BarSection {
id: root
spacing: Math.max(1, M.Theme.moduleSpacing - 2)
tooltip: ""
property var _mounts: M.SystemStats.diskMounts
property int _rootPct: M.SystemStats.diskRootPct
function _fmt(bytes) {
if (bytes >= 1e12)
return (bytes / 1e12).toFixed(1) + "T";
if (bytes >= 1e9)
return Math.round(bytes / 1e9) + "G";
if (bytes >= 1e6)
return Math.round(bytes / 1e6) + "M";
return bytes + "B";
}
function _barColor(pct) {
const t = Math.max(0, Math.min(100, pct)) / 100;
const a = t < 0.5 ? M.Theme.base0B : M.Theme.base0A;
const b = t < 0.5 ? M.Theme.base0A : M.Theme.base08;
const u = t < 0.5 ? t * 2 : (t - 0.5) * 2;
return Qt.rgba(a.r + (b.r - a.r) * u, a.g + (b.g - a.g) * u, a.b + (b.b - a.b) * u, 1);
}
property bool _pinned: false
readonly property bool _anyHover: root._hovered || hoverPanel.panelHovered
readonly property bool _showPanel: _anyHover || _pinned
on_AnyHoverChanged: {
if (_anyHover)
_unpinTimer.stop();
else if (_pinned)
_unpinTimer.start();
}
Timer {
id: _unpinTimer
interval: 500
onTriggered: root._pinned = false
}
M.BarIcon {
icon: "\uF0C9"
anchors.verticalCenter: parent.verticalCenter
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: root._pinned = !root._pinned
}
}
M.BarLabel {
label: root._rootPct + "%"
minText: "100%"
anchors.verticalCenter: parent.verticalCenter
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: root._pinned = !root._pinned
}
}
M.HoverPanel {
id: hoverPanel
showPanel: root._showPanel
screen: QsWindow.window?.screen ?? null
anchorItem: root
accentColor: root.accentColor
panelNamespace: "nova-disk"
panelTitle: "Disk"
contentWidth: 260
Repeater {
model: root._mounts
delegate: Item {
required property var modelData
width: hoverPanel.contentWidth
height: 22
Text {
id: mountLabel
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: modelData.target
color: M.Theme.base05
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
elide: Text.ElideRight
width: 72
}
Item {
id: mountBar
anchors.left: mountLabel.right
anchors.leftMargin: 6
anchors.right: sizeLabel.left
anchors.rightMargin: 6
anchors.verticalCenter: parent.verticalCenter
height: 4
Rectangle {
anchors.fill: parent
color: M.Theme.base02
radius: 2
}
Rectangle {
width: parent.width * (modelData.pct / 100)
height: parent.height
color: root._barColor(modelData.pct)
radius: 2
Behavior on width {
NumberAnimation {
duration: 200
}
}
}
}
Text {
id: sizeLabel
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: root._fmt(modelData.usedBytes) + "/" + root._fmt(modelData.totalBytes)
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
width: 72
horizontalAlignment: Text.AlignRight
}
}
}
Item {
width: 1
height: 4
}
}
}

101
shell/modules/Flyout.qml Normal file
View file

@ -0,0 +1,101 @@
import QtQuick
import Quickshell
import Quickshell.Wayland
import "." as M
PanelWindow {
id: root
required property var screen
visible: _winVisible
color: "transparent"
property bool _winVisible: false
property bool _shown: M.FlyoutState.visible && M.FlyoutState.screen === root.screen
on_ShownChanged: {
if (_shown) {
_winVisible = true;
hideAnim.stop();
showAnim.start();
} else {
showAnim.stop();
hideAnim.start();
}
}
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.exclusiveZone: 0
WlrLayershell.namespace: "nova-flyout"
anchors.top: true
anchors.left: true
margins.top: 0
margins.left: Math.max(0, Math.min(Math.round(M.FlyoutState.itemX - implicitWidth / 2), screen.width - implicitWidth))
implicitWidth: label.implicitWidth + M.Theme.barPadding * 2
implicitHeight: label.implicitHeight + M.Theme.barPadding * 2
ParallelAnimation {
id: showAnim
NumberAnimation {
target: content
property: "opacity"
to: 1
duration: 120
easing.type: Easing.OutCubic
}
NumberAnimation {
target: content
property: "y"
to: 0
duration: 150
easing.type: Easing.OutCubic
}
}
ParallelAnimation {
id: hideAnim
NumberAnimation {
target: content
property: "opacity"
to: 0
duration: 150
easing.type: Easing.InCubic
}
NumberAnimation {
target: content
property: "y"
to: -content.height
duration: 150
easing.type: Easing.InCubic
}
onFinished: root._winVisible = false
}
Item {
id: content
anchors.left: parent.left
anchors.right: parent.right
height: root.implicitHeight
opacity: 0
y: -height
M.PopupBackground {
anchors.fill: parent
accentColor: M.FlyoutState.accentColor
}
Text {
id: label
anchors.centerIn: parent
text: M.FlyoutState.text.replace(/\n/g, "<br>")
textFormat: Text.RichText
color: M.Theme.base05
font.pixelSize: M.Theme.fontSize
font.family: M.Theme.fontFamily
}
}
}

View file

@ -0,0 +1,11 @@
pragma Singleton
import QtQuick
import "." as M
QtObject {
property bool visible: false
property string text: ""
property real itemX: 0
property var screen: null
property color accentColor: M.Theme.base05
}

276
shell/modules/Gpu.qml Normal file
View file

@ -0,0 +1,276 @@
import QtQuick
import Quickshell
import "." as M
M.BarSection {
id: root
spacing: Math.max(1, M.Theme.moduleSpacing - 2)
tooltip: ""
visible: M.Modules.gpu.enable && M.SystemStats.gpuAvailable
property bool _pinned: false
readonly property bool _anyHover: root._hovered || hoverPanel.panelHovered
readonly property bool _showPanel: _anyHover || _pinned
on_AnyHoverChanged: {
if (_anyHover)
_unpinTimer.stop();
else if (_pinned)
_unpinTimer.start();
}
Timer {
id: _unpinTimer
interval: 500
onTriggered: root._pinned = false
}
function _loadColor(pct) {
const t = Math.max(0, Math.min(100, pct)) / 100;
const a = t < 0.5 ? M.Theme.base0B : M.Theme.base0A;
const b = t < 0.5 ? M.Theme.base0A : M.Theme.base08;
const u = t < 0.5 ? t * 2 : (t - 0.5) * 2;
return Qt.rgba(a.r + (b.r - a.r) * u, a.g + (b.g - a.g) * u, a.b + (b.b - a.b) * u, 1);
}
function _fmt(gb) {
return gb >= 10 ? gb.toFixed(1) + "G" : gb.toFixed(2) + "G";
}
M.BarIcon {
icon: "\uDB84\uDCB0"
color: root._loadColor(M.SystemStats.gpuUsage)
anchors.verticalCenter: parent.verticalCenter
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: root._pinned = !root._pinned
}
}
M.BarLabel {
label: M.SystemStats.gpuUsage + "%"
minText: "100%"
color: root._loadColor(M.SystemStats.gpuUsage)
anchors.verticalCenter: parent.verticalCenter
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: root._pinned = !root._pinned
}
}
M.HoverPanel {
id: hoverPanel
showPanel: root._showPanel
screen: QsWindow.window?.screen ?? null
anchorItem: root
accentColor: root.accentColor
panelNamespace: "nova-gpu"
panelTitle: "GPU"
contentWidth: 240
// Header vendor + usage%
Item {
width: parent.width
height: 28
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: M.SystemStats.gpuVendor.toUpperCase()
color: M.Theme.base03
font.pixelSize: M.Theme.fontSize - 3
font.family: M.Theme.fontFamily
font.letterSpacing: 1
}
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: M.SystemStats.gpuUsage + "%"
color: root._loadColor(M.SystemStats.gpuUsage)
font.pixelSize: M.Theme.fontSize
font.family: M.Theme.fontFamily
font.bold: true
}
}
// Usage bar
Item {
width: parent.width
height: 14
Item {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
height: 6
Rectangle {
anchors.fill: parent
color: M.Theme.base02
radius: 3
}
Rectangle {
width: parent.width * Math.min(1, M.SystemStats.gpuUsage / 100)
height: parent.height
color: root._loadColor(M.SystemStats.gpuUsage)
radius: 3
Behavior on width {
enabled: root._showPanel
NumberAnimation {
duration: 200
easing.type: Easing.OutCubic
}
}
}
}
}
// Usage history sparkline
Canvas {
id: _sparkline
anchors.left: parent.left
anchors.leftMargin: 12
anchors.right: parent.right
anchors.rightMargin: 12
height: 36
property var _hist: M.SystemStats.gpuHistory
on_HistChanged: if (root._showPanel)
requestPaint()
Connections {
target: root
function on_ShowPanelChanged() {
if (root._showPanel)
_sparkline.requestPaint();
}
}
onPaint: {
const ctx = getContext("2d");
if (!ctx)
return;
ctx.clearRect(0, 0, width, height);
const d = _hist;
if (!d.length)
return;
const maxSamples = 60;
const bw = width / maxSamples;
const offset = maxSamples - d.length;
for (let i = 0; i < d.length; i++) {
const barH = Math.max(1, height * d[i] / 100);
const col = root._loadColor(d[i]);
ctx.fillStyle = col.toString();
ctx.fillRect((offset + i) * bw, height - barH, Math.max(1, bw - 0.5), barH);
}
}
}
// VRAM section
Rectangle {
width: parent.width - 16
height: 1
anchors.horizontalCenter: parent.horizontalCenter
color: M.Theme.base03
}
Item {
width: parent.width
height: 22
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "VRAM"
color: M.Theme.base03
font.pixelSize: M.Theme.fontSize - 3
font.family: M.Theme.fontFamily
font.letterSpacing: 1
}
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: root._fmt(M.SystemStats.gpuVramUsedGb) + " / " + root._fmt(M.SystemStats.gpuVramTotalGb)
color: root.accentColor
font.pixelSize: M.Theme.fontSize - 1
font.family: M.Theme.fontFamily
font.bold: true
}
}
Item {
width: parent.width
height: 12
Item {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
height: 5
Rectangle {
anchors.fill: parent
color: M.Theme.base02
radius: 2
}
Rectangle {
width: M.SystemStats.gpuVramTotalGb > 0 ? parent.width * Math.min(1, M.SystemStats.gpuVramUsedGb / M.SystemStats.gpuVramTotalGb) : 0
height: parent.height
color: root.accentColor
radius: 2
Behavior on width {
enabled: root._showPanel
NumberAnimation {
duration: 200
easing.type: Easing.OutCubic
}
}
}
}
}
// Temperature row
Item {
width: parent.width
height: 22
visible: M.SystemStats.gpuTempC > 0
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "Temp"
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
}
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: M.SystemStats.gpuTempC + "\u00B0C"
color: M.SystemStats.gpuTempC > 85 ? M.Theme.base08 : M.SystemStats.gpuTempC > 70 ? M.Theme.base0A : M.Theme.base05
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
}
}
Item {
width: 1
height: 4
}
}
}

View file

@ -0,0 +1,381 @@
import QtQuick
import Quickshell
import Quickshell.Wayland
import "." as M
// Unified bar panel fullscreen transparent window so content can resize
// freely without triggering Wayland surface resizes.
//
// Hover mode (popupMode: false, default):
// Parent drives visibility via showPanel. Panel auto-closes when showPanel
// 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 {
id: root
property bool popupMode: false
// Hover mode
property bool showPanel: true
property Item anchorItem: null
property bool panelHovered: false
// Popup mode
property real anchorX: -1
signal dismissed
// Shared
required property color accentColor
property string panelTitle: ""
property Component titleActionsComponent: null
property string panelNamespace: "nova-panel"
property real contentWidth: 220
default property alias content: panelContent.children
visible: _winVisible
color: "transparent"
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.exclusiveZone: 0
WlrLayershell.namespace: root.panelNamespace
property real topMargin: 0
anchors.top: true
anchors.left: true
anchors.right: true
anchors.bottom: true
margins.top: topMargin
function _updatePosition() {
const scr = screen;
const sw = scr?.width ?? 1920;
let cx;
if (root.anchorItem) {
const pt = root.anchorItem.mapToGlobal(root.anchorItem.width / 2, 0);
cx = pt.x - (scr?.x ?? 0);
} else {
cx = root.anchorX;
}
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
// to route wl_pointer.enter to the new overlay surface (cursor may be stationary).
// 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
Timer {
id: _graceTimer
interval: root.popupMode ? 1500 : 400
onTriggered: {
root._grace = false;
if (!root.showPanel && !root._pinned)
root.dismiss();
}
}
// Content-change grace: call keepOpen(ms) when panel content is about to
// resize/rebuild (session switch, device list change, etc.) to prevent the
// hover-drop-on-resize from closing the panel.
property bool _contentBusy: false
Timer {
id: _contentBusyTimer
onTriggered: {
root._contentBusy = false;
if (!root.showPanel && !root._grace && !root._pinned)
_hideTimer.restart();
}
}
function keepOpen(ms) {
_contentBusy = true;
_contentBusyTimer.interval = ms ?? 400;
_contentBusyTimer.restart();
}
function _show() {
_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) {
// Explicitly set y before animating avoids the y:-height binding (live, depends on
// _panelColumn.height) surviving a 00 no-op animation when layout isn't done yet.
panelContainer.y = -(panelContainer.height > 0 ? panelContainer.height : 400);
panelContainer.opacity = 0;
}
_winVisible = true;
hideAnim.stop();
showAnim.start();
_grace = true;
_graceTimer.restart();
}
function dismiss() {
_pinned = false;
showAnim.stop();
hideAnim.start();
_grace = false;
_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: {
if (root.popupMode)
return;
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)
_show();
} else {
_hideTimer.restart();
}
}
ParallelAnimation {
id: showAnim
NumberAnimation {
target: panelContainer
property: "opacity"
to: 1
duration: 120
easing.type: Easing.OutCubic
}
NumberAnimation {
target: panelContainer
property: "y"
to: 0
duration: 150
easing.type: Easing.OutCubic
}
}
ParallelAnimation {
id: hideAnim
NumberAnimation {
target: panelContainer
property: "opacity"
to: 0
duration: 150
easing.type: Easing.InCubic
}
NumberAnimation {
target: panelContainer
property: "y"
to: -panelContainer.height
duration: 150
easing.type: Easing.InCubic
}
onFinished: {
root._winVisible = false;
if (root.popupMode)
root.dismissed();
}
}
// Popup mode: click-outside dismiss.
// 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.
Item {
anchors.fill: parent
visible: root.popupMode
TapHandler {
enabled: !root._grace
onTapped: {
const p = point.position;
const pad = 8;
if (p.x < panelContainer.x - pad || p.x > panelContainer.x + panelContainer.width + pad || p.y < panelContainer.y - pad || p.y > panelContainer.y + panelContainer.height + pad)
root.dismiss();
}
}
}
M.PopupBackground {
x: panelContainer.x
y: panelContainer.y
width: panelContainer.width
height: panelContainer.height
opacity: panelContainer.opacity * Math.max(M.Theme.barOpacity, 0.85)
accentColor: root.accentColor
}
Item {
id: panelContainer
x: 0
y: 0
width: root.contentWidth
height: _panelColumn.height
opacity: 0
HoverHandler {
enabled: !root.popupMode && !root._pinned
onHoveredChanged: if (!root.popupMode && !root._pinned)
root.panelHovered = hovered
}
Column {
id: _panelColumn
width: root.contentWidth
// Header row: title + action buttons + pin shown in hover mode always,
// and in popup mode when a title or actions are provided.
Item {
id: _headerItem
visible: !root.popupMode || root.panelTitle !== "" || root.titleActionsComponent !== null
width: parent.width
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 {
visible: root.panelTitle !== ""
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: root.panelTitle
color: root.accentColor
font.pixelSize: M.Theme.fontSize - 1
font.bold: true
font.family: M.Theme.fontFamily
}
// Action buttons anchored left of pin button slot
Loader {
id: _titleActionsLoader
anchors.right: _pinBtn.left
anchors.rightMargin: 4
anchors.verticalCenter: parent.verticalCenter
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
TapHandler {
cursorShape: Qt.PointingHandCursor
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 : M.Theme.base04
font.pixelSize: M.Theme.fontSize - 1
font.family: M.Theme.iconFontFamily
Behavior on color {
ColorAnimation {
duration: 100
}
}
}
}
// Divider at bottom of header
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 1
color: M.Theme.base03
}
}
Column {
id: panelContent
width: parent.width
}
}
}
// Border overlay on top of content so full-bleed items don't cover it
Rectangle {
x: panelContainer.x
y: panelContainer.y
width: panelContainer.width
height: panelContainer.height
color: "transparent"
border.color: root.accentColor
border.width: 1
radius: M.Theme.radius
opacity: panelContainer.opacity
}
}

View file

@ -0,0 +1,50 @@
import QtQuick
import Quickshell
import Quickshell.Io
import "." as M
M.BarIcon {
id: root
color: root.active ? M.Theme.base09 : root.accentColor
tooltip: {
const parts = ["Idle inhibition: " + (root.active ? "active" : "inactive")];
if (root._inhibitors)
parts.push(root._inhibitors);
return parts.join("\n");
}
property bool active: false
property string _inhibitors: ""
icon: root.active ? "\uF06E" : "\uF070"
Process {
id: inhibitor
command: ["systemd-inhibit", "--what=idle", "--who=nova-shell", "--why=user", "sleep", "infinity"]
running: root.active
}
// Poll current inhibitors
Process {
id: listProc
running: true
command: ["sh", "-c", "systemd-inhibit --list 2>/dev/null | grep -i idle | awk '{print $NF}' | sort -u | tr '\\n' ', ' | sed 's/, $//'"]
stdout: StdioCollector {
onStreamFinished: root._inhibitors = text.trim() ? "Blocked by: " + text.trim() : ""
}
}
Timer {
interval: 5000
running: root._hovered
repeat: true
triggeredOnStart: true
onTriggered: listProc.running = true
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.active = !root.active
}
}

344
shell/modules/Memory.qml Normal file
View file

@ -0,0 +1,344 @@
import QtQuick
import Quickshell
import "." as M
M.BarSection {
id: root
spacing: Math.max(1, M.Theme.moduleSpacing - 2)
tooltip: ""
property int percent: M.SystemStats.memPercent
property real usedGb: M.SystemStats.memUsedGb
property real totalGb: M.SystemStats.memTotalGb
property real availGb: M.SystemStats.memAvailGb
property real cachedGb: M.SystemStats.memCachedGb
property real buffersGb: M.SystemStats.memBuffersGb
function _fmt(gb) {
return gb >= 10 ? gb.toFixed(1) + "G" : gb.toFixed(2) + "G";
}
property bool _pinned: false
readonly property bool _anyHover: root._hovered || hoverPanel.panelHovered
readonly property bool _showPanel: _anyHover || _pinned
property M.ProcessList _procs: M.ProcessList {
sortBy: "mem"
active: root._showPanel
onProcessesChanged: hoverPanel.keepOpen(300)
}
on_AnyHoverChanged: {
if (_anyHover)
_unpinTimer.stop();
else if (_pinned)
_unpinTimer.start();
}
Timer {
id: _unpinTimer
interval: 500
onTriggered: root._pinned = false
}
M.BarIcon {
icon: "\uEFC5"
anchors.verticalCenter: parent.verticalCenter
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: root._pinned = !root._pinned
}
}
M.BarLabel {
label: root.percent + "%"
minText: "100%"
anchors.verticalCenter: parent.verticalCenter
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: root._pinned = !root._pinned
}
}
M.HoverPanel {
id: hoverPanel
showPanel: root._showPanel
screen: QsWindow.window?.screen ?? null
anchorItem: root
accentColor: root.accentColor
panelNamespace: "nova-memory"
panelTitle: "Memory"
contentWidth: 240
// Usage bar
Item {
width: parent.width
height: 14
Item {
id: memBar
anchors.left: parent.left
anchors.leftMargin: 12
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
height: 6
Rectangle {
anchors.fill: parent
color: M.Theme.base02
radius: 3
}
// Cached (base0D, behind used)
Rectangle {
width: parent.width * Math.min(1, (root.usedGb + root.cachedGb) / Math.max(root.totalGb, 0.001))
height: parent.height
color: M.Theme.base0D
opacity: 0.4
radius: 3
Behavior on width {
enabled: root._showPanel
NumberAnimation {
duration: 200
}
}
}
// Used (accentColor, on top)
Rectangle {
width: parent.width * Math.min(1, root.usedGb / Math.max(root.totalGb, 0.001))
height: parent.height
color: root.accentColor
radius: 3
Behavior on width {
enabled: root._showPanel
NumberAnimation {
duration: 200
}
}
}
}
}
// Memory history sparkline
Canvas {
id: memSparkline
anchors.left: parent.left
anchors.leftMargin: 12
anchors.right: parent.right
anchors.rightMargin: 12
height: 18
property var _hist: M.SystemStats.memHistory
property color _col: root.accentColor
on_HistChanged: if (root._showPanel)
requestPaint()
on_ColChanged: if (root._showPanel)
requestPaint()
Connections {
target: root
function on_ShowPanelChanged() {
if (root._showPanel)
memSparkline.requestPaint();
}
}
onPaint: {
const ctx = getContext("2d");
if (!ctx)
return;
ctx.clearRect(0, 0, width, height);
const d = _hist;
if (!d.length)
return;
const bw = width / 30;
ctx.fillStyle = Qt.rgba(_col.r, _col.g, _col.b, 0.15).toString();
ctx.fillRect(0, 0, width, height);
ctx.fillStyle = _col.toString();
for (let i = 0; i < d.length; i++) {
const h = Math.max(1, height * d[i] / 100);
ctx.fillRect((30 - d.length + i) * bw, height - h, Math.max(1, bw - 0.5), h);
}
}
}
// Breakdown rows
Item {
width: parent.width
height: 18
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "Used"
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
}
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: root._fmt(root.usedGb)
color: M.Theme.base05
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
}
}
Item {
width: parent.width
height: 18
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "Cached"
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
}
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: root._fmt(root.cachedGb)
color: M.Theme.base05
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
}
}
Item {
width: parent.width
height: 18
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "Available"
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
}
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: root._fmt(root.availGb)
color: M.Theme.base05
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
}
}
Item {
width: parent.width
height: 18
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "Total"
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
}
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: root._fmt(root.totalGb)
color: M.Theme.base05
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
}
}
// Process list separator
Rectangle {
width: parent.width - 16
height: 1
anchors.horizontalCenter: parent.horizontalCenter
color: M.Theme.base03
}
Item {
width: hoverPanel.contentWidth
height: 18
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "PROCESS"
color: M.Theme.base03
font.pixelSize: M.Theme.fontSize - 3
font.family: M.Theme.fontFamily
font.letterSpacing: 1
}
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "MEM"
color: M.Theme.base03
font.pixelSize: M.Theme.fontSize - 3
font.family: M.Theme.fontFamily
font.letterSpacing: 1
}
}
// Top processes by memory
Repeater {
model: root._procs.processes
delegate: Item {
required property var modelData
width: hoverPanel.contentWidth
height: 20
Text {
id: procCmd
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: modelData.cmd
color: M.Theme.base05
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
elide: Text.ElideRight
width: parent.width - 80
}
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: modelData.mem.toFixed(1) + "%"
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
width: 36
horizontalAlignment: Text.AlignRight
}
}
}
Item {
width: 1
height: 4
}
}
}

142
shell/modules/Modules.qml Normal file
View file

@ -0,0 +1,142 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
QtObject {
id: root
property var workspaces: ({
enable: true
})
property var tray: ({
enable: true
})
property var windowTitle: ({
enable: true
})
property var clock: ({
enable: true
})
property var notifications: ({
enable: true,
timeout: 3000,
maxPopups: 4,
maxVisible: 10,
maxHistory: -1
})
property var mpris: ({
enable: true
})
property var volume: ({
enable: true
})
property var bluetooth: ({
enable: true
})
property var backlight: ({
enable: true,
step: 5
})
property var network: ({
enable: true
})
property var powerProfile: ({
enable: true
})
property var idleInhibitor: ({
enable: true
})
property var weather: ({
enable: true,
args: ["--nerd"],
interval: 3600000
})
property var temperature: ({
enable: true,
warm: 80,
hot: 90,
device: ""
})
property var gpu: ({
enable: true
})
property var cpu: ({
enable: true
})
property var memory: ({
enable: true
})
property var disk: ({
enable: true,
interval: 30000
})
property var battery: ({
enable: true,
warning: 25,
critical: 15
})
property var privacy: ({
enable: true
})
property var screenCorners: ({
enable: true
})
property var power: ({
enable: true
})
property var backgroundOverlay: ({
enable: true
})
property var overviewBackdrop: ({
enable: true
})
property var lock: ({
enable: true
})
property var statsDaemon: ({
interval: -1
})
// All module keys that have an enable flag used to default-enable anything
// not explicitly mentioned in modules.json
readonly property var _moduleKeys: ["workspaces", "tray", "windowTitle", "clock", "notifications", "mpris", "volume", "bluetooth", "backlight", "network", "powerProfile", "idleInhibitor", "weather", "temperature", "gpu", "cpu", "memory", "disk", "battery", "privacy", "screenCorners", "power", "backgroundOverlay", "overviewBackdrop", "lock"]
// Fallback: if modules.json doesn't exist, enable everything
Component.onCompleted: _apply("{}")
property FileView _file: FileView {
path: (Quickshell.env("XDG_CONFIG_HOME") || (Quickshell.env("HOME") + "/.config")) + "/nova-shell/modules.json"
watchChanges: true
onFileChanged: reload()
onLoaded: root._apply(text())
}
function _apply(raw) {
let data = {};
try {
data = JSON.parse(raw);
} catch (e) {}
// Enable all modules that aren't explicitly mentioned in the JSON
for (const k of _moduleKeys) {
if (!(k in data))
root[k] = Object.assign({}, root[k], {
enable: true
});
}
// Apply JSON overrides
for (const k of Object.keys(data)) {
if (!(k in root))
continue;
const v = data[k];
if (typeof v === "object" && v !== null)
root[k] = Object.assign({}, root[k], v);
else if (typeof v === "boolean")
root[k] = Object.assign({}, root[k], {
enable: v
});
}
}
}

470
shell/modules/Mpris.qml Normal file
View file

@ -0,0 +1,470 @@
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.Mpris
import "." as M
M.BarSection {
id: root
spacing: M.Theme.moduleSpacing
opacity: M.Modules.mpris.enable && player !== null ? 1 : 0
visible: opacity > 0
tooltip: ""
property int _playerIdx: 0
readonly property var _players: (Mpris.players.values ?? []).filter(p => p.trackTitle || p.playbackState === MprisPlaybackState.Playing || p.playbackState === MprisPlaybackState.Paused)
readonly property MprisPlayer player: _players[_playerIdx] ?? _players[0] ?? null
readonly property bool playing: player?.playbackState === MprisPlaybackState.Playing
// Reset index if current player disappears
on_PlayersChanged: if (_playerIdx >= _players.length)
_playerIdx = 0
property string _cachedArt: ""
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 _currentTrack: player?.trackTitle ?? ""
on_ArtUrlChanged: if (_artUrl)
_cachedArt = _artUrl
on_CurrentTrackChanged: if (_currentTrack !== _artTrack) {
_artTrack = _currentTrack;
_cachedArt = _artUrl || "";
}
// Preload art while panel is hidden ensures QML image cache has the pixels
Image {
visible: false
source: root._cachedArt
asynchronous: true
}
// Cava visualizer 16 bars, raw output mode
property var _cavaBars: Array(16).fill(0)
property bool _cavaActive: false
on_ShowPanelChanged: {
if (_showPanel) {
_cavaKillTimer.stop();
_cavaActive = true;
} else {
_cavaKillTimer.restart();
}
}
Timer {
id: _cavaKillTimer
interval: 30000
onTriggered: root._cavaActive = false
}
Process {
id: cavaProc
running: root.playing && root._cavaActive
command: ["sh", "-c", "cfg=$(mktemp /tmp/nova-cava-XXXXXX.conf);" + "cat > \"$cfg\" << 'CAVAEOF'\n" + "[general]\nbars=16\nframerate=30\n[output]\nmethod=raw\nraw_target=/dev/stdout\ndata_format=ascii\nascii_max_range=100\n" + "CAVAEOF\n" + "trap 'rm -f \"$cfg\"' EXIT;" + "exec cava -p \"$cfg\""]
stdout: SplitParser {
splitMarker: "\n"
onRead: line => {
const vals = line.split(";").filter(s => s).map(Number);
if (vals.length >= 16)
root._cavaBars = vals.map(v => v / 100);
}
}
}
required property var bar
property bool _pinned: false
readonly property bool _anyHover: root._hovered || hoverPanel.panelHovered
readonly property bool _showPanel: _anyHover || _pinned
on_AnyHoverChanged: {
if (_anyHover)
_unpinTimer.stop();
else if (_pinned)
_unpinTimer.start();
}
Timer {
id: _unpinTimer
interval: 500
onTriggered: root._pinned = false
}
M.BarIcon {
icon: root.playing ? "\uF04B" : (root.player?.playbackState === MprisPlaybackState.Paused ? "\uDB80\uDFE4" : "\uDB81\uDCDB")
anchors.verticalCenter: parent.verticalCenter
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root._pinned = !root._pinned
}
}
M.BarLabel {
label: root.player?.trackTitle || root.player?.identity || ""
elide: Text.ElideRight
width: Math.min(implicitWidth, 200)
anchors.verticalCenter: parent.verticalCenter
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root._pinned = !root._pinned
}
}
M.HoverPanel {
id: hoverPanel
showPanel: root._showPanel
screen: QsWindow.window?.screen ?? null
anchorItem: root
accentColor: root.accentColor
panelNamespace: "nova-mpris"
panelTitle: "Now Playing"
contentWidth: 280
// Album art always 1:1, crossfades on session switch
Item {
width: parent.width
height: width
clip: true
Rectangle {
anchors.fill: parent
color: M.Theme.base02
}
// Outgoing art snaps to current opacity, then fades out
Image {
id: _artImgPrev
anchors.fill: parent
fillMode: Image.PreserveAspectCrop
asynchronous: true
opacity: 0
NumberAnimation {
id: _prevFadeOut
target: _artImgPrev
property: "opacity"
to: 0
duration: 300
easing.type: Easing.InOutCubic
}
}
// Incoming art fades in once loaded
Image {
id: _artImg
anchors.fill: parent
fillMode: Image.PreserveAspectCrop
asynchronous: true
opacity: 0
property bool _hasArt: false
onStatusChanged: {
if (status === Image.Ready && source !== "") {
_hasArt = true;
_artFadeIn.start();
_prevFadeOut.start();
} else if (status === Image.Error) {
_hasArt = false;
}
}
NumberAnimation {
id: _artFadeIn
target: _artImg
property: "opacity"
to: 1
duration: 300
easing.type: Easing.InOutCubic
}
Connections {
target: root
function on_CachedArtChanged() {
if (!root._cachedArt) {
_artFadeIn.stop();
_prevFadeOut.stop();
_artImg._hasArt = false;
_artImg.opacity = 0;
_artImgPrev.opacity = 0;
_artImg.source = "";
} else if (root._cachedArt !== _artImg.source) {
_prevFadeOut.stop();
_artFadeIn.stop();
_artImgPrev.source = _artImg.source;
_artImgPrev.opacity = _artImg.opacity;
_artImg.opacity = 0;
_artImg.source = root._cachedArt;
}
}
}
}
// Visualizer bars
Row {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: parent.height * 0.6
spacing: 2
visible: root.playing
opacity: 0.5
Repeater {
model: 16
Rectangle {
required property int index
width: (parent.width - 15 * parent.spacing) / 16
height: parent.height * (root._cavaBars[index] ?? 0)
anchors.bottom: parent.bottom
color: root.accentColor
radius: 1
Behavior on height {
NumberAnimation {
duration: 50
}
}
}
}
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 40
visible: _artImg._hasArt
gradient: Gradient {
GradientStop {
position: 0
color: "transparent"
}
GradientStop {
position: 1
color: M.Theme.base01
}
}
}
Text {
anchors.centerIn: parent
text: "\uF001"
color: M.Theme.base04
font.pixelSize: 28
font.family: M.Theme.iconFontFamily
visible: !_artImg._hasArt
}
}
// Track info
Item {
width: parent.width
height: titleCol.implicitHeight + 8
Column {
id: titleCol
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: 12
anchors.rightMargin: 12
spacing: 2
Text {
width: parent.width
text: root.player?.trackTitle || "No track"
color: M.Theme.base05
font.pixelSize: M.Theme.fontSize + 1
font.family: M.Theme.fontFamily
font.bold: true
elide: Text.ElideRight
}
Text {
width: parent.width
text: {
const p = root.player;
if (!p)
return "";
const artist = Array.isArray(p.trackArtists) ? p.trackArtists.join(", ") : (p.trackArtists || "");
return [artist, p.trackAlbum].filter(s => s).join(" \u2014 ");
}
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize - 1
font.family: M.Theme.fontFamily
elide: Text.ElideRight
visible: text !== ""
}
}
}
// Progress
Item {
width: parent.width
height: 20
readonly property real pos: root.player?.position ?? 0
readonly property real dur: root.player?.length ?? 0
readonly property real frac: dur > 0 ? pos / dur : 0
function _fmtTime(ms) {
const s = Math.floor(ms / 1000);
const m = Math.floor(s / 60);
return m + ":" + String(s % 60).padStart(2, "0");
}
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: parent._fmtTime(parent.pos)
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
}
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: parent._fmtTime(parent.dur)
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
}
Item {
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
width: parent.width - 80
height: 4
Rectangle {
anchors.fill: parent
color: M.Theme.base02
radius: 2
}
Rectangle {
width: parent.width * Math.min(1, Math.max(0, parent.parent.frac))
height: parent.height
color: root.accentColor
radius: 2
}
}
}
// Transport controls
Item {
width: parent.width
height: 36
Row {
anchors.centerIn: parent
spacing: 24
Text {
text: "\uF048"
color: root.player?.canGoPrevious ? M.Theme.base05 : M.Theme.base03
font.pixelSize: M.Theme.fontSize + 4
font.family: M.Theme.iconFontFamily
anchors.verticalCenter: parent.verticalCenter
TapHandler {
cursorShape: Qt.PointingHandCursor
enabled: root.player?.canGoPrevious ?? false
onTapped: root.player.previous()
}
}
Text {
text: root.playing ? "\uF04C" : "\uF04B"
color: root.accentColor
font.pixelSize: M.Theme.fontSize + 8
font.family: M.Theme.iconFontFamily
anchors.verticalCenter: parent.verticalCenter
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: root.player?.togglePlaying()
}
}
Text {
text: "\uF051"
color: root.player?.canGoNext ? M.Theme.base05 : M.Theme.base03
font.pixelSize: M.Theme.fontSize + 4
font.family: M.Theme.iconFontFamily
anchors.verticalCenter: parent.verticalCenter
TapHandler {
cursorShape: Qt.PointingHandCursor
enabled: root.player?.canGoNext ?? false
onTapped: root.player.next()
}
}
}
}
// Player switcher
Item {
width: parent.width
height: _players.length > 1 ? 28 : 0
visible: _players.length > 1
Flickable {
id: _switcher
anchors.centerIn: parent
width: Math.min(_playerRow.implicitWidth, parent.width - 16)
height: 22
contentWidth: _playerRow.implicitWidth
clip: true
Row {
id: _playerRow
height: 22
spacing: 6
Repeater {
model: root._players
delegate: Rectangle {
required property var modelData
required property int index
readonly property bool _active: index === root._playerIdx
width: _pLabel.implicitWidth + 12
height: 18
radius: 9
color: _active ? M.Theme.base02 : (pHover.hovered ? M.Theme.base02 : "transparent")
border.color: _active ? root.accentColor : M.Theme.base03
border.width: _active ? 1 : 0
anchors.verticalCenter: parent.verticalCenter
Text {
id: _pLabel
anchors.centerIn: parent
text: modelData.identity ?? "Player"
color: _active ? root.accentColor : M.Theme.base04
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
font.bold: _active
}
HoverHandler {
id: pHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: {
root._playerIdx = index;
hoverPanel.keepOpen(400);
}
}
}
}
}
}
}
}
}

110
shell/modules/Network.qml Normal file
View file

@ -0,0 +1,110 @@
import QtQuick
import Quickshell
import Quickshell.Io
import "." as M
M.BarSection {
id: root
spacing: M.Theme.moduleSpacing
tooltip: ""
property string ifname: ""
property string essid: ""
property string state: "disconnected"
property string ipAddr: ""
property string signal: ""
Process {
id: proc
running: M.Modules.network.enable
command: ["sh", "-c", "line=$(nmcli -t -f NAME,TYPE,DEVICE connection show --active 2>/dev/null | head -1); if [ -z \"$line\" ]; then dev=$(nmcli -t -f DEVICE,STATE device 2>/dev/null | grep ':connected' | grep -v ':unmanaged\\|:unavailable\\|:disconnected\\|:connecting' | head -1 | cut -d: -f1); [ -n \"$dev\" ] && line=\"linked:linked:$dev\"; fi; [ -z \"$line\" ] && exit 0; echo \"$line\"; dev=$(echo \"$line\" | cut -d: -f3); ip=$(nmcli -t -f IP4.ADDRESS device show \"$dev\" 2>/dev/null | head -1 | cut -d: -f2); echo \"ip:${ip:-}\"; sig=$(nmcli -t -f GENERAL.SIGNAL device show \"$dev\" 2>/dev/null | head -1 | cut -d: -f2); echo \"sig:${sig:-}\""]
stdout: StdioCollector {
onStreamFinished: {
const lines = text.trim().split("\n");
if (!lines[0]) {
root.state = "disconnected";
root.essid = "";
root.ifname = "";
root.ipAddr = "";
root.signal = "";
return;
}
const parts = lines[0].split(":");
root.essid = parts[0] || "";
root.ifname = parts[2] || "";
if ((parts[1] || "").includes("wireless"))
root.state = "wifi";
else if (parts[0] === "linked")
root.state = "linked";
else
root.state = "eth";
// Parse extra info lines
root.ipAddr = "";
root.signal = "";
for (let i = 1; i < lines.length; i++) {
if (lines[i].startsWith("ip:"))
root.ipAddr = lines[i].slice(3);
else if (lines[i].startsWith("sig:"))
root.signal = lines[i].slice(4);
}
}
}
}
// Event-driven: re-poll on any network change
Process {
id: monitor
running: M.Modules.network.enable
command: ["nmcli", "monitor"]
stdout: SplitParser {
splitMarker: "\n"
onRead: _debounce.restart()
}
}
Timer {
id: _debounce
interval: 300
onTriggered: {
proc.running = true;
networkMenu.triggerRefresh();
}
}
// Fallback poll
Timer {
interval: 60000
running: M.Modules.network.enable
repeat: true
onTriggered: proc.running = true
}
M.BarIcon {
icon: {
if (root.state === "wifi")
return "\uF1EB";
if (root.state === "eth")
return "\uDB80\uDE00";
if (root.state === "linked")
return "\uDB85\uDE16";
return "\uDB82\uDCFD";
}
color: root.state === "disconnected" ? M.Theme.base08 : root.accentColor
anchors.verticalCenter: parent.verticalCenter
}
M.BarLabel {
visible: root.state === "wifi"
label: root.essid
color: root.state === "disconnected" ? M.Theme.base08 : root.accentColor
anchors.verticalCenter: parent.verticalCenter
}
required property var bar
readonly property bool _anyHover: root._hovered || networkMenu.panelHovered
M.NetworkMenu {
id: networkMenu
showPanel: root._anyHover
screen: QsWindow.window?.screen ?? null
anchorItem: root
accentColor: root.accentColor
}
}

View file

@ -0,0 +1,229 @@
import QtQuick
import Quickshell
import Quickshell.Io
import "." as M
M.HoverPanel {
id: menuWindow
contentWidth: 250
panelNamespace: "nova-network"
panelTitle: "Wi-Fi"
titleActionsComponent: Component {
Item {
width: 20
height: 20
Text {
anchors.centerIn: parent
text: "\uF011"
color: menuWindow._wifiEnabled ? menuWindow.accentColor : M.Theme.base04
font.pixelSize: M.Theme.fontSize
font.family: M.Theme.iconFontFamily
Behavior on color {
ColorAnimation {
duration: 100
}
}
}
HoverHandler {
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: {
radioProc._state = menuWindow._wifiEnabled ? "off" : "on";
radioProc.running = true;
}
}
}
}
onVisibleChanged: if (visible)
scanner.running = true
function triggerRefresh() {
if (visible)
scanner.running = true;
}
property var _networks: []
property bool _wifiEnabled: true
property Process _scanner: Process {
id: scanner
running: true
command: ["sh", "-c", "echo '---RADIO---';" + "nmcli radio wifi 2>/dev/null;" + "echo '---CONNS---';" + "nmcli -t -f NAME,UUID,TYPE,ACTIVE connection show 2>/dev/null;" + "echo '---WIFI---';" + "nmcli -t -f SSID,SIGNAL device wifi list --rescan no 2>/dev/null"]
stdout: StdioCollector {
onStreamFinished: {
const radioSection = text.split("---CONNS---")[0].split("---RADIO---")[1] || "";
menuWindow._wifiEnabled = radioSection.trim() === "enabled";
const sections = text.split("---WIFI---");
const connLines = (sections[0] || "").split("---CONNS---")[1] || "";
const wifiLines = sections[1] || "";
const visible = {};
for (const l of wifiLines.trim().split("\n")) {
if (!l)
continue;
const parts = l.split(":");
const ssid = parts[0];
if (ssid)
visible[ssid] = parseInt(parts[1]) || 0;
}
const nets = [];
for (const l of connLines.trim().split("\n")) {
if (!l)
continue;
const parts = l.split(":");
const name = parts[0];
const uuid = parts[1];
const type = parts[2] || "";
const active = parts[3] === "yes";
const isWifi = type.includes("wireless");
if (isWifi && !(name in visible))
continue;
nets.push({
"name": name,
"uuid": uuid,
"isWifi": isWifi,
"active": active,
"signal": isWifi ? (visible[name] || 0) : -1
});
}
nets.sort((a, b) => {
if (a.active !== b.active)
return a.active ? -1 : 1;
if (a.signal >= 0 && b.signal >= 0)
return b.signal - a.signal;
return a.name.localeCompare(b.name);
});
menuWindow._networks = nets;
}
}
}
property Process _radioProc: Process {
id: radioProc
property string _state: ""
command: ["nmcli", "radio", "wifi", _state]
onRunningChanged: if (!running) {
scanner.running = true;
menuWindow.keepOpen(500);
}
}
property Process _connectProc: Process {
id: connectProc
property string uuid: ""
command: ["nmcli", "connection", "up", uuid]
onRunningChanged: if (!running) {
scanner.running = true;
menuWindow.keepOpen(500);
}
}
property Process _disconnectProc: Process {
id: disconnectProc
property string uuid: ""
command: ["nmcli", "connection", "down", uuid]
onRunningChanged: if (!running) {
scanner.running = true;
menuWindow.keepOpen(500);
}
}
Repeater {
model: menuWindow._networks
delegate: Item {
id: entry
required property var modelData
required property int index
width: menuWindow.contentWidth
height: 32
Rectangle {
anchors.fill: parent
anchors.leftMargin: 4
anchors.rightMargin: 4
color: entryHover.hovered ? M.Theme.base02 : "transparent"
radius: M.Theme.radius
}
Text {
id: netIcon
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: entry.modelData.isWifi ? "\uF1EB" : "\uDB80\uDE00"
color: entry.modelData.active ? menuWindow.accentColor : M.Theme.base05
font.pixelSize: M.Theme.fontSize + 1
font.family: M.Theme.iconFontFamily
}
Text {
anchors.left: netIcon.right
anchors.leftMargin: 8
anchors.right: sigLabel.left
anchors.rightMargin: 4
anchors.verticalCenter: parent.verticalCenter
text: entry.modelData.name
color: entry.modelData.active ? menuWindow.accentColor : M.Theme.base05
font.pixelSize: M.Theme.fontSize
font.family: M.Theme.fontFamily
font.bold: entry.modelData.active
elide: Text.ElideRight
}
Text {
id: sigLabel
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: entry.modelData.signal >= 0 ? entry.modelData.signal + "%" : ""
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize - 1
font.family: M.Theme.fontFamily
width: entry.modelData.signal >= 0 ? implicitWidth : 0
}
HoverHandler {
id: entryHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: {
if (entry.modelData.active) {
disconnectProc.uuid = entry.modelData.uuid;
disconnectProc.running = true;
} else {
connectProc.uuid = entry.modelData.uuid;
connectProc.running = true;
}
}
}
}
}
Text {
visible: menuWindow._networks.length === 0
width: menuWindow.contentWidth
height: 32
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
text: menuWindow._wifiEnabled ? "No networks available" : "Wi-Fi is off"
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize
font.family: M.Theme.fontFamily
}
}

74
shell/modules/NiriIpc.qml Normal file
View file

@ -0,0 +1,74 @@
pragma Singleton
import QtQuick
import Quickshell.Io
QtObject {
id: root
signal workspacesChanged(var workspaces)
signal workspaceActivated(int id, bool focused)
signal windowFocusChanged(var windowId)
signal windowOpenedOrChanged(var window)
property bool available: false
property string focusedTitle: ""
property string focusedAppId: ""
property bool overviewOpen: false
property var _focusedProc: Process {
running: true
command: ["niri", "msg", "--json", "focused-window"]
stdout: StdioCollector {
onStreamFinished: {
try {
const w = JSON.parse(text);
if (w) {
root.focusedTitle = w.title || "";
root.focusedAppId = w.app_id || "";
} else {
root.focusedTitle = "";
root.focusedAppId = "";
}
} catch (e) {}
}
}
}
property var _eventStream: Process {
running: true
command: ["niri", "msg", "--json", "event-stream"]
stdout: SplitParser {
splitMarker: "\n"
onRead: line => {
try {
const ev = JSON.parse(line);
root.available = true;
if (ev.WorkspacesChanged !== undefined)
root.workspacesChanged(ev.WorkspacesChanged.workspaces);
else if (ev.WorkspaceActivated !== undefined)
root.workspaceActivated(ev.WorkspaceActivated.id, ev.WorkspaceActivated.focused);
else if (ev.WindowFocusChanged !== undefined) {
root.windowFocusChanged(ev.WindowFocusChanged.id);
if (ev.WindowFocusChanged.id !== null)
root._focusedProc.running = true;
else {
root.focusedTitle = "";
root.focusedAppId = "";
}
} else if (ev.OverviewOpenedOrClosed !== undefined) {
root.overviewOpen = ev.OverviewOpenedOrClosed.is_open;
} else if (ev.WindowOpenedOrChanged !== undefined) {
root.windowOpenedOrChanged(ev.WindowOpenedOrChanged.window);
const w = ev.WindowOpenedOrChanged.window;
if (w.is_focused) {
root.focusedTitle = w.title || "";
root.focusedAppId = w.app_id || "";
}
}
} catch (e) {}
}
}
}
}

276
shell/modules/NotifCard.qml Normal file
View file

@ -0,0 +1,276 @@
import QtQuick
import Quickshell
import Quickshell.Services.Notifications
import "." as M
// Shared notification card: background, progress bar, urgency bar, icon, text, dismiss button.
// Does NOT include dismiss animation or dismiss logic emits dismissRequested() instead.
Item {
id: root
required property var notif // NotifItem (may be null all accesses use ?.)
property bool showAppName: true
property bool dismissOnAction: true
property int iconSize: 32
property int bodyMaxLines: 3
property color accentColor: M.Theme.base0D
signal dismissRequested
// Tall enough for content including padding, or the icon if it's taller
implicitHeight: Math.max(_contentCol.implicitHeight, _icon.visible ? _icon.height : 0) + 16
HoverHandler {
id: _hover
}
TapHandler {
acceptedButtons: Qt.RightButton
cursorShape: Qt.PointingHandCursor
onTapped: root.dismissRequested()
}
// Background: base01, base02 on hover
Rectangle {
anchors.fill: parent
color: _hover.hovered ? M.Theme.base02 : M.Theme.base01
opacity: _hover.hovered ? 1.0 : Math.max(M.Theme.barOpacity, 0.9)
radius: M.Theme.radius
Behavior on color {
ColorAnimation {
duration: 100
}
}
// Progress bar fill (hint value)
Rectangle {
visible: (root.notif?.hints?.value ?? -1) >= 0
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.left: parent.left
width: parent.width * Math.min(1, Math.max(0, (root.notif?.hints?.value ?? 0) / 100))
color: M.Theme.base03
radius: parent.radius
Behavior on width {
NumberAnimation {
duration: 200
}
}
}
}
// Urgency accent bar
Rectangle {
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: 3
radius: 1
color: {
const u = root.notif?.urgency ?? NotificationUrgency.Normal;
return u === NotificationUrgency.Critical ? M.Theme.base08 : u === NotificationUrgency.Low ? M.Theme.base04 : M.Theme.base0D;
}
}
// App icon / image
Image {
id: _icon
anchors.left: parent.left
anchors.leftMargin: 14
anchors.top: parent.top
anchors.topMargin: 8
width: root.iconSize
height: root.iconSize
source: {
const img = root.notif?.image;
if (img)
return img;
const ic = root.notif?.appIcon;
if (!ic)
return "";
return (ic.startsWith("/") || ic.startsWith("file://")) ? ic : Quickshell.iconPath(ic, "dialog-information");
}
visible: status === Image.Ready
fillMode: Image.PreserveAspectFit
sourceSize: Qt.size(root.iconSize, root.iconSize)
asynchronous: true
}
// Dismiss button overlays top-right, visible only on hover (opacity keeps layout stable)
Text {
id: _dismissBtn
anchors.right: parent.right
anchors.rightMargin: 10
anchors.top: parent.top
anchors.topMargin: 8
text: "\uF00D"
color: _dismissHover.hovered ? M.Theme.base08 : M.Theme.base03
font.pixelSize: M.Theme.fontSize - 1
font.family: M.Theme.iconFontFamily
opacity: _hover.hovered ? 1 : 0
HoverHandler {
id: _dismissHover
}
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: root.dismissRequested()
}
}
Column {
id: _contentCol
anchors.left: _icon.visible ? _icon.right : parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.leftMargin: _icon.visible ? 8 : 14
anchors.rightMargin: 20
anchors.topMargin: 8
spacing: 2
// Text section tappable for default action
Item {
id: _textSection
width: parent.width
height: _textCol.implicitHeight
implicitHeight: _textCol.implicitHeight
TapHandler {
cursorShape: root.notif?.actions?.some(a => a.identifier === "default") ? Qt.PointingHandCursor : undefined
onTapped: {
const def = root.notif?.actions?.find(a => a.identifier === "default");
if (def) {
def.invoke();
if (root.dismissOnAction)
root.dismissRequested();
}
}
}
Column {
id: _textCol
width: parent.width
spacing: 2
// App name + time row (optional)
Row {
visible: root.showAppName
width: parent.width
Text {
text: root.notif?.appName ?? "Notification"
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
width: parent.width - _timeText.implicitWidth - 4
elide: Text.ElideRight
}
Text {
id: _timeText
text: root.notif?.timeStr ?? ""
color: M.Theme.base03
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
}
}
// Summary (with inline time when app name row is hidden)
Row {
visible: !root.showAppName
width: parent.width
Text {
text: root.notif?.summary ?? ""
color: M.Theme.base05
font.pixelSize: M.Theme.fontSize
font.family: M.Theme.fontFamily
font.bold: true
elide: Text.ElideRight
width: parent.width - _inlineTime.implicitWidth - 4
}
Text {
id: _inlineTime
text: root.notif?.timeStr ?? ""
color: M.Theme.base03
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
anchors.verticalCenter: parent.verticalCenter
}
}
Text {
visible: root.showAppName
width: parent.width
text: root.notif?.summary ?? ""
color: M.Theme.base05
font.pixelSize: M.Theme.fontSize
font.family: M.Theme.fontFamily
font.bold: true
elide: Text.ElideRight
wrapMode: Text.WordWrap
maximumLineCount: 2
}
Text {
width: parent.width
text: root.notif?.body ?? ""
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize - 1
font.family: M.Theme.fontFamily
elide: Text.ElideRight
wrapMode: Text.WordWrap
maximumLineCount: root.bodyMaxLines
visible: text !== ""
}
}
}
// Action buttons filter "default" (click-notification convention) and empty labels
Row {
spacing: 6
visible: _actionRepeater.count > 0
Repeater {
id: _actionRepeater
model: (root.notif?.actions ?? []).filter(a => a.text && a.identifier !== "default")
delegate: Rectangle {
required property var modelData
width: _actText.implicitWidth + 12
height: _actText.implicitHeight + 6
radius: M.Theme.radius
color: _actHover.hovered ? M.Theme.base03 : "transparent"
border.color: M.Theme.base03
border.width: 1
Text {
id: _actText
anchors.centerIn: parent
text: parent.modelData.text
color: root.accentColor
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
}
HoverHandler {
id: _actHover
}
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: {
parent.modelData.invoke();
if (root.dismissOnAction)
root.dismissRequested();
}
}
}
}
}
}
}

View file

@ -0,0 +1,486 @@
import QtQuick
import Quickshell
import "." as M
M.HoverPanel {
id: menuWindow
popupMode: true
contentWidth: 350
// Header: title + clear all + DND toggle
Item {
width: menuWindow.contentWidth
height: 32
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "Notifications"
color: M.Theme.base05
font.pixelSize: M.Theme.fontSize + 1
font.family: M.Theme.fontFamily
font.bold: true
}
Row {
anchors.right: parent.right
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
spacing: 8
// DND toggle
Text {
text: M.NotifService.dnd ? "\uDB82\uDE93" : "\uDB80\uDC9C"
color: M.NotifService.dnd ? M.Theme.base09 : M.Theme.base04
font.pixelSize: M.Theme.fontSize
font.family: M.Theme.iconFontFamily
anchors.verticalCenter: parent.verticalCenter
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: M.NotifService.toggleDnd()
}
}
// Clear all
Text {
text: "\uF1F8"
color: clearArea.containsMouse ? M.Theme.base08 : M.Theme.base04
font.pixelSize: M.Theme.fontSize
font.family: M.Theme.iconFontFamily
anchors.verticalCenter: parent.verticalCenter
visible: M.NotifService.count > 0
MouseArea {
id: clearArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: menuWindow._cascadeDismiss()
}
}
}
}
property var _pendingDismissIds: []
// Collapsed groups set reassign to trigger reactivity
property var _collapsedGroups: ({})
property real _savedScrollY: 0
property bool _restoringScroll: false
function _toggleCollapse(appName) {
_savedScrollY = notifList.contentY;
_restoringScroll = true;
const next = Object.assign({}, _collapsedGroups);
if (next[appName])
delete next[appName];
else
next[appName] = true;
_collapsedGroups = next;
}
// Group notifications by appName, sorted by max urgency desc then most recent time desc
readonly property var _groups: {
const map = {};
for (const n of M.NotifService.list) {
const key = n.appName || "";
if (!map[key])
map[key] = {
appName: key,
appIcon: n.appIcon,
notifs: [],
maxUrgency: 0,
maxTime: 0
};
map[key].notifs.push(n);
if (n.urgency > map[key].maxUrgency)
map[key].maxUrgency = n.urgency;
if (n.time > map[key].maxTime)
map[key].maxTime = n.time;
}
return Object.values(map).sort((a, b) => {
if (b.maxUrgency !== a.maxUrgency)
return b.maxUrgency - a.maxUrgency;
return b.maxTime - a.maxTime;
});
}
// Flat model: group header followed by its notifications (omitted when collapsed)
readonly property var _flatModel: {
const arr = [];
for (const g of _groups) {
const collapsed = !!_collapsedGroups[g.appName];
arr.push({
type: "header",
appName: g.appName,
appIcon: g.appIcon,
count: g.notifs.length,
collapsed: collapsed,
summaries: g.notifs.map(n => n.summary || "")
});
if (!collapsed) {
for (const n of g.notifs)
arr.push({
type: "notif",
data: n
});
}
}
return arr;
}
// Collect visible (non-dismissing) notif delegates, optionally filtered by appName
function _getVisibleNotifDelegates(appName) {
const result = [];
for (let i = 0; i < _flatModel.length; i++) {
const item = _flatModel[i];
if (item.type !== "notif")
continue;
if (appName !== undefined && item.data.appName !== appName)
continue;
const d = notifList.itemAtIndex(i);
if (d && d._type === "notif" && d._notif?.state !== "dismissing")
result.push(d);
}
return result;
}
function _startCascade(visibles, ids) {
_pendingDismissIds = ids;
if (visibles.length === 0) {
_finishCascade();
return;
}
for (let i = 0; i < visibles.length; i++) {
_cascadeTimer.createObject(menuWindow, {
_target: visibles[i],
_delay: i * 60,
_isLast: i === visibles.length - 1
});
}
}
function _cascadeDismiss() {
if (M.NotifService.list.length === 0)
return;
const ids = M.NotifService.list.map(n => n.id);
_startCascade(_getVisibleNotifDelegates(), ids);
}
function _cascadeGroupDismiss(appName) {
const ids = M.NotifService.list.filter(n => n.appName === appName).map(n => n.id);
if (ids.length === 0)
return;
_startCascade(_getVisibleNotifDelegates(appName), ids);
}
function _finishCascade() {
const ids = _pendingDismissIds;
_pendingDismissIds = [];
for (const id of ids)
M.NotifService.dismiss(id);
}
property Component _cascadeTimer: Component {
Timer {
property var _target
property int _delay
property bool _isLast
interval: _delay
running: true
onTriggered: {
if (_target && _target.dismissVisualOnly)
_target.dismissVisualOnly();
if (_isLast)
_bulkTimer.createObject(menuWindow, {});
destroy();
}
}
}
property Component _bulkTimer: Component {
Timer {
interval: 400 // swipe (200) + collapse (150) + margin
running: true
onTriggered: {
menuWindow._finishCascade();
destroy();
}
}
}
// Separator
Rectangle {
width: menuWindow.contentWidth - 16
height: 1
anchors.horizontalCenter: parent.horizontalCenter
color: M.Theme.base03
}
// Notification list (scrollable)
ListView {
id: notifList
width: menuWindow.contentWidth
height: Math.min(contentHeight, 60 * (M.Modules.notifications.maxVisible || 10))
clip: true
boundsBehavior: Flickable.StopAtBounds
model: menuWindow._flatModel
onContentHeightChanged: {
if (menuWindow._restoringScroll) {
contentY = Math.min(menuWindow._savedScrollY, Math.max(0, contentHeight - height));
menuWindow._restoringScroll = false;
}
}
delegate: Item {
id: notifDelegate
required property var modelData
required property int index
readonly property string _type: modelData.type
readonly property var _notif: _type === "notif" ? modelData.data : null
width: menuWindow.contentWidth
height: _displayTargetHeight * _heightScale
clip: true
opacity: 0
readonly property real _targetHeight: {
if (_type === "header")
return modelData.collapsed ? (28 + modelData.count * (M.Theme.fontSize + 4)) : 28;
return _notifCard.implicitHeight;
}
// Animated version of _targetHeight smoothly transitions header height on collapse
property real _displayTargetHeight: _targetHeight
Behavior on _displayTargetHeight {
NumberAnimation {
duration: 150
easing.type: Easing.OutCubic
}
}
property real _heightScale: 1
property bool _skipDismiss: false
function dismiss() {
if (_type !== "notif" || _notif.state === "dismissing")
return;
_notif.beginDismiss();
_dismissAnim.start();
}
function dismissVisualOnly() {
if (_type !== "notif" || _notif.state === "dismissing")
return;
_notif.beginDismiss();
_skipDismiss = true;
_dismissAnim.start();
}
Component.onCompleted: fadeIn.start()
NumberAnimation {
id: fadeIn
target: notifDelegate
property: "opacity"
to: 1
duration: 150
easing.type: Easing.OutCubic
}
// ---- Group header ----
Item {
visible: notifDelegate._type === "header"
anchors.fill: parent
HoverHandler {
id: _headerHover
}
// Tap target for collapse covers header row only, excludes dismiss button
Item {
anchors.left: parent.left
anchors.right: _groupDismissBtn.left
anchors.top: parent.top
height: 28
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: menuWindow._toggleCollapse(notifDelegate.modelData.appName)
}
}
Image {
id: _headerIcon
anchors.left: parent.left
anchors.leftMargin: 10
anchors.top: parent.top
anchors.topMargin: (28 - height) / 2
width: M.Theme.fontSize + 2
height: M.Theme.fontSize + 2
source: {
if (notifDelegate._type !== "header")
return "";
const ic = notifDelegate.modelData.appIcon;
if (!ic)
return "";
return (ic.startsWith("/") || ic.startsWith("file://")) ? ic : Quickshell.iconPath(ic, "dialog-information");
}
visible: status === Image.Ready
fillMode: Image.PreserveAspectFit
sourceSize: Qt.size(M.Theme.fontSize + 2, M.Theme.fontSize + 2)
asynchronous: true
}
// Collapse chevron
Text {
id: _chevron
anchors.right: _groupDismissBtn.left
anchors.rightMargin: 8
anchors.top: parent.top
height: 28
verticalAlignment: Text.AlignVCenter
text: notifDelegate._type === "header" && notifDelegate.modelData.collapsed ? "\u25B8" : "\u25BE"
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
opacity: _headerHover.hovered ? 1 : 0
}
// App name
Text {
anchors.left: _headerIcon.visible ? _headerIcon.right : parent.left
anchors.leftMargin: _headerIcon.visible ? 6 : 10
anchors.right: _chevron.left
anchors.rightMargin: 4
anchors.top: parent.top
height: 28
verticalAlignment: Text.AlignVCenter
text: notifDelegate._type === "header" ? (notifDelegate.modelData.appName || "Unknown") : ""
color: M.Theme.base05
font.pixelSize: M.Theme.fontSize - 1
font.family: M.Theme.fontFamily
font.bold: true
elide: Text.ElideRight
}
// Dismiss button opacity-hidden when header not hovered
Text {
id: _groupDismissBtn
anchors.right: parent.right
anchors.rightMargin: 10
anchors.top: parent.top
height: 28
verticalAlignment: Text.AlignVCenter
text: "\uF1F8"
color: _groupDismissHover.hovered ? M.Theme.base08 : M.Theme.base04
font.pixelSize: M.Theme.fontSize - 1
font.family: M.Theme.iconFontFamily
opacity: _headerHover.hovered ? 1 : 0
HoverHandler {
id: _groupDismissHover
}
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: {
if (notifDelegate._type === "header")
menuWindow._cascadeGroupDismiss(notifDelegate.modelData.appName);
}
}
}
// Collapsed preview: one line per notification summary
Repeater {
model: (notifDelegate._type === "header" && notifDelegate.modelData.collapsed) ? notifDelegate.modelData.summaries : []
Text {
required property string modelData
required property int index
anchors.left: parent.left
anchors.leftMargin: 10
anchors.right: parent.right
anchors.rightMargin: 10
y: 28 + index * (M.Theme.fontSize + 4)
height: M.Theme.fontSize + 4
verticalAlignment: Text.AlignVCenter
text: modelData
elide: Text.ElideRight
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
color: M.Theme.base04
}
}
}
// ---- Individual notification ----
M.NotifCard {
id: _notifCard
visible: notifDelegate._type === "notif"
anchors.fill: parent
anchors.leftMargin: 8
notif: notifDelegate._notif
showAppName: false
dismissOnAction: false
iconSize: 24
bodyMaxLines: 2
onDismissRequested: notifDelegate.dismiss()
}
SequentialAnimation {
id: _dismissAnim
ParallelAnimation {
NumberAnimation {
target: notifDelegate
property: "x"
to: menuWindow.contentWidth
duration: 200
easing.type: Easing.InCubic
}
NumberAnimation {
target: notifDelegate
property: "opacity"
to: 0
duration: 200
easing.type: Easing.InCubic
}
}
NumberAnimation {
target: notifDelegate
property: "_heightScale"
to: 0
duration: 150
easing.type: Easing.OutCubic
}
ScriptAction {
script: {
if (notifDelegate._notif && !notifDelegate._skipDismiss)
M.NotifService.dismiss(notifDelegate._notif.id);
}
}
}
}
}
// Empty state
Text {
visible: M.NotifService.count === 0
width: menuWindow.contentWidth
height: 48
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
text: "No notifications"
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize
font.family: M.Theme.fontFamily
}
}

View file

@ -0,0 +1,64 @@
import QtQuick
import Quickshell.Services.Notifications
import "." as M
QtObject {
id: root
property bool popup: false
property string state: "visible" // "visible" | "dismissing" | "dismissed"
property var notification: null
property var id
property string summary
property string body
property string appName
property string appIcon
property string image
property var hints
property int urgency: NotificationUrgency.Normal
property var actions: []
property real time: Date.now()
// Expire timer owned by this item, not dynamically created
readonly property Timer _expireTimer: Timer {
running: false
onTriggered: {
if (root.state === "visible")
root.popup = false;
}
}
// Relative time string recomputed whenever NotifService._now ticks (single global 5s timer)
readonly property string timeStr: {
const diff = M.NotifService._now - time;
const m = Math.floor(diff / 60000);
if (m < 1)
return "now";
const h = Math.floor(m / 60);
if (h < 1)
return m + "m";
const d = Math.floor(h / 24);
return d > 0 ? d + "d" : h + "h";
}
// App closed the notification from its side remove from our list while the object is still alive
readonly property Connections _notifConn: Connections {
target: root.notification
function onClosed() {
if (root.state !== "dismissed")
M.NotifService.dismiss(root.id);
}
}
function beginDismiss() {
if (state === "visible")
state = "dismissing";
}
function finishDismiss() {
state = "dismissed";
_expireTimer.running = false;
notification?.dismiss();
}
}

View file

@ -0,0 +1,168 @@
import QtQuick
import Quickshell
import Quickshell.Wayland
import Quickshell.Services.Notifications
import "." as M
PanelWindow {
id: root
required property var screen
visible: M.NotifService.popups.length > 0 && !M.NiriIpc.overviewOpen
color: "transparent"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.exclusiveZone: 0
WlrLayershell.namespace: "nova-notif-popup"
anchors.top: true
anchors.right: true
margins.top: 0
margins.right: 8
implicitWidth: 320
implicitHeight: popupCol.implicitHeight
Column {
id: popupCol
width: parent.width
spacing: 6
property var _knownIds: ({})
Repeater {
model: M.NotifService.popups.slice(0, M.Modules.notifications.maxPopups || 4)
delegate: Item {
id: popupItem
required property var modelData
required property int index
width: popupCol.width
height: _targetHeight * _heightScale
opacity: 0
x: 320
clip: true
readonly property real _targetHeight: _card.implicitHeight
property real _heightScale: 0
M.NotifCard {
id: _card
anchors.fill: parent
notif: popupItem.modelData
showAppName: true
iconSize: 36
bodyMaxLines: 3
onDismissRequested: popupItem.animateDismiss(true)
}
property bool _entered: false
Component.onCompleted: {
if (popupCol._knownIds[modelData.id]) {
opacity = 1;
x = 0;
_heightScale = 1;
} else {
popupCol._knownIds[modelData.id] = true;
slideIn.start();
}
_entered = true;
}
Connections {
target: M.NotifService
function onPopupExpiring(notifId) {
if (notifId === popupItem.modelData.id)
popupItem.animateDismiss(false);
}
}
Behavior on y {
enabled: popupItem._entered
NumberAnimation {
duration: 200
easing.type: Easing.OutCubic
}
}
ParallelAnimation {
id: slideIn
NumberAnimation {
target: popupItem
property: "opacity"
to: 1
duration: 250
easing.type: Easing.OutCubic
}
NumberAnimation {
target: popupItem
property: "x"
to: 0
duration: 350
easing.type: Easing.OutBack
easing.overshoot: 0.5
}
NumberAnimation {
target: popupItem
property: "_heightScale"
to: 1
duration: 250
easing.type: Easing.OutCubic
}
}
property bool _fullDismiss: false
function animateDismiss(full) {
if (popupItem.modelData.state === "dismissing")
return;
popupItem.modelData.beginDismiss();
_fullDismiss = !!full;
slideIn.stop();
slideOut.start();
}
SequentialAnimation {
id: slideOut
ParallelAnimation {
NumberAnimation {
target: popupItem
property: "x"
to: 400
duration: 250
easing.type: Easing.InCubic
}
NumberAnimation {
target: popupItem
property: "opacity"
to: 0
duration: 250
easing.type: Easing.InCubic
}
}
NumberAnimation {
target: popupItem
property: "_heightScale"
to: 0
duration: 200
easing.type: Easing.OutCubic
}
ScriptAction {
script: popupItem._fullDismiss ? M.NotifService.dismiss(popupItem.modelData.id) : M.NotifService.dismissPopup(popupItem.modelData.id)
}
}
// Click anywhere to dismiss (left = popup only, right = full dismiss)
MouseArea {
anchors.fill: parent
z: -1
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: mouse => popupItem.animateDismiss(mouse.button === Qt.RightButton)
}
}
}
}
}

View file

@ -0,0 +1,137 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Services.Notifications
import "." as M
QtObject {
id: root
property var list: []
property bool dnd: false
readonly property var popups: list.filter(n => n.popup && n.state !== "dismissed")
readonly property int count: list.filter(n => n.state !== "dismissed").length
// O(1) lookup
property var _byId: ({})
function dismiss(notifId) {
const item = _byId[notifId];
if (!item)
return;
item.finishDismiss();
list = list.filter(n => n !== item);
delete _byId[notifId];
item.destroy();
}
function dismissAll() {
for (const item of list.slice()) {
item.finishDismiss();
delete _byId[item.id];
item.destroy();
}
list = [];
}
function dismissPopup(notifId) {
const item = _byId[notifId];
if (item) {
item.popup = false;
_changed();
}
}
function toggleDnd() {
dnd = !dnd;
}
function _changed() {
list = list.slice();
}
// Signal popups to animate out before removal
signal popupExpiring(var notifId)
property NotificationServer _server: NotificationServer {
actionsSupported: true
bodyMarkupSupported: true
imageSupported: true
persistenceSupported: true
keepOnReload: false
onNotification: notif => {
notif.tracked = true;
const isCritical = notif.urgency === NotificationUrgency.Critical;
const item = _itemComp.createObject(root, {
notification: notif,
id: notif.id,
summary: notif.summary,
body: notif.body,
appName: notif.appName,
appIcon: notif.appIcon,
image: notif.image,
hints: notif.hints,
urgency: notif.urgency,
actions: notif.actions ? notif.actions.map(a => ({
identifier: a.identifier,
text: a.text,
invoke: () => a.invoke()
})) : [],
time: Date.now(),
popup: isCritical || !root.dnd
});
root._byId[item.id] = item;
root.list = [item, ...root.list].sort((a, b) => {
const aU = a.urgency === NotificationUrgency.Critical ? 1 : 0;
const bU = b.urgency === NotificationUrgency.Critical ? 1 : 0;
if (aU !== bU)
return bU - aU;
return b.time - a.time;
});
// Trim excess popups
const max = M.Modules.notifications.maxPopups || 4;
const currentPopups = root.list.filter(n => n.popup);
if (currentPopups.length > max) {
for (let i = max; i < currentPopups.length; i++)
currentPopups[i].popup = false;
root._changed();
}
// Auto-expire popup (skip for critical)
if (item.popup && !isCritical) {
const timeout = notif.expireTimeout > 0 ? notif.expireTimeout : (M.Modules.notifications.timeout || 3000);
item._expireTimer.interval = timeout;
item._expireTimer.running = true;
}
// Trim history (-1 = unlimited)
const maxHistory = M.Modules.notifications.maxHistory ?? -1;
while (maxHistory > 0 && root.list.length > maxHistory) {
const old = root.list.pop();
old.finishDismiss();
delete root._byId[old.id];
old.destroy();
}
}
}
property Component _itemComp: Component {
NotifItem {}
}
// Single global tick for all NotifItem.timeStr bindings replaces per-item 5s timers
property real _now: Date.now()
property Timer _nowTimer: Timer {
running: root.count > 0
repeat: true
interval: 5000
onTriggered: root._now = Date.now()
}
}

View file

@ -0,0 +1,94 @@
import QtQuick
import Quickshell
import Quickshell.Services.Notifications
import "." as M
M.BarSection {
id: root
spacing: M.Theme.moduleSpacing
tooltip: {
const parts = [M.NotifService.count + " notification" + (M.NotifService.count !== 1 ? "s" : "")];
if (M.NotifService.dnd)
parts.push("Do not disturb");
return parts.join("\n");
}
required property var bar
readonly property bool hasUrgent: M.NotifService.list.some(n => n.urgency === NotificationUrgency.Critical && n.state !== "dismissed")
M.BarIcon {
icon: {
if (M.NotifService.dnd)
return M.NotifService.count > 0 ? "\uDB80\uDCA0" : "\uDB82\uDE93";
return M.NotifService.count > 0 ? "\uDB84\uDD6B" : "\uDB80\uDC9C";
}
color: M.NotifService.dnd ? M.Theme.base04 : root.accentColor
anchors.verticalCenter: parent.verticalCenter
}
M.BarLabel {
id: countLabel
label: M.NotifService.count > 0 ? String(M.NotifService.count) + (root.hasUrgent ? "!" : "") : ""
color: root.hasUrgent ? M.Theme.base08 : root.accentColor
anchors.verticalCenter: parent.verticalCenter
transform: Scale {
id: countScale
origin.x: countLabel.width / 2
origin.y: countLabel.height / 2
xScale: 1
yScale: 1
}
SequentialAnimation {
id: popAnim
NumberAnimation {
target: countScale
properties: "xScale,yScale"
to: 1.4
duration: 100
easing.type: Easing.OutQuad
}
NumberAnimation {
target: countScale
properties: "xScale,yScale"
to: 1.0
duration: 200
easing.type: Easing.OutElastic
}
}
}
Connections {
target: M.NotifService
function onCountChanged() {
if (M.NotifService.count > 0)
popAnim.start();
}
}
TapHandler {
acceptedButtons: Qt.LeftButton
cursorShape: Qt.PointingHandCursor
onTapped: {
centerLoader.active = !centerLoader.active;
M.FlyoutState.visible = false;
}
}
TapHandler {
acceptedButtons: Qt.RightButton
cursorShape: Qt.PointingHandCursor
onTapped: M.NotifService.toggleDnd()
}
LazyLoader {
id: centerLoader
active: false
M.NotifCenter {
accentColor: root.accentColor
screen: root.bar.screen
anchorX: root.mapToGlobal(root.width / 2, 0).x - (QsWindow.window?.screen?.x ?? 0)
onDismissed: centerLoader.active = false
}
}
}

View file

@ -0,0 +1,129 @@
import QtQuick
import Quickshell
import Quickshell.Wayland
import "." as M
PanelWindow {
id: root
required property var screen
color: "transparent"
WlrLayershell.layer: WlrLayer.Background
WlrLayershell.exclusiveZone: -1
WlrLayershell.namespace: "nova-overview-backdrop"
mask: Region {}
anchors.top: true
anchors.left: true
anchors.right: true
anchors.bottom: true
Rectangle {
anchors.fill: parent
color: M.Theme.base01
}
ShaderEffect {
id: fx
anchors.fill: parent
fragmentShader: Quickshell.shellPath("modules/hex_wave.frag.qsb")
property real uSize: 50.0
property real uWavePhase: -200
property real uBreath: 0
property real uGlitch: 0
property real uGlitchSeed: 0.0
property vector4d uResolution: Qt.vector4d(width, height, 0, 0)
property color uC0: M.Theme.base0C
property color uC1: M.Theme.base0E
property color uC2: M.Theme.base09
Connections {
target: M.NiriIpc
function onOverviewOpenChanged() {
if (!M.NiriIpc.overviewOpen) {
fx.uWavePhase = -200;
fx.uBreath = 0;
}
}
}
// Wave animation: 6s sweep + 8s pause, only while overview is open
SequentialAnimation on uWavePhase {
loops: Animation.Infinite
running: M.NiriIpc.overviewOpen && !M.Theme.reducedMotion
NumberAnimation {
from: -200
to: fx.width + 200
duration: 6000
easing.type: Easing.InOutSine
}
PauseAnimation {
duration: 8000
}
}
// Breathing pulse while overview is open
SequentialAnimation on uBreath {
loops: Animation.Infinite
running: M.NiriIpc.overviewOpen && !M.Theme.reducedMotion
NumberAnimation {
from: 0
to: 1
duration: 2500
easing.type: Easing.InOutSine
}
NumberAnimation {
from: 1
to: 0
duration: 2500
easing.type: Easing.InOutSine
}
}
// Random subtle glitches fire every 1237s, total ~250ms each
Timer {
interval: 20000
repeat: true
running: !M.Theme.reducedMotion
onTriggered: {
interval = 12000 + Math.floor(Math.random() * 25000);
fx.uGlitchSeed = Math.random() * 1000.0;
_glitchAnim.start();
}
}
SequentialAnimation {
id: _glitchAnim
NumberAnimation {
target: fx
property: "uGlitch"
to: 0.7
duration: 50
easing.type: Easing.OutQuad
}
NumberAnimation {
target: fx
property: "uGlitch"
to: 0.15
duration: 50
}
NumberAnimation {
target: fx
property: "uGlitch"
to: 0.85
duration: 60
easing.type: Easing.OutQuad
}
NumberAnimation {
target: fx
property: "uGlitch"
to: 0
duration: 100
easing.type: Easing.InQuad
}
}
}
}

View file

@ -0,0 +1,12 @@
import QtQuick
import "." as M
Rectangle {
property color accentColor: M.Theme.base05
color: M.Theme.base01
opacity: Math.max(M.Theme.barOpacity, 0.85)
radius: M.Theme.radius
border.color: accentColor
border.width: 1
}

40
shell/modules/Power.qml Normal file
View file

@ -0,0 +1,40 @@
import QtQuick
import Quickshell
import Quickshell.Io
import "." as M
M.BarIcon {
id: root
icon: "\uF011"
tooltip: "Power menu"
required property var bar
Process {
id: runner
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
menuLoader.active = !menuLoader.active;
M.FlyoutState.visible = false;
}
}
LazyLoader {
id: menuLoader
active: false
M.PowerMenu {
accentColor: root.accentColor
screen: root.bar.screen
anchorX: root.mapToGlobal(root.width / 2, 0).x - (QsWindow.window?.screen?.x ?? 0)
onDismissed: menuLoader.active = false
onRunCommand: cmd => {
runner.command = cmd;
runner.running = true;
}
}
}
}

100
shell/modules/PowerMenu.qml Normal file
View file

@ -0,0 +1,100 @@
import QtQuick
import Quickshell
import "." as M
M.HoverPanel {
id: menuWindow
popupMode: true
contentWidth: 180
signal runCommand(var cmd)
readonly property bool _isNiri: Quickshell.env("NIRI_SOCKET") !== ""
function _run(cmd) {
runCommand(cmd);
dismiss();
}
Repeater {
model: [
{
label: "Lock",
icon: "\uF023",
cmd: ["loginctl", "lock-session"],
color: M.Theme.base0D
},
{
label: "Suspend",
icon: "\uF186",
cmd: ["systemctl", "suspend"],
color: M.Theme.base0E
},
{
label: "Logout",
icon: "\uF2F5",
cmd: menuWindow._isNiri ? ["niri", "msg", "action", "quit"] : ["loginctl", "terminate-user", ""],
color: M.Theme.base0A
},
{
label: "Reboot",
icon: "\uF021",
cmd: ["systemctl", "reboot"],
color: M.Theme.base09
},
{
label: "Shutdown",
icon: "\uF011",
cmd: ["systemctl", "poweroff"],
color: M.Theme.base08
}
]
delegate: Item {
id: entry
required property var modelData
required property int index
width: menuWindow.contentWidth
height: 32
Rectangle {
anchors.fill: parent
anchors.leftMargin: 4
anchors.rightMargin: 4
color: entryArea.containsMouse ? M.Theme.base02 : "transparent"
radius: M.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: M.Theme.fontSize + 1
font.family: M.Theme.iconFontFamily
}
Text {
anchors.verticalCenter: parent.verticalCenter
anchors.left: entryIcon.right
anchors.leftMargin: 10
text: entry.modelData.label
color: M.Theme.base05
font.pixelSize: M.Theme.fontSize
font.family: M.Theme.fontFamily
}
MouseArea {
id: entryArea
anchors.fill: parent
hoverEnabled: true
onClicked: menuWindow._run(entry.modelData.cmd)
}
}
}
}

View file

@ -0,0 +1,29 @@
import QtQuick
import "." as M
M.BarIcon {
id: root
tooltip: "Power profile: " + (M.PowerProfileService.profile || "unknown")
color: M.PowerProfileService.profile === "performance" ? M.Theme.base09 : M.PowerProfileService.profile === "power-saver" ? M.Theme.base0B : root.accentColor
icon: {
if (M.PowerProfileService.profile === "performance")
return "\uF0E7";
if (M.PowerProfileService.profile === "power-saver")
return "\uF06C";
if (M.PowerProfileService.profile === "balanced")
return "\uF24E";
return "\uF0E7";
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
const cycle = ["performance", "balanced", "power-saver"];
const idx = cycle.indexOf(M.PowerProfileService.profile);
M.PowerProfileService.set(cycle[(idx + 1) % cycle.length]);
}
}
}

View file

@ -0,0 +1,53 @@
pragma Singleton
import QtQuick
import Quickshell.Io
import "." as M
QtObject {
id: root
property string profile: ""
readonly property bool powerSaver: profile === "power-saver"
property var _proc: Process {
running: M.Modules.powerProfile.enable
command: ["powerprofilesctl", "get"]
stdout: StdioCollector {
onStreamFinished: root.profile = text.trim()
}
}
property var _monitor: Process {
running: M.Modules.powerProfile.enable
command: ["sh", "-c", "dbus-monitor --system \"interface='org.freedesktop.DBus.Properties',member='PropertiesChanged',path='/net/hadess/PowerProfiles'\" 2>/dev/null"]
stdout: SplitParser {
splitMarker: "\n"
onRead: _debounce.restart()
}
}
property var _debounce: Timer {
interval: 300
onTriggered: root._proc.running = true
}
property var _poll: Timer {
interval: 60000
running: M.Modules.powerProfile.enable
repeat: true
onTriggered: root._proc.running = true
}
function set(p) {
_setter.next = p;
_setter.running = true;
}
property var _setter: Process {
property string next: ""
command: ["powerprofilesctl", "set", next]
onRunningChanged: if (!running && next !== "")
root._proc.running = true
}
}

106
shell/modules/Privacy.qml Normal file
View file

@ -0,0 +1,106 @@
import QtQuick
import QtQuick.Effects
import Quickshell.Services.Pipewire
import "." as M
Row {
id: root
spacing: M.Theme.moduleSpacing
// Only detect active client streams, not hardware sources/devices
readonly property bool _videoCapture: {
if (!Pipewire.nodes)
return false;
for (const node of Pipewire.nodes.values) {
if (!node.isStream)
continue;
const mc = node.properties?.["media.class"] ?? "";
if (mc === "Stream/Input/Video" || mc === "Stream/Output/Video")
return true;
}
return false;
}
readonly property bool _audioIn: {
if (!Pipewire.nodes)
return false;
for (const node of Pipewire.nodes.values) {
if (!node.isStream)
continue;
const mc = node.properties?.["media.class"] ?? "";
if (mc === "Stream/Input/Audio")
return true;
}
return false;
}
visible: M.Modules.privacy.enable && (root._videoCapture || root._audioIn)
// Screenshare indicator
Text {
visible: root._videoCapture
text: "\uF03D"
color: M.Theme.base08
font.pixelSize: M.Theme.fontSize + 2
font.family: M.Theme.iconFontFamily
anchors.verticalCenter: parent.verticalCenter
layer.enabled: true
layer.effect: MultiEffect {
shadowEnabled: true
shadowColor: M.Theme.base08
shadowBlur: 0.8
shadowVerticalOffset: 0
shadowHorizontalOffset: 0
}
SequentialAnimation on opacity {
running: root._videoCapture
loops: Animation.Infinite
NumberAnimation {
to: 0.4
duration: 600
easing.type: Easing.InOutQuad
}
NumberAnimation {
to: 1
duration: 600
easing.type: Easing.InOutQuad
}
}
}
// Microphone indicator
Text {
visible: root._audioIn
text: "\uF130"
color: M.Theme.base0B
font.pixelSize: M.Theme.fontSize + 2
font.family: M.Theme.iconFontFamily
anchors.verticalCenter: parent.verticalCenter
layer.enabled: true
layer.effect: MultiEffect {
shadowEnabled: true
shadowColor: M.Theme.base0B
shadowBlur: 0.8
shadowVerticalOffset: 0
shadowHorizontalOffset: 0
}
SequentialAnimation on opacity {
running: root._audioIn
loops: Animation.Infinite
NumberAnimation {
to: 0.4
duration: 600
easing.type: Easing.InOutQuad
}
NumberAnimation {
to: 1
duration: 600
easing.type: Easing.InOutQuad
}
}
}
}

View file

@ -0,0 +1,43 @@
import QtQuick
import Quickshell.Io
QtObject {
id: root
property string sortBy: "cpu" // "cpu" or "mem"
property int maxItems: 8
property bool active: false
property var processes: []
property Process _proc: Process {
command: ["sh", "-c", "ps --no-headers -eo pid,pcpu,pmem,comm --sort=-%" + root.sortBy + " | head -" + root.maxItems]
stdout: StdioCollector {
onStreamFinished: {
const rows = [];
for (const line of text.trim().split("\n")) {
if (!line)
continue;
const p = line.trim().split(/\s+/);
if (p.length < 4)
continue;
rows.push({
"pid": parseInt(p[0]),
"cpu": parseFloat(p[1]),
"mem": parseFloat(p[2]),
"cmd": p.slice(3).join(" ")
});
}
root.processes = rows;
}
}
}
property Timer _timer: Timer {
interval: 2000
running: root.active
repeat: true
triggeredOnStart: true
onTriggered: root._proc.running = true
}
}

View file

@ -0,0 +1,93 @@
import QtQuick
import Quickshell
import Quickshell.Wayland
import "." as M
// Draws rounded black corners at the edges of each screen.
// Disabled when screenRadius is 0.
Item {
id: root
required property var screen
readonly property int _r: M.Theme.screenRadius
component Corner: PanelWindow {
id: win
property int corner: 0
screen: root.screen
visible: root._r > 0
color: "transparent"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.exclusiveZone: -1
WlrLayershell.namespace: "nova-corners"
mask: Region {}
implicitWidth: root._r
implicitHeight: root._r
Canvas {
anchors.fill: parent
onPaint: {
const r = root._r;
const ctx = getContext("2d");
ctx.clearRect(0, 0, r, r);
ctx.fillStyle = "black";
ctx.beginPath();
switch (win.corner) {
case 0: // top-left
ctx.moveTo(0, 0);
ctx.lineTo(r, 0);
ctx.arc(r, r, r, -Math.PI / 2, Math.PI, true);
ctx.closePath();
break;
case 1: // top-right
ctx.moveTo(r, 0);
ctx.lineTo(0, 0);
ctx.arc(0, r, r, -Math.PI / 2, 0, false);
ctx.closePath();
break;
case 2: // bottom-left
ctx.moveTo(0, r);
ctx.lineTo(0, 0);
ctx.arc(r, 0, r, Math.PI, Math.PI / 2, true);
ctx.closePath();
break;
case 3: // bottom-right
ctx.moveTo(r, r);
ctx.lineTo(r, 0);
ctx.arc(0, 0, r, 0, Math.PI / 2, false);
ctx.closePath();
break;
}
ctx.fill();
}
}
}
Corner {
corner: 0
anchors.top: true
anchors.left: true
}
Corner {
corner: 1
anchors.top: true
anchors.right: true
}
Corner {
corner: 2
anchors.bottom: true
anchors.left: true
}
Corner {
corner: 3
anchors.bottom: true
anchors.right: true
}
}

View file

@ -0,0 +1,185 @@
pragma Singleton
import QtQuick
import Quickshell.Io
import "." as M
QtObject {
id: root
// CPU
property int cpuUsage: 0
property real cpuFreqGhz: 0
property var cpuCores: [] // [{usage, freq_ghz, history:[]}] only rebuilt while coreConsumers > 0
property int coreConsumers: 0
onCoreConsumersChanged: {
if (coreConsumers > 0)
_coreGraceTimer.stop();
else
_coreGraceTimer.start();
}
property var _coreGraceTimer: Timer {
interval: 30000
onTriggered: root.cpuCores = root.cpuCores.map(() => ({
"usage": 0,
"freq_ghz": 0,
"history": []
}))
}
property var cpuCoreMaxFreq: []
property var cpuCoreTypes: []
// Temperature
property int tempCelsius: 0
property var tempHistory: [] // 150 samples @ 4s each 10 min
property var tempDevices: [] // [{name, celsius}] sorted hottest-first
// GPU
property bool gpuAvailable: false
property string gpuVendor: ""
property int gpuUsage: 0
property real gpuVramUsedGb: 0
property real gpuVramTotalGb: 0
property int gpuTempC: 0
property var gpuHistory: [] // 60 samples @ ~4-8s each 4-8 min
// Memory
property int memPercent: 0
property real memUsedGb: 0
property real memTotalGb: 0
property real memAvailGb: 0
property real memCachedGb: 0
property real memBuffersGb: 0
property var memHistory: []
// Disk
property var diskMounts: []
property int diskRootPct: 0
// nova-stats stream (cpu + mem)
property var _statsProc: Process {
running: true
command: {
const ms = M.Modules.statsDaemon.interval;
return ms > 0 ? ["nova-stats", "--interval", ms.toString()] : ["nova-stats"];
}
stdout: SplitParser {
splitMarker: "\n"
onRead: line => {
try {
const ev = JSON.parse(line);
if (ev.type === "cpu") {
root.cpuUsage = ev.usage;
root.cpuFreqGhz = ev.freq_ghz;
if (root.coreConsumers > 0) {
const histLen = 16;
const prev = root.cpuCores;
root.cpuCores = ev.cores.map((c, i) => {
const oldHist = prev[i]?.history ?? [];
const hist = oldHist.concat([c.usage]);
return {
usage: c.usage,
freq_ghz: c.freq_ghz,
history: hist.length > histLen ? hist.slice(hist.length - histLen) : hist
};
});
} else if (root.cpuCores.length !== ev.cores.length) {
// Keep count in sync so panel can size correctly before consumers activate
root.cpuCores = ev.cores.map(c => ({
"usage": c.usage,
"freq_ghz": c.freq_ghz,
"history": []
}));
}
} else if (ev.type === "temp") {
root.tempCelsius = ev.celsius;
const th = root.tempHistory.concat([ev.celsius]);
root.tempHistory = th.length > 150 ? th.slice(th.length - 150) : th;
if (ev.devices)
root.tempDevices = ev.devices;
} else if (ev.type === "gpu") {
root.gpuAvailable = true;
root.gpuVendor = ev.vendor;
root.gpuUsage = ev.usage;
root.gpuVramUsedGb = ev.vram_used_gb;
root.gpuVramTotalGb = ev.vram_total_gb;
root.gpuTempC = ev.temp_c;
const gh = root.gpuHistory.concat([ev.usage]);
root.gpuHistory = gh.length > 60 ? gh.slice(gh.length - 60) : gh;
} else if (ev.type === "mem") {
root.memPercent = ev.percent;
root.memUsedGb = ev.used_gb;
root.memTotalGb = ev.total_gb;
root.memAvailGb = ev.avail_gb;
root.memCachedGb = ev.cached_gb;
root.memBuffersGb = ev.buffers_gb;
const h = root.memHistory.concat([ev.percent]);
root.memHistory = h.length > 30 ? h.slice(h.length - 30) : h;
}
} catch (e) {}
}
}
}
// One-time: per-core max freq
property var _maxFreqProc: Process {
running: true
command: ["sh", "-c", "for f in /sys/devices/system/cpu/cpu[0-9]*/cpufreq/cpuinfo_max_freq; do [ -f \"$f\" ] && cat \"$f\" || echo 0; done 2>/dev/null"]
stdout: StdioCollector {
onStreamFinished: {
root.cpuCoreMaxFreq = text.trim().split("\n").filter(l => l).map(l => parseInt(l) / 1e6);
}
}
}
// One-time: P/E-core topology
property var _coreTypesProc: Process {
running: true
command: ["sh", "-c", "for d in /sys/devices/system/cpu/cpu[0-9]*/topology/core_type; do [ -f \"$d\" ] && cat \"$d\" || echo Performance; done 2>/dev/null"]
stdout: StdioCollector {
onStreamFinished: {
root.cpuCoreTypes = text.trim().split("\n").filter(l => l).map(l => l.trim());
}
}
}
// Disk via df
property var _diskProc: Process {
id: diskProc
running: true
command: ["sh", "-c", "df -x tmpfs -x devtmpfs -x squashfs -x efivarfs -x overlay -B1 --output=target,size,used 2>/dev/null | awk 'NR>1 && $2+0>0 {print $1\"|\"$2\"|\"$3}'"]
stdout: StdioCollector {
onStreamFinished: {
const lines = text.trim().split("\n").filter(l => l);
const mounts = [];
for (const line of lines) {
const parts = line.split("|");
if (parts.length < 3)
continue;
const total = parseInt(parts[1]);
const used = parseInt(parts[2]);
if (total <= 0)
continue;
mounts.push({
"target": parts[0],
"pct": Math.round(used / total * 100),
"usedBytes": used,
"totalBytes": total
});
}
root.diskMounts = mounts;
const rm = mounts.find(m => m.target === "/");
if (rm)
root.diskRootPct = rm.pct;
}
}
}
property var _diskTimer: Timer {
interval: M.Modules.disk.interval || 30000
running: true
repeat: true
onTriggered: diskProc.running = true
}
}

View file

@ -0,0 +1,326 @@
import QtQuick
import Quickshell
import "." as M
M.BarSection {
id: root
spacing: Math.max(1, M.Theme.moduleSpacing - 2)
tooltip: ""
readonly property int _warm: M.Modules.temperature.warm || 80
readonly property int _hot: M.Modules.temperature.hot || 90
readonly property string _deviceFilter: M.Modules.temperature.device || ""
// If a device filter is set, use that device's temp; otherwise fall back to system max
readonly property int _temp: {
if (_deviceFilter !== "") {
const dev = M.SystemStats.tempDevices.find(d => d.name === _deviceFilter);
if (dev)
return dev.celsius;
}
return M.SystemStats.tempCelsius;
}
property color _stateColor: _temp > _hot ? M.Theme.base08 : _temp > _warm ? M.Theme.base0A : root.accentColor
Behavior on _stateColor {
ColorAnimation {
duration: 300
}
}
property bool _pinned: false
readonly property bool _anyHover: root._hovered || hoverPanel.panelHovered
readonly property bool _showPanel: _anyHover || _pinned
on_AnyHoverChanged: {
if (_anyHover)
_unpinTimer.stop();
else if (_pinned)
_unpinTimer.start();
}
Timer {
id: _unpinTimer
interval: 500
onTriggered: root._pinned = false
}
// Returns a color interpolated greenyellowred for a given celsius value
function _tempColor(celsius) {
const t = Math.max(0, Math.min(100, celsius)) / 100;
const a = t < 0.5 ? M.Theme.base0B : M.Theme.base0A;
const b = t < 0.5 ? M.Theme.base0A : M.Theme.base08;
const u = t < 0.5 ? t * 2 : (t - 0.5) * 2;
return Qt.rgba(a.r + (b.r - a.r) * u, a.g + (b.g - a.g) * u, a.b + (b.b - a.b) * u, 1);
}
M.BarIcon {
icon: "\uF2C9"
color: root._stateColor
anchors.verticalCenter: parent.verticalCenter
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: root._pinned = !root._pinned
}
}
M.BarLabel {
label: root._temp + "\u00B0C"
minText: "100\u00B0C"
color: root._stateColor
anchors.verticalCenter: parent.verticalCenter
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: root._pinned = !root._pinned
}
}
M.HoverPanel {
id: hoverPanel
showPanel: root._showPanel
screen: QsWindow.window?.screen ?? null
anchorItem: root
accentColor: root.accentColor
panelNamespace: "nova-temperature"
panelTitle: "Temperature"
contentWidth: 220
// Header current temp
Item {
width: parent.width
height: 28
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: root._temp + "\u00B0C"
color: root._stateColor
font.pixelSize: M.Theme.fontSize
font.family: M.Theme.fontFamily
font.bold: true
width: _tempSizer.implicitWidth
horizontalAlignment: Text.AlignRight
Text {
id: _tempSizer
visible: false
text: "100\u00B0C"
font: parent.font
}
}
}
// Gauge bar (0100°C), with warm/hot threshold markers
Item {
width: parent.width
height: 16
Item {
id: _gaugeBar
anchors.left: parent.left
anchors.leftMargin: 12
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
height: 6
Rectangle {
anchors.fill: parent
color: M.Theme.base02
radius: 3
}
Rectangle {
width: parent.width * Math.min(1, root._temp / 100)
height: parent.height
color: root._stateColor
radius: 3
Behavior on width {
enabled: root._showPanel
NumberAnimation {
duration: 300
easing.type: Easing.OutCubic
}
}
}
// Warm threshold marker
Rectangle {
x: parent.width * (root._warm / 100) - 1
width: 1
height: parent.height + 4
anchors.verticalCenter: parent.verticalCenter
color: M.Theme.base0A
opacity: 0.6
}
// Hot threshold marker
Rectangle {
x: parent.width * (root._hot / 100) - 1
width: 1
height: parent.height + 4
anchors.verticalCenter: parent.verticalCenter
color: M.Theme.base08
opacity: 0.6
}
}
}
// History sparkline (~10 min @ 4s per sample)
Canvas {
id: _sparkline
anchors.left: parent.left
anchors.leftMargin: 12
anchors.right: parent.right
anchors.rightMargin: 12
height: 40
property var _hist: M.SystemStats.tempHistory
property color _col: root._stateColor
on_HistChanged: if (root._showPanel)
requestPaint()
on_ColChanged: if (root._showPanel)
requestPaint()
Connections {
target: root
function on_ShowPanelChanged() {
if (root._showPanel)
_sparkline.requestPaint();
}
}
onPaint: {
const ctx = getContext("2d");
if (!ctx)
return;
ctx.clearRect(0, 0, width, height);
const d = _hist;
if (!d.length)
return;
const maxSamples = 150;
const bw = width / maxSamples;
// Background tint
ctx.fillStyle = Qt.rgba(_col.r, _col.g, _col.b, 0.08).toString();
ctx.fillRect(0, 0, width, height);
// Warm threshold line
const warmY = height - height * (root._warm / 100);
ctx.strokeStyle = M.Theme.base0A.toString();
ctx.globalAlpha = 0.3;
ctx.lineWidth = 1;
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.moveTo(0, warmY);
ctx.lineTo(width, warmY);
ctx.stroke();
// Hot threshold line
const hotY = height - height * (root._hot / 100);
ctx.strokeStyle = M.Theme.base08.toString();
ctx.beginPath();
ctx.moveTo(0, hotY);
ctx.lineTo(width, hotY);
ctx.stroke();
ctx.setLineDash([]);
ctx.globalAlpha = 1.0;
// Bars
const offset = maxSamples - d.length;
for (let i = 0; i < d.length; i++) {
const barH = Math.max(1, height * d[i] / 100);
const barColor = d[i] > root._hot ? M.Theme.base08 : d[i] > root._warm ? M.Theme.base0A : _col;
ctx.fillStyle = barColor.toString();
ctx.fillRect((offset + i) * bw, height - barH, Math.max(1, bw - 0.5), barH);
}
}
}
// Threshold labels
Item {
width: parent.width
height: 16
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "warm " + root._warm + "\u00B0 hot " + root._hot + "\u00B0"
color: M.Theme.base03
font.pixelSize: M.Theme.fontSize - 3
font.family: M.Theme.fontFamily
font.letterSpacing: 0.5
}
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "10 min"
color: M.Theme.base03
font.pixelSize: M.Theme.fontSize - 3
font.family: M.Theme.fontFamily
}
}
// Per-device breakdown
Rectangle {
width: parent.width - 16
height: 1
anchors.horizontalCenter: parent.horizontalCenter
color: M.Theme.base03
visible: M.SystemStats.tempDevices.length > 0
}
Repeater {
model: M.SystemStats.tempDevices
delegate: Item {
required property var modelData
width: hoverPanel.contentWidth
height: 22
readonly property bool _isActive: root._deviceFilter === modelData.name
Rectangle {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 8
color: _isActive ? Qt.rgba(root.accentColor.r, root.accentColor.g, root.accentColor.b, 0.12) : "transparent"
radius: 3
}
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: modelData.name
color: _isActive ? root.accentColor : M.Theme.base04
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
elide: Text.ElideRight
width: parent.width - 80
}
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: modelData.celsius + "\u00B0C"
color: root._tempColor(modelData.celsius)
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
font.bold: _isActive
}
}
}
Item {
width: 1
height: 4
}
}
}

86
shell/modules/Theme.qml Normal file
View file

@ -0,0 +1,86 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
QtObject {
id: root
// base16 palette, overwritten from ~/.config/nova-shell/theme.json
property color base00: "#1e1e2e"
property color base01: "#181825"
property color base02: "#313244"
property color base03: "#45475a"
property color base04: "#585b70"
property color base05: "#cdd6f4"
property color base06: "#f5e0dc"
property color base07: "#b4befe"
property color base08: "#f38ba8"
property color base09: "#fab387"
property color base0A: "#f9e2af"
property color base0B: "#a6e3a1"
property color base0C: "#94e2d5"
property color base0D: "#89b4fa"
property color base0E: "#cba6f7"
property color base0F: "#f2cdcd"
property string fontFamily: "sans-serif"
property string iconFontFamily: "Symbols Nerd Font"
property int fontSize: 12
property real barOpacity: 0.9
property int barHeight: 32
property int barPadding: 8
property int moduleSpacing: 4
property int groupSpacing: 6
property int groupPadding: 8
property int radius: 4
property int screenRadius: 15
property bool _reducedMotionConfig: false
readonly property bool reducedMotion: _reducedMotionConfig || PowerProfileService.powerSaver
property FileView _themeFile: FileView {
path: (Quickshell.env("XDG_CONFIG_HOME") || (Quickshell.env("HOME") + "/.config")) + "/nova-shell/theme.json"
watchChanges: true
onFileChanged: reload()
onLoaded: root._apply(text())
}
function _apply(raw) {
let data;
try {
data = JSON.parse(raw);
} catch (e) {
return;
}
const c = data.colors || {};
for (const k of Object.keys(c)) {
if (k in root)
root[k] = c[k];
}
if (data.fontFamily)
root.fontFamily = data.fontFamily;
if (data.iconFontFamily)
root.iconFontFamily = data.iconFontFamily;
if (data.fontSize)
root.fontSize = data.fontSize;
if (data.barOpacity !== undefined)
root.barOpacity = data.barOpacity;
if (data.barHeight !== undefined)
root.barHeight = data.barHeight;
if (data.barPadding !== undefined)
root.barPadding = data.barPadding;
if (data.moduleSpacing !== undefined)
root.moduleSpacing = data.moduleSpacing;
if (data.groupSpacing !== undefined)
root.groupSpacing = data.groupSpacing;
if (data.groupPadding !== undefined)
root.groupPadding = data.groupPadding;
if (data.radius !== undefined)
root.radius = data.radius;
if (data.screenRadius !== undefined)
root.screenRadius = data.screenRadius;
if (data.reducedMotion !== undefined)
root._reducedMotionConfig = data.reducedMotion;
}
}

View file

@ -0,0 +1,16 @@
import QtQuick
import QtQuick.Effects
Image {
id: root
required property color tint
fillMode: Image.PreserveAspectFit
layer.enabled: true
layer.effect: MultiEffect {
colorization: 1.0
colorizationColor: root.tint
}
}

125
shell/modules/Tray.qml Normal file
View file

@ -0,0 +1,125 @@
import QtQuick
import QtQuick.Effects
import QtQuick.Layouts
import Quickshell
import Quickshell.Services.SystemTray
import "." as M
RowLayout {
id: root
spacing: M.Theme.moduleSpacing + 2
visible: M.Modules.tray.enable && SystemTray.items.length > 0
required property var bar
property var _activeMenu: null
Repeater {
model: SystemTray.items
delegate: Item {
id: iconItem
required property SystemTrayItem modelData
readonly property bool _needsAttention: modelData.status === 2
property bool _hovered: false
property real _pulseOpacity: 1
implicitWidth: M.Theme.fontSize + 4
implicitHeight: M.Theme.fontSize + 4
SequentialAnimation {
running: iconItem._needsAttention
loops: Animation.Infinite
NumberAnimation {
target: iconItem
property: "_pulseOpacity"
to: 0.3
duration: 400
easing.type: Easing.InOutQuad
}
NumberAnimation {
target: iconItem
property: "_pulseOpacity"
to: 1
duration: 400
easing.type: Easing.InOutQuad
}
onRunningChanged: if (!running)
iconItem._pulseOpacity = 1
}
Item {
anchors.fill: parent
opacity: iconItem._pulseOpacity
layer.enabled: iconItem._needsAttention || iconItem._hovered
layer.effect: MultiEffect {
shadowEnabled: true
shadowColor: iconItem._needsAttention ? M.Theme.base08 : M.Theme.base05
shadowBlur: iconItem._needsAttention ? 0.8 : 0.5
shadowVerticalOffset: 0
shadowHorizontalOffset: 0
}
M.ThemedIcon {
anchors.fill: parent
source: iconItem.modelData.icon
tint: iconItem._needsAttention ? M.Theme.base08 : (root.parent?.accentColor ?? M.Theme.base05)
}
}
HoverHandler {
onHoveredChanged: {
iconItem._hovered = hovered;
const tip = [iconItem.modelData.tooltipTitle, iconItem.modelData.tooltipDescription].filter(s => s).join("\n") || iconItem.modelData.title;
if (hovered && tip) {
M.FlyoutState.text = tip;
M.FlyoutState.itemX = iconItem.mapToGlobal(iconItem.width / 2, 0).x - (QsWindow.window?.screen?.x ?? 0);
M.FlyoutState.screen = QsWindow.window?.screen ?? null;
M.FlyoutState.accentColor = root.parent?.accentColor ?? M.Theme.base05;
M.FlyoutState.visible = true;
} else if (!hovered) {
M.FlyoutState.visible = false;
}
}
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: mouse => {
if (mouse.button === Qt.LeftButton) {
iconItem.modelData.activate();
} else if (mouse.button === Qt.RightButton) {
if (iconItem.modelData.menu) {
if (root._activeMenu && root._activeMenu !== menuLoader)
root._activeMenu.active = false;
menuLoader.active = true;
M.FlyoutState.visible = false;
root._activeMenu = menuLoader;
} else {
iconItem.modelData.secondaryActivate();
}
}
}
}
// Per-icon context menu window, created on demand
LazyLoader {
id: menuLoader
active: false
M.TrayMenu {
accentColor: root.parent?.accentColor ?? M.Theme.base05
handle: iconItem.modelData.menu
screen: root.bar.screen
anchorX: iconItem.mapToGlobal(iconItem.width / 2, 0).x - (QsWindow.window?.screen?.x ?? 0)
onDismissed: {
menuLoader.active = false;
root._activeMenu = null;
}
}
}
}
}
}

142
shell/modules/TrayMenu.qml Normal file
View file

@ -0,0 +1,142 @@
import QtQuick
import Quickshell
import "." as M
M.HoverPanel {
id: menuWindow
popupMode: true
required property var handle
property var _currentHandle: handle
property var _handleStack: []
property QsMenuOpener _opener: QsMenuOpener {
menu: menuWindow._currentHandle
}
// Back button (submenus only)
Item {
visible: menuWindow._handleStack.length > 0
width: menuWindow.contentWidth
height: visible ? 28 : 0
Rectangle {
anchors.fill: parent
anchors.leftMargin: 4
anchors.rightMargin: 4
color: backArea.containsMouse ? M.Theme.base02 : "transparent"
radius: M.Theme.radius
}
Text {
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: 12
text: "\u2039 Back"
color: M.Theme.base05
font.pixelSize: M.Theme.fontSize
font.family: M.Theme.fontFamily
}
MouseArea {
id: backArea
anchors.fill: parent
hoverEnabled: true
onClicked: {
const stack = menuWindow._handleStack.slice();
menuWindow._currentHandle = stack.pop();
menuWindow._handleStack = stack;
}
}
}
Repeater {
model: menuWindow._opener.children
delegate: Item {
id: entryItem
required property QsMenuEntry modelData
width: menuWindow.contentWidth
height: modelData.isSeparator ? 9 : 28
Rectangle {
visible: entryItem.modelData.isSeparator
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: 8
anchors.rightMargin: 8
height: 1
color: M.Theme.base03
}
Rectangle {
visible: !entryItem.modelData.isSeparator
anchors.fill: parent
anchors.leftMargin: 4
anchors.rightMargin: 4
color: rowArea.containsMouse && entryItem.modelData.enabled ? M.Theme.base02 : "transparent"
radius: M.Theme.radius
}
M.ThemedIcon {
id: entryIcon
visible: !entryItem.modelData.isSeparator && entryItem.modelData.icon !== ""
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: 12
width: M.Theme.fontSize
height: M.Theme.fontSize
source: entryItem.modelData.icon
tint: menuWindow.accentColor
fillMode: Image.PreserveAspectFit
}
Text {
visible: !entryItem.modelData.isSeparator
anchors.verticalCenter: parent.verticalCenter
anchors.left: entryIcon.visible ? entryIcon.right : parent.left
anchors.leftMargin: entryIcon.visible ? 6 : 12
anchors.right: entryChevron.visible ? entryChevron.left : parent.right
anchors.rightMargin: entryChevron.visible ? 4 : 12
text: entryItem.modelData.text
color: entryItem.modelData.enabled ? M.Theme.base05 : M.Theme.base03
font.pixelSize: M.Theme.fontSize
font.family: M.Theme.fontFamily
elide: Text.ElideRight
}
Text {
id: entryChevron
visible: !entryItem.modelData.isSeparator && entryItem.modelData.hasChildren
anchors.verticalCenter: parent.verticalCenter
anchors.right: parent.right
anchors.rightMargin: 12
text: "\u203A"
color: entryItem.modelData.enabled ? M.Theme.base05 : M.Theme.base03
font.pixelSize: M.Theme.fontSize
font.family: M.Theme.fontFamily
}
MouseArea {
id: rowArea
anchors.fill: parent
hoverEnabled: true
enabled: !entryItem.modelData.isSeparator && entryItem.modelData.enabled
onClicked: {
if (entryItem.modelData.hasChildren) {
menuWindow._handleStack = menuWindow._handleStack.concat([menuWindow._currentHandle]);
menuWindow._currentHandle = entryItem.modelData;
} else {
entryItem.modelData.triggered();
menuWindow.dismiss();
}
}
}
}
}
}

391
shell/modules/Volume.qml Normal file
View file

@ -0,0 +1,391 @@
import QtQuick
import Quickshell
import Quickshell.Services.Pipewire
import "." as M
M.BarSection {
id: root
spacing: M.Theme.moduleSpacing
tooltip: ""
PwObjectTracker {
objects: [Pipewire.defaultAudioSink, ...root._streamList]
}
readonly property var sink: Pipewire.defaultAudioSink
readonly property real volume: sink?.audio?.volume ?? 0
readonly property bool muted: sink?.audio?.muted ?? false
readonly property string _volumeIcon: muted ? "\uF026" : (volume > 0.5 ? "\uF028" : (volume > 0 ? "\uF027" : "\uF026"))
readonly property color _volumeColor: muted ? M.Theme.base04 : root.accentColor
readonly property var _sinkList: {
const sinks = [];
if (Pipewire.nodes) {
for (const node of Pipewire.nodes.values)
if (!node.isStream && node.isSink)
sinks.push(node);
}
return sinks;
}
readonly property var _streamList: {
const streams = [];
if (Pipewire.nodes) {
for (const node of Pipewire.nodes.values)
if (node.isStream && node.audio)
streams.push(node);
}
return streams;
}
property bool _osdActive: false
property bool _volumeInit: false
property bool _mutedInit: false
readonly property bool _anyHover: root._hovered || hoverPanel.panelHovered
readonly property bool _showPanel: _anyHover || _osdActive
onVolumeChanged: {
if (!_volumeInit) {
_volumeInit = true;
return;
}
_flashPanel();
}
onMutedChanged: {
if (!_mutedInit) {
_mutedInit = true;
return;
}
_flashPanel();
}
function _flashPanel() {
_osdActive = true;
_osdTimer.restart();
}
Timer {
id: _osdTimer
interval: 1500
onTriggered: root._osdActive = false
}
M.BarIcon {
icon: root._volumeIcon
minIcon: "\uF028"
color: root._volumeColor
anchors.verticalCenter: parent.verticalCenter
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: if (root.sink?.audio)
root.sink.audio.muted = !root.sink.audio.muted
}
}
M.BarLabel {
label: Math.round(root.volume * 100) + "%"
minText: "100%"
color: root._volumeColor
anchors.verticalCenter: parent.verticalCenter
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: if (root.sink?.audio)
root.sink.audio.muted = !root.sink.audio.muted
}
}
WheelHandler {
onWheel: event => {
if (!root.sink?.audio)
return;
root.sink.audio.volume = Math.max(0, root.sink.audio.volume + (event.angleDelta.y > 0 ? 0.05 : -0.05));
}
}
M.HoverPanel {
id: hoverPanel
showPanel: root._showPanel
screen: QsWindow.window?.screen ?? null
anchorItem: root
accentColor: root.accentColor
panelNamespace: "nova-volume"
panelTitle: "Sound"
contentWidth: 220
// Slider row
Item {
width: parent.width
height: 36
Text {
id: muteIcon
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: root._volumeIcon
color: root._volumeColor
font.pixelSize: M.Theme.fontSize + 2
font.family: M.Theme.iconFontFamily
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: if (root.sink?.audio)
root.sink.audio.muted = !root.sink.audio.muted
}
}
Item {
id: slider
anchors.left: muteIcon.right
anchors.leftMargin: 8
anchors.right: volLabel.left
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
height: 6
Rectangle {
anchors.fill: parent
color: M.Theme.base02
radius: 3
}
Rectangle {
width: parent.width * Math.min(1, Math.max(0, root.volume))
height: parent.height
color: root._volumeColor
radius: 3
Behavior on width {
NumberAnimation {
duration: 80
}
}
}
MouseArea {
anchors.fill: parent
anchors.margins: -6
cursorShape: Qt.PointingHandCursor
onPressed: mouse => _setVol(mouse)
onPositionChanged: mouse => {
if (pressed)
_setVol(mouse);
}
function _setVol(mouse) {
if (!root.sink?.audio)
return;
root.sink.audio.volume = Math.max(0, Math.min(1, mouse.x / slider.width));
}
}
}
Text {
id: volLabel
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: Math.round(root.volume * 100) + "%"
color: root.muted ? M.Theme.base04 : M.Theme.base05
font.pixelSize: M.Theme.fontSize
font.family: M.Theme.fontFamily
width: 30
}
}
// Device + stream list
Column {
id: deviceList
width: parent.width
// Output devices only shown when more than one exists
Column {
visible: root._sinkList.length > 1
width: parent.width
Rectangle {
width: parent.width - 16
height: 1
anchors.horizontalCenter: parent.horizontalCenter
color: M.Theme.base03
}
Text {
width: parent.width
height: 24
verticalAlignment: Text.AlignVCenter
leftPadding: 12
text: "Output Devices"
color: root.accentColor
font.pixelSize: M.Theme.fontSize - 1
font.family: M.Theme.fontFamily
}
Repeater {
model: root._sinkList
delegate: Item {
required property var modelData
width: deviceList.width
height: 28
readonly property bool _active: modelData === root.sink
Rectangle {
anchors.fill: parent
anchors.leftMargin: 4
anchors.rightMargin: 4
color: deviceHover.hovered ? M.Theme.base02 : "transparent"
radius: M.Theme.radius
}
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: modelData.description || modelData.name || "Unknown"
color: parent._active ? root.accentColor : M.Theme.base05
font.pixelSize: M.Theme.fontSize
font.family: M.Theme.fontFamily
font.bold: parent._active
elide: Text.ElideRight
}
HoverHandler {
id: deviceHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: Pipewire.preferredDefaultAudioSink = modelData
}
}
}
}
// Streams section
Rectangle {
visible: root._streamList.length > 0
width: parent.width - 16
height: visible ? 1 : 0
anchors.horizontalCenter: parent.horizontalCenter
color: M.Theme.base03
}
Text {
visible: root._streamList.length > 0
width: parent.width
height: visible ? 24 : 0
verticalAlignment: Text.AlignVCenter
leftPadding: 12
text: "Applications"
color: root.accentColor
font.pixelSize: M.Theme.fontSize - 1
font.family: M.Theme.fontFamily
}
Repeater {
model: root._streamList
delegate: Item {
id: streamEntry
required property var modelData
width: deviceList.width
height: 32
readonly property string _appName: modelData.properties["application.name"] || modelData.description || modelData.name || "Unknown"
readonly property real _vol: modelData.audio?.volume ?? 0
readonly property bool _muted: modelData.audio?.muted ?? false
Text {
id: streamIcon
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: streamEntry._muted ? "\uF026" : "\uF028"
color: streamEntry._muted ? M.Theme.base04 : M.Theme.base05
font.pixelSize: M.Theme.fontSize
font.family: M.Theme.iconFontFamily
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: if (streamEntry.modelData.audio)
streamEntry.modelData.audio.muted = !streamEntry.modelData.audio.muted
}
}
Text {
id: streamName
anchors.left: streamIcon.right
anchors.leftMargin: 6
anchors.verticalCenter: parent.verticalCenter
text: streamEntry._appName
color: M.Theme.base05
font.pixelSize: M.Theme.fontSize - 1
font.family: M.Theme.fontFamily
elide: Text.ElideRight
width: 70
}
Item {
id: streamSlider
anchors.left: streamName.right
anchors.leftMargin: 6
anchors.right: streamVol.left
anchors.rightMargin: 6
anchors.verticalCenter: parent.verticalCenter
height: 4
Rectangle {
anchors.fill: parent
color: M.Theme.base02
radius: 2
}
Rectangle {
width: parent.width * Math.min(1, Math.max(0, streamEntry._vol))
height: parent.height
color: streamEntry._muted ? M.Theme.base04 : root.accentColor
radius: 2
}
MouseArea {
anchors.fill: parent
anchors.margins: -6
cursorShape: Qt.PointingHandCursor
onPressed: mouse => _set(mouse)
onPositionChanged: mouse => {
if (pressed)
_set(mouse);
}
function _set(mouse) {
if (!streamEntry.modelData.audio)
return;
streamEntry.modelData.audio.volume = Math.max(0, Math.min(1, mouse.x / streamSlider.width));
}
}
}
Text {
id: streamVol
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: Math.round(streamEntry._vol * 100) + "%"
color: streamEntry._muted ? M.Theme.base04 : M.Theme.base05
font.pixelSize: M.Theme.fontSize - 1
font.family: M.Theme.fontFamily
width: 28
}
}
}
// Bottom padding
Item {
width: 1
height: 4
}
}
}
}

40
shell/modules/Weather.qml Normal file
View file

@ -0,0 +1,40 @@
import QtQuick
import Quickshell.Io
import "." as M
M.BarSection {
id: root
spacing: M.Theme.moduleSpacing
tooltip: root.weatherTooltip
property string weatherTooltip: ""
Process {
id: proc
running: true
command: ["wttrbar"].concat(M.Modules.weather.args)
stdout: StdioCollector {
onStreamFinished: {
try {
const data = JSON.parse(text);
label.icon = data.text ?? "";
root.weatherTooltip = data.tooltip ?? "";
} catch (e) {
label.icon = "";
root.weatherTooltip = "";
}
}
}
}
Timer {
interval: M.Modules.weather.interval || 3600000
running: true
repeat: true
onTriggered: proc.running = true
}
M.BarIcon {
id: label
anchors.verticalCenter: parent.verticalCenter
}
}

View file

@ -0,0 +1,45 @@
import QtQuick
import QtQuick.Effects
import Quickshell
import Quickshell.Widgets
import "." as M
M.BarSection {
id: root
spacing: M.Theme.moduleSpacing
tooltip: M.NiriIpc.focusedAppId ? M.NiriIpc.focusedAppId + "\n" + M.NiriIpc.focusedTitle : M.NiriIpc.focusedTitle
readonly property string _iconSource: {
if (!M.NiriIpc.focusedAppId)
return "";
const entry = DesktopEntries.heuristicLookup(M.NiriIpc.focusedAppId);
return entry ? Quickshell.iconPath(entry.icon) : "";
}
readonly property real _iconOffset: _icon.visible ? _icon.width + root.spacing : 0
// Natural content width Bar.qml uses this to cap the group width
readonly property real naturalWidth: _iconOffset + _label.implicitWidth
IconImage {
id: _icon
visible: root._iconSource !== ""
source: root._iconSource
implicitSize: M.Theme.fontSize + 2
anchors.verticalCenter: parent.verticalCenter
layer.enabled: true
layer.effect: MultiEffect {
colorization: 1.0
colorizationColor: root.accentColor
}
}
M.BarLabel {
id: _label
label: M.NiriIpc.focusedTitle
color: root.accentColor
elide: Text.ElideRight
anchors.verticalCenter: parent.verticalCenter
width: Math.min(implicitWidth, Math.max(0, root.width - root._iconOffset))
}
}

View file

@ -0,0 +1,110 @@
import QtQuick
import Quickshell
import Quickshell.Io
import "." as M
Row {
id: root
spacing: 4
required property var bar
property var _allWorkspaces: []
property int _activeId: -1
readonly property string _output: bar.screen?.name ?? ""
readonly property var _workspaces: _allWorkspaces.filter(w => w.output === root._output)
// Initial state
Process {
id: initProc
running: true
command: ["niri", "msg", "--json", "workspaces"]
stdout: StdioCollector {
onStreamFinished: {
try {
const ws = JSON.parse(text);
root._allWorkspaces = ws.sort((a, b) => a.idx - b.idx);
for (const w of ws) {
if (w.is_focused)
root._activeId = w.id;
}
} catch (e) {}
}
}
}
// Live updates via shared NiriIpc singleton
Connections {
target: M.NiriIpc
function onWorkspacesChanged(workspaces) {
root._allWorkspaces = workspaces.sort((a, b) => a.idx - b.idx);
}
function onWorkspaceActivated(id, focused) {
if (focused)
root._activeId = id;
}
}
Process {
id: switchProc
property int wsId: -1
command: ["niri", "msg", "action", "focus-workspace", String(wsId)]
}
Repeater {
model: root._workspaces
delegate: Rectangle {
id: pill
required property var modelData
readonly property bool active: modelData.id === root._activeId
property bool _hovered: false
HoverHandler {
onHoveredChanged: {
pill._hovered = hovered;
const name = pill.modelData.name || ("Workspace " + pill.modelData.idx);
if (hovered) {
M.FlyoutState.text = name;
M.FlyoutState.itemX = pill.mapToGlobal(pill.width / 2, 0).x - (QsWindow.window?.screen?.x ?? 0);
M.FlyoutState.screen = QsWindow.window?.screen ?? null;
M.FlyoutState.accentColor = root.parent?.accentColor ?? M.Theme.base05;
M.FlyoutState.visible = true;
} else {
M.FlyoutState.visible = false;
}
}
}
width: M.Theme.fontSize + 4
height: M.Theme.fontSize + 4
radius: width / 2
color: pill.active ? (root.parent?.accentColor ?? M.Theme.base0D) : (pill._hovered ? M.Theme.base03 : M.Theme.base02)
Behavior on color {
ColorAnimation {
duration: 150
}
}
Text {
anchors.centerIn: parent
text: pill.modelData.idx
color: pill.active ? M.Theme.base00 : (root.parent?.accentColor ?? M.Theme.base05)
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
font.bold: pill.active
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
switchProc.wsId = pill.modelData.id;
switchProc.running = true;
}
}
}
}
}

115
shell/modules/hex_wave.frag Normal file
View file

@ -0,0 +1,115 @@
#version 440
layout(location = 0) in vec2 qt_TexCoord0;
layout(location = 0) out vec4 fragColor;
layout(std140, binding = 0) uniform buf {
mat4 qt_Matrix;
float qt_Opacity;
float uSize;
float uWavePhase;
float uBreath;
float uGlitch;
float uGlitchSeed;
vec4 uResolution;
vec4 uC0;
vec4 uC1;
vec4 uC2;
};
float sdHexagon(vec2 p, float r) {
const vec3 k = vec3(-0.866025404, 0.5, 0.577350269);
p = abs(p);
p -= 2.0 * min(dot(k.xy, p), 0.0) * k.xy;
p -= vec2(clamp(p.x, -k.z * r, k.z * r), r);
return length(p) * sign(p.y);
}
// Interpolate between three theme stops (t in [0,1])
vec3 themeGradient(float t) {
return t < 0.5
? mix(uC0.rgb, uC1.rgb, t * 2.0)
: mix(uC1.rgb, uC2.rgb, (t - 0.5) * 2.0);
}
// Cheap hash for glitch bands
float hash(float n) {
return fract(sin(n) * 43758.5453123);
}
void main() {
vec2 res = uResolution.xy;
// Glitch: shift some horizontal bands slightly
vec2 frag = qt_TexCoord0 * res;
if (uGlitch > 0.0) {
float band = floor(frag.y / 7.0);
float h = hash(band * 127.1 + uGlitchSeed * 311.7);
if (h > 0.72) {
float shift = (hash(band * 93.9 + uGlitchSeed) - 0.5) * 14.0 * uGlitch;
frag.x = clamp(frag.x + shift, 0.0, res.x);
}
}
float dx = uSize * 1.5;
float dy = uSize * 1.7320508;
float col = round(frag.x / dx);
float yoff = mod(col, 2.0) != 0.0 ? dy * 0.5 : 0.0;
float row = round((frag.y - yoff) / dy);
vec2 center = vec2(col * dx, row * dy + yoff);
// Wave — wide gaussian so it reads more as a broad pulse than a sharp sweep
float dist = center.x - uWavePhase;
float wf = exp(-dist * dist / 40000.0);
float baseR = uSize * 0.48;
float inradius = baseR * (1.0 + 0.15 * wf);
vec2 p = frag - center;
float d = sdHexagon(p.yx, inradius); // swap for flat-top
if (d > 0.0) {
fragColor = vec4(0.0);
return;
}
// Gradient color
float fx = clamp(center.x / res.x, 0.0, 1.0);
vec3 rgb = fx < 0.5
? mix(uC0.rgb, uC1.rgb, fx * 2.0)
: mix(uC1.rgb, uC2.rgb, (fx - 0.5) * 2.0);
// Alpha from distance to center (vignette)
float fy = clamp(center.y / res.y, 0.0, 1.0);
float dc = length(vec2(fx - 0.5, fy - 0.5));
float a = 0.03 + dc * 0.06;
// Breathing pulse (when overview open)
a += 0.025 * uBreath;
rgb = min(rgb + vec3(0.04 * uBreath), vec3(1.0));
// Wave brighten (softer than before)
rgb = min(rgb + vec3(0.15 * wf), vec3(1.0));
a += 0.07 * wf;
// Shimmer on hex edges as wave passes
float edgeWidth = 5.0;
float edgeFactor = smoothstep(-edgeWidth, 0.0, d);
if (wf > 0.01 && edgeFactor > 0.0) {
float angle = atan(p.y, p.x);
float rawT = fract((angle + 3.14159) / 6.28318 + center.x * 0.003 + center.y * 0.005);
float t = abs(rawT * 2.0 - 1.0);
vec3 shimmerColor = themeGradient(t);
float shimmer = edgeFactor * wf;
rgb = mix(rgb, shimmerColor, shimmer * 0.5);
a = mix(a, 0.5, shimmer * 0.35);
}
// Anti-alias outer edge
float aa = 1.0 - smoothstep(-1.0, 0.0, d);
a *= aa;
fragColor = vec4(rgb * a, a) * qt_Opacity;
}

View file

@ -0,0 +1,78 @@
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import ".." as M
Scope {
id: root
readonly property bool _enabled: M.Modules.lock.enable
property string _sessionPath: ""
WlSessionLock {
id: _lock
LockSurface {
lock: _lock
auth: _auth
}
}
LockAuth {
id: _auth
lock: _lock
}
// Resolve the actual logind session object path at startup
Process {
id: _sessionResolver
command: ["busctl", "call", "--system", "org.freedesktop.login1", "/org/freedesktop/login1", "org.freedesktop.login1.Manager", "GetSession", "s", "auto"]
running: root._enabled
stdout: SplitParser {
onRead: data => {
// Output: o "/org/freedesktop/login1/session/_32"
const match = data.match(/"([^"]+)"/);
if (match)
root._sessionPath = match[1];
}
}
onExited: {
if (root._sessionPath)
_logindMonitor.running = true;
}
}
// Listen for logind Lock/Unlock signals via gdbus monitor.
// TODO: replace with native D-Bus integration when nova-stats becomes a quickshell plugin
Process {
id: _logindMonitor
command: ["gdbus", "monitor", "--system", "--dest", "org.freedesktop.login1", "--object-path", root._sessionPath]
running: false
stdout: SplitParser {
onRead: data => {
if (data.indexOf(".Lock ()") !== -1 && root._enabled)
_lock.locked = true;
// Unlock is PAM-driven, ignore logind Unlock signal
}
}
}
// Set logind LockedHint when lock state changes
Process {
id: _lockedHint
command: ["busctl", "call", "--system", "org.freedesktop.login1", root._sessionPath || "/org/freedesktop/login1/session/auto", "org.freedesktop.login1.Session", "SetLockedHint", "b", _lock.locked ? "true" : "false"]
}
Connections {
target: _lock
function onLockStateChanged() {
if (root._sessionPath)
_lockedHint.running = true;
}
}
}

View file

@ -0,0 +1,91 @@
import QtQuick
import Quickshell
import Quickshell.Wayland
import Quickshell.Services.Pam
QtObject {
id: root
required property WlSessionLock lock
// Auth state: "", "busy", "fail", "error", "max"
property string state: ""
property string message: ""
property string buffer: ""
signal unlockRequested
signal authFailed
function submit() {
if (_passwd.active || state === "max")
return;
if (buffer.length > 0)
_passwd.start();
}
property PamContext _passwd: PamContext {
config: "nova-shell"
configDirectory: Quickshell.shellDir + "/assets/pam.d"
onActiveChanged: {
if (active)
root.state = "busy";
}
onResponseRequiredChanged: {
if (!responseRequired)
return;
respond(root.buffer);
root.buffer = "";
}
onCompleted: res => {
if (res === PamResult.Success) {
root.state = "";
root.message = "";
root.unlockRequested();
return;
}
if (res === PamResult.Error) {
root.state = "error";
root.message = "Authentication error";
} else if (res === PamResult.MaxTries) {
root.state = "max";
root.message = "Too many attempts";
} else if (res === PamResult.Failed) {
root.state = "fail";
root.message = "Wrong password";
}
root.authFailed();
_stateReset.restart();
}
onMessageChanged: {
if (message.startsWith("The account is locked"))
root.message = message;
}
}
property Timer _stateReset: Timer {
interval: 3000
onTriggered: {
if (root.state !== "max")
root.state = "";
}
}
// Reset state when lock becomes secure (freshly locked)
property Connections _lockConn: Connections {
target: root.lock
function onSecureChanged() {
if (root.lock.secure) {
root.buffer = "";
root.state = "";
root.message = "";
}
}
}
}

View file

@ -0,0 +1,104 @@
import QtQuick
import ".." as M
Item {
id: root
required property string buffer
required property string state
implicitHeight: 48
implicitWidth: 280
// Background pill
Rectangle {
anchors.fill: parent
color: {
if (root.state === "fail" || root.state === "error")
return Qt.rgba(M.Theme.base08.r, M.Theme.base08.g, M.Theme.base08.b, 0.15);
if (root.state === "busy")
return Qt.rgba(M.Theme.base0D.r, M.Theme.base0D.g, M.Theme.base0D.b, 0.1);
return M.Theme.base02;
}
radius: height / 2
border.color: {
if (root.state === "fail" || root.state === "error")
return M.Theme.base08;
if (root.state === "busy")
return M.Theme.base0D;
return M.Theme.base03;
}
border.width: 1
Behavior on color {
ColorAnimation {
duration: 200
}
}
Behavior on border.color {
ColorAnimation {
duration: 200
}
}
}
// Placeholder text
Text {
anchors.centerIn: parent
text: {
if (root.state === "busy")
return "Authenticating...";
if (root.state === "max")
return "Too many attempts";
return "Enter password";
}
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize
font.family: M.Theme.fontFamily
opacity: root.buffer.length === 0 ? 1 : 0
Behavior on opacity {
NumberAnimation {
duration: 150
}
}
}
// Password dots
Row {
anchors.centerIn: parent
spacing: 6
Repeater {
model: root.buffer.length
delegate: Rectangle {
required property int index
width: 10
height: 10
radius: 5
color: M.Theme.base05
scale: 0
opacity: 0
Component.onCompleted: {
scale = 1;
opacity = 1;
}
Behavior on scale {
NumberAnimation {
duration: 120
easing.type: Easing.OutBack
}
}
Behavior on opacity {
NumberAnimation {
duration: 100
}
}
}
}
}
}

View file

@ -0,0 +1,272 @@
import QtQuick
import QtQuick.Effects
import Quickshell
import Quickshell.Wayland
import ".." as M
WlSessionLockSurface {
id: root
required property WlSessionLock lock
required property LockAuth auth
color: M.Theme.base00
// Blur screenshot of desktop as background
ScreencopyView {
id: background
anchors.fill: parent
captureSource: root.screen
opacity: 0
layer.enabled: true
layer.effect: MultiEffect {
autoPaddingEnabled: false
blurEnabled: true
blur: 1
blurMax: 64
}
NumberAnimation on opacity {
to: 1
duration: 400
easing.type: Easing.OutCubic
}
}
// Dim overlay
Rectangle {
anchors.fill: parent
color: Qt.rgba(M.Theme.base00.r, M.Theme.base00.g, M.Theme.base00.b, 0.4)
opacity: background.opacity
}
// Center content
Item {
id: content
anchors.centerIn: parent
width: 320
height: _col.height
opacity: 0
scale: 0.9
NumberAnimation on opacity {
to: 1
duration: 300
easing.type: Easing.OutCubic
}
NumberAnimation on scale {
to: 1
duration: 300
easing.type: Easing.OutCubic
}
Column {
id: _col
anchors.horizontalCenter: parent.horizontalCenter
spacing: 24
// Clock
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: Qt.formatTime(new Date(), "HH:mm")
color: M.Theme.base05
font.pixelSize: 72
font.family: M.Theme.fontFamily
font.bold: true
Timer {
interval: 1000
running: true
repeat: true
onTriggered: parent.text = Qt.formatTime(new Date(), "HH:mm")
}
}
// Date
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: Qt.formatDate(new Date(), "dddd, d MMMM")
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize + 2
font.family: M.Theme.fontFamily
Timer {
interval: 60000
running: true
repeat: true
onTriggered: parent.text = Qt.formatDate(new Date(), "dddd, d MMMM")
}
}
// Spacer
Item {
width: 1
height: 24
}
// Password input
LockInput {
anchors.horizontalCenter: parent.horizontalCenter
width: 280
buffer: root.auth.buffer
state: root.auth.state
}
// Error message
Text {
id: _errorText
anchors.horizontalCenter: parent.horizontalCenter
text: root.auth.message
color: M.Theme.base08
font.pixelSize: M.Theme.fontSize - 1
font.family: M.Theme.fontFamily
opacity: root.auth.message ? 1 : 0
height: root.auth.message ? implicitHeight : 0
Behavior on opacity {
NumberAnimation {
duration: 200
}
}
Behavior on height {
NumberAnimation {
duration: 200
easing.type: Easing.OutCubic
}
}
}
}
}
// Keyboard input via TextInput - engages Qt's full input pipeline including
// text-input protocol, which is more reliable than Keys on a plain Item in
// layer-shell/lock surfaces where raw wl_keyboard delivery can be flaky.
TextInput {
id: _keyInput
anchors.fill: parent
focus: true
color: "transparent"
selectionColor: "transparent"
selectedTextColor: "transparent"
cursorVisible: false
enabled: !root._unlocking && root.auth.state !== "max" && root.auth.state !== "busy"
onTextChanged: if (root.auth)
root.auth.buffer = text
Keys.onReturnPressed: root.auth.submit()
Keys.onEnterPressed: root.auth.submit()
Keys.onEscapePressed: {
text = "";
}
onActiveFocusChanged: {
if (!activeFocus)
forceActiveFocus();
}
}
onVisibleChanged: {
if (visible)
_keyInput.forceActiveFocus();
}
// Sync TextInput when auth clears buffer externally (PAM submit, lock reset)
Connections {
target: root.auth
function onBufferChanged() {
if (_keyInput.text !== root.auth.buffer)
_keyInput.text = root.auth.buffer;
}
}
// Unlock animation
property bool _unlocking: false
Connections {
target: root.auth
function onUnlockRequested() {
root._unlocking = true;
_unlockAnim.start();
}
}
SequentialAnimation {
id: _unlockAnim
ParallelAnimation {
NumberAnimation {
target: content
property: "opacity"
to: 0
duration: 200
easing.type: Easing.InCubic
}
NumberAnimation {
target: content
property: "scale"
to: 0.9
duration: 200
easing.type: Easing.InCubic
}
NumberAnimation {
target: background
property: "opacity"
to: 0
duration: 300
easing.type: Easing.InCubic
}
}
PropertyAction {
target: root.lock
property: "locked"
value: false
}
}
// Shake animation on auth failure
SequentialAnimation {
id: _shakeAnim
NumberAnimation {
target: content
property: "anchors.horizontalCenterOffset"
to: 12
duration: 50
}
NumberAnimation {
target: content
property: "anchors.horizontalCenterOffset"
to: -12
duration: 50
}
NumberAnimation {
target: content
property: "anchors.horizontalCenterOffset"
to: 8
duration: 50
}
NumberAnimation {
target: content
property: "anchors.horizontalCenterOffset"
to: -8
duration: 50
}
NumberAnimation {
target: content
property: "anchors.horizontalCenterOffset"
to: 0
duration: 50
}
}
Connections {
target: root.auth
function onAuthFailed() {
_shakeAnim.restart();
}
}
}

View file

@ -0,0 +1,4 @@
Lock 1.0 Lock.qml
LockAuth 1.0 LockAuth.qml
LockInput 1.0 LockInput.qml
LockSurface 1.0 LockSurface.qml

46
shell/modules/qmldir Normal file
View file

@ -0,0 +1,46 @@
module modules
singleton Theme 1.0 Theme.qml
singleton FlyoutState 1.0 FlyoutState.qml
singleton Modules 1.0 Modules.qml
Bar 1.0 Bar.qml
BarGroup 1.0 BarGroup.qml
BarSection 1.0 BarSection.qml
Flyout 1.0 Flyout.qml
Workspaces 1.0 Workspaces.qml
WindowTitle 1.0 WindowTitle.qml
Clock 1.0 Clock.qml
Volume 1.0 Volume.qml
Tray 1.0 Tray.qml
TrayMenu 1.0 TrayMenu.qml
PopupBackground 1.0 PopupBackground.qml
HoverPanel 1.0 HoverPanel.qml
PowerMenu 1.0 PowerMenu.qml
ScreenCorners 1.0 ScreenCorners.qml
ThemedIcon 1.0 ThemedIcon.qml
Battery 1.0 Battery.qml
Mpris 1.0 Mpris.qml
Network 1.0 Network.qml
NetworkMenu 1.0 NetworkMenu.qml
Bluetooth 1.0 Bluetooth.qml
BluetoothMenu 1.0 BluetoothMenu.qml
Backlight 1.0 Backlight.qml
Cpu 1.0 Cpu.qml
Memory 1.0 Memory.qml
Disk 1.0 Disk.qml
Temperature 1.0 Temperature.qml
Weather 1.0 Weather.qml
PowerProfile 1.0 PowerProfile.qml
IdleInhibitor 1.0 IdleInhibitor.qml
Notifications 1.0 Notifications.qml
singleton NiriIpc 1.0 NiriIpc.qml
singleton PowerProfileService 1.0 PowerProfileService.qml
singleton SystemStats 1.0 SystemStats.qml
ProcessList 1.0 ProcessList.qml
singleton NotifService 1.0 NotifService.qml
NotifItem 1.0 NotifItem.qml
NotifPopup 1.0 NotifPopup.qml
NotifCenter 1.0 NotifCenter.qml
Power 1.0 Power.qml
Privacy 1.0 Privacy.qml
BackgroundOverlay 1.0 BackgroundOverlay.qml
OverviewBackdrop 1.0 OverviewBackdrop.qml