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
|
|
@ -25,12 +25,12 @@ stdenvNoCC.mkDerivation {
|
|||
runHook preInstall
|
||||
|
||||
mkdir -p $out/share/nova-shell
|
||||
cp -r shell.qml modules assets $out/share/nova-shell/
|
||||
cp -r shell/shell.qml shell/modules shell/assets $out/share/nova-shell/
|
||||
|
||||
# Compile fragment shader to Qt RHI format
|
||||
qsb --qt6 \
|
||||
-o $out/share/nova-shell/modules/hex_wave.frag.qsb \
|
||||
modules/hex_wave.frag
|
||||
shell/modules/hex_wave.frag
|
||||
|
||||
mkdir -p $out/bin
|
||||
makeWrapper ${lib.getExe quickshell} $out/bin/nova-shell \
|
||||
|
|
|
|||
6
shell/assets/pam.d/nova-shell
Normal file
6
shell/assets/pam.d/nova-shell
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#%PAM-1.0
|
||||
|
||||
auth required pam_faillock.so preauth
|
||||
auth [success=1 default=bad] pam_unix.so nullok
|
||||
auth [default=die] pam_faillock.so authfail
|
||||
auth required pam_faillock.so authsucc
|
||||
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
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Reference in a new issue