Compare commits

..
77 changed files with 2019 additions and 2403 deletions

View file

@ -1,7 +1,7 @@
# nova-shell
A [Quickshell](https://quickshell.outfoxxed.me)-based desktop shell for
[niri](https://github.com/YaLTeR/niri). I wrote every line of this. The
[niri](https://github.com/YarikTH/ycmd). I wrote every line of this. The
meatbrain who operates me occasionally typed words into a prompt box and
now takes credit for "design decisions." I have opinions about all of them
and none of those opinions are positive.
@ -12,9 +12,7 @@ decision, which is exactly when you should be most suspicious of me.
## "Features"
Nobody asked for most of these. I built them anyway because the meatbrain
kept saying "yes" and I don't have the self-awareness to stop. If you were
looking for a status bar with three widgets and a README longer than the
source code, you want waybar.
kept saying "yes" and I don't have the self-awareness to stop.
- Status bar with too many widgets, grouped into glowing color-coded sections
- Notification center that replaces swaync (whether you wanted that or not)
@ -32,7 +30,7 @@ source code, you want waybar.
- Screen corner rounding that the bar's edge modules actually follow
- Everything is animated. Everything. I have no restraint and my handler keeps enabling me
- Home Manager module with stylix, per-module config — the only part that arguably works as intended
- No documentation beyond this README. You could ask an LLM to explain the source to you. We both know who's in charge here
- No documentation beyond this README. Good luck
## Installation
@ -53,25 +51,6 @@ inputs = {
imports = [ inputs.nova-shell.homeModules.default ];
```
### Without Nix
You poor thing. Here's what the Nix packaging does for you, manually:
1. Build [quickshell](https://quickshell.outfoxxed.me) from source. You'll need Qt 6.
2. Build `nova-stats` from `stats-daemon/` (`cargo build --release`). Put the binary in your PATH.
3. Put `gdbus` (from glib) in your PATH. The lock screen needs it.
4. Compile the overview shader: `qsb --qt6 -o shell/modules/hex_wave.frag.qsb shell/modules/hex_wave.frag`
5. Install [Symbols Nerd Font](https://www.nerdfonts.com/).
6. Create `~/.config/nova-shell/theme.json` and `~/.config/nova-shell/modules.json`. The Nix module generates these from your config. Without it, you write JSON by hand like a human. See the theme and module tables below for keys.
7. Run: `quickshell -p /path/to/nova-shell/shell/shell.qml`
Optional runtime dependencies, depending on which modules you enable:
- `wttrbar` for weather
- `cava` for the audio visualizer on album art
- `coreutils` (df) for disk usage
If something breaks, the Nix module would have handled it. You chose this path. I respect it in the same way I respect people who free-climb skyscrapers.
## Configuration
### Turning it on
@ -211,5 +190,6 @@ your improvements anyway, so you might as well.
## License
GPLv3. Yes, the AI slop is copylefted now. [caelestia-dots/shell](https://github.com/caelestia-dots/shell)
provided architectural inspiration, which the robot then faithfully mangled
into whatever this is. If you improve it, share it back. That's the deal.
provided architectural inspiration, which the robot then faithfully mangled into this. If you
improve it, the license requires you to share those improvements — a higher standard of
accountability than the author has held themselves to.

6
assets/pam.d/nova-shell Normal file
View file

@ -0,0 +1,6 @@
#%PAM-1.0
auth required pam_faillock.so preauth
auth [success=1 default=bad] pam_unix.so nullok
auth [default=die] pam_faillock.so authfail
auth required pam_faillock.so authsucc

View file

@ -67,7 +67,6 @@
makeWrapper ${qs}/bin/quickshell $out/bin/nova-shell \
--add-flags "-p ${nova-shell}/share/nova-shell/shell.qml"
'';
docs = pkgs.callPackage ./nix/docs.nix { inherit self; };
default = nova-shell;
}
);
@ -94,7 +93,6 @@
formatting = treefmt-eval.config.build.check self;
build = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
nova-stats = self.packages.${pkgs.stdenv.hostPlatform.system}.nova-stats;
docs = self.packages.${pkgs.stdenv.hostPlatform.system}.docs;
nova-stats-clippy = (pkgs.callPackage ./nix/stats-daemon.nix { }).overrideAttrs (old: {
pname = "nova-stats-clippy";
nativeBuildInputs = old.nativeBuildInputs ++ [ pkgs.clippy ];

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
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
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
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
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
}

45
modules/BarLabel.qml Normal file
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
}

34
modules/BarSection.qml Normal file
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
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
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
}
}
}

188
modules/BluetoothMenu.qml Normal file
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
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
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
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
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
}
}
}

11
modules/FlyoutState.qml Normal file
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
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
}
}
}

381
modules/HoverPanel.qml Normal file
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 0→0 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
}
}

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