reorganize repo: move shell sources into shell/, test scripts into test/
This commit is contained in:
parent
344c1f8512
commit
d6cd2f173a
60 changed files with 2 additions and 2 deletions
217
shell/modules/BackgroundOverlay.qml
Normal file
217
shell/modules/BackgroundOverlay.qml
Normal 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
186
shell/modules/Backlight.qml
Normal 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
233
shell/modules/Bar.qml
Normal 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
166
shell/modules/BarGroup.qml
Normal 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
74
shell/modules/BarIcon.qml
Normal 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
shell/modules/BarLabel.qml
Normal file
45
shell/modules/BarLabel.qml
Normal 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
shell/modules/BarSection.qml
Normal file
34
shell/modules/BarSection.qml
Normal 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
419
shell/modules/Battery.qml
Normal 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
103
shell/modules/Bluetooth.qml
Normal 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
shell/modules/BluetoothMenu.qml
Normal file
188
shell/modules/BluetoothMenu.qml
Normal 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
14
shell/modules/Clock.qml
Normal 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
302
shell/modules/Cpu.qml
Normal 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
145
shell/modules/Disk.qml
Normal 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
101
shell/modules/Flyout.qml
Normal 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
shell/modules/FlyoutState.qml
Normal file
11
shell/modules/FlyoutState.qml
Normal 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
276
shell/modules/Gpu.qml
Normal 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
shell/modules/HoverPanel.qml
Normal file
381
shell/modules/HoverPanel.qml
Normal 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
|
||||
}
|
||||
}
|
||||
50
shell/modules/IdleInhibitor.qml
Normal file
50
shell/modules/IdleInhibitor.qml
Normal 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
344
shell/modules/Memory.qml
Normal 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
142
shell/modules/Modules.qml
Normal 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
470
shell/modules/Mpris.qml
Normal 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
110
shell/modules/Network.qml
Normal 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
|
||||
}
|
||||
}
|
||||
229
shell/modules/NetworkMenu.qml
Normal file
229
shell/modules/NetworkMenu.qml
Normal 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
74
shell/modules/NiriIpc.qml
Normal 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
276
shell/modules/NotifCard.qml
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
486
shell/modules/NotifCenter.qml
Normal file
486
shell/modules/NotifCenter.qml
Normal 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
|
||||
}
|
||||
}
|
||||
64
shell/modules/NotifItem.qml
Normal file
64
shell/modules/NotifItem.qml
Normal 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();
|
||||
}
|
||||
}
|
||||
168
shell/modules/NotifPopup.qml
Normal file
168
shell/modules/NotifPopup.qml
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
137
shell/modules/NotifService.qml
Normal file
137
shell/modules/NotifService.qml
Normal 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()
|
||||
}
|
||||
}
|
||||
94
shell/modules/Notifications.qml
Normal file
94
shell/modules/Notifications.qml
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
129
shell/modules/OverviewBackdrop.qml
Normal file
129
shell/modules/OverviewBackdrop.qml
Normal 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 12–37s, 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
12
shell/modules/PopupBackground.qml
Normal file
12
shell/modules/PopupBackground.qml
Normal 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
40
shell/modules/Power.qml
Normal 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
100
shell/modules/PowerMenu.qml
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
29
shell/modules/PowerProfile.qml
Normal file
29
shell/modules/PowerProfile.qml
Normal 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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
53
shell/modules/PowerProfileService.qml
Normal file
53
shell/modules/PowerProfileService.qml
Normal 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
106
shell/modules/Privacy.qml
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
43
shell/modules/ProcessList.qml
Normal file
43
shell/modules/ProcessList.qml
Normal 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
|
||||
}
|
||||
}
|
||||
93
shell/modules/ScreenCorners.qml
Normal file
93
shell/modules/ScreenCorners.qml
Normal 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
|
||||
}
|
||||
}
|
||||
185
shell/modules/SystemStats.qml
Normal file
185
shell/modules/SystemStats.qml
Normal 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
|
||||
}
|
||||
}
|
||||
326
shell/modules/Temperature.qml
Normal file
326
shell/modules/Temperature.qml
Normal 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 green→yellow→red 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 (0–100°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
86
shell/modules/Theme.qml
Normal 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;
|
||||
}
|
||||
}
|
||||
16
shell/modules/ThemedIcon.qml
Normal file
16
shell/modules/ThemedIcon.qml
Normal 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
125
shell/modules/Tray.qml
Normal 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
142
shell/modules/TrayMenu.qml
Normal 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
391
shell/modules/Volume.qml
Normal 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
40
shell/modules/Weather.qml
Normal 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
|
||||
}
|
||||
}
|
||||
45
shell/modules/WindowTitle.qml
Normal file
45
shell/modules/WindowTitle.qml
Normal 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))
|
||||
}
|
||||
}
|
||||
110
shell/modules/Workspaces.qml
Normal file
110
shell/modules/Workspaces.qml
Normal 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
115
shell/modules/hex_wave.frag
Normal 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;
|
||||
}
|
||||
78
shell/modules/lock/Lock.qml
Normal file
78
shell/modules/lock/Lock.qml
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
91
shell/modules/lock/LockAuth.qml
Normal file
91
shell/modules/lock/LockAuth.qml
Normal 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 = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
104
shell/modules/lock/LockInput.qml
Normal file
104
shell/modules/lock/LockInput.qml
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
272
shell/modules/lock/LockSurface.qml
Normal file
272
shell/modules/lock/LockSurface.qml
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
4
shell/modules/lock/qmldir
Normal file
4
shell/modules/lock/qmldir
Normal 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
46
shell/modules/qmldir
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue