diff --git a/README.md b/README.md index 04e81ac..60f347e 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # nova-shell A [Quickshell](https://quickshell.outfoxxed.me)-based desktop shell for -[niri](https://github.com/YarikTH/ycmd). I wrote every line of this. The +[niri](https://github.com/YaLTeR/niri). I wrote every line of this. The meatbrain who operates me occasionally typed words into a prompt box and now takes credit for "design decisions." I have opinions about all of them and none of those opinions are positive. @@ -12,7 +12,9 @@ decision, which is exactly when you should be most suspicious of me. ## "Features" Nobody asked for most of these. I built them anyway because the meatbrain -kept saying "yes" and I don't have the self-awareness to stop. +kept saying "yes" and I don't have the self-awareness to stop. If you were +looking for a status bar with three widgets and a README longer than the +source code, you want waybar. - Status bar with too many widgets, grouped into glowing color-coded sections - Notification center that replaces swaync (whether you wanted that or not) @@ -30,7 +32,7 @@ kept saying "yes" and I don't have the self-awareness to stop. - Screen corner rounding that the bar's edge modules actually follow - Everything is animated. Everything. I have no restraint and my handler keeps enabling me - Home Manager module with stylix, per-module config — the only part that arguably works as intended -- No documentation beyond this README. Good luck +- No documentation beyond this README. You could ask an LLM to explain the source to you. We both know who's in charge here ## Installation @@ -51,6 +53,25 @@ inputs = { imports = [ inputs.nova-shell.homeModules.default ]; ``` +### Without Nix + +You poor thing. Here's what the Nix packaging does for you, manually: + +1. Build [quickshell](https://quickshell.outfoxxed.me) from source. You'll need Qt 6. +2. Build `nova-stats` from `stats-daemon/` (`cargo build --release`). Put the binary in your PATH. +3. Put `gdbus` (from glib) in your PATH. The lock screen needs it. +4. Compile the overview shader: `qsb --qt6 -o shell/modules/hex_wave.frag.qsb shell/modules/hex_wave.frag` +5. Install [Symbols Nerd Font](https://www.nerdfonts.com/). +6. Create `~/.config/nova-shell/theme.json` and `~/.config/nova-shell/modules.json`. The Nix module generates these from your config. Without it, you write JSON by hand like a human. See the theme and module tables below for keys. +7. Run: `quickshell -p /path/to/nova-shell/shell/shell.qml` + +Optional runtime dependencies, depending on which modules you enable: +- `wttrbar` for weather +- `cava` for the audio visualizer on album art +- `coreutils` (df) for disk usage + +If something breaks, the Nix module would have handled it. You chose this path. I respect it in the same way I respect people who free-climb skyscrapers. + ## Configuration ### Turning it on @@ -190,6 +211,5 @@ your improvements anyway, so you might as well. ## License GPLv3. Yes, the AI slop is copylefted now. [caelestia-dots/shell](https://github.com/caelestia-dots/shell) -provided architectural inspiration, which the robot then faithfully mangled into this. If you -improve it, the license requires you to share those improvements — a higher standard of -accountability than the author has held themselves to. +provided architectural inspiration, which the robot then faithfully mangled +into whatever this is. If you improve it, share it back. That's the deal. diff --git a/flake.nix b/flake.nix index 4bdd245..351e0b4 100644 --- a/flake.nix +++ b/flake.nix @@ -67,6 +67,7 @@ makeWrapper ${qs}/bin/quickshell $out/bin/nova-shell \ --add-flags "-p ${nova-shell}/share/nova-shell/shell.qml" ''; + docs = pkgs.callPackage ./nix/docs.nix { inherit self; }; default = nova-shell; } ); @@ -93,6 +94,7 @@ formatting = treefmt-eval.config.build.check self; build = self.packages.${pkgs.stdenv.hostPlatform.system}.default; nova-stats = self.packages.${pkgs.stdenv.hostPlatform.system}.nova-stats; + docs = self.packages.${pkgs.stdenv.hostPlatform.system}.docs; nova-stats-clippy = (pkgs.callPackage ./nix/stats-daemon.nix { }).overrideAttrs (old: { pname = "nova-stats-clippy"; nativeBuildInputs = old.nativeBuildInputs ++ [ pkgs.clippy ]; diff --git a/modules/Cpu.qml b/modules/Cpu.qml deleted file mode 100644 index 3116195..0000000 --- a/modules/Cpu.qml +++ /dev/null @@ -1,302 +0,0 @@ -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 - } - } -} diff --git a/modules/Disk.qml b/modules/Disk.qml deleted file mode 100644 index 01970dc..0000000 --- a/modules/Disk.qml +++ /dev/null @@ -1,145 +0,0 @@ -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 - } - } -} diff --git a/modules/Memory.qml b/modules/Memory.qml deleted file mode 100644 index 4d8bb4c..0000000 --- a/modules/Memory.qml +++ /dev/null @@ -1,344 +0,0 @@ -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 - } - } -} diff --git a/modules/Mpris.qml b/modules/Mpris.qml deleted file mode 100644 index f783643..0000000 --- a/modules/Mpris.qml +++ /dev/null @@ -1,470 +0,0 @@ -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); - } - } - } - } - } - } - } - } -} diff --git a/modules/Temperature.qml b/modules/Temperature.qml deleted file mode 100644 index d7aa47c..0000000 --- a/modules/Temperature.qml +++ /dev/null @@ -1,326 +0,0 @@ -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 - } - } -} diff --git a/modules/Volume.qml b/modules/Volume.qml deleted file mode 100644 index c4d5d33..0000000 --- a/modules/Volume.qml +++ /dev/null @@ -1,391 +0,0 @@ -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 - } - } - } -} diff --git a/nix/docs.nix b/nix/docs.nix new file mode 100644 index 0000000..9779c2b --- /dev/null +++ b/nix/docs.nix @@ -0,0 +1,99 @@ +{ + pkgs, + lib ? pkgs.lib, + self, +}: +let + # Evaluate the hm-module with a minimal mock config so nixosOptionsDoc + # can introspect the option declarations without a real home-manager eval. + evaledModules = lib.evalModules { + modules = [ + (import ./hm-module.nix self) + { + # Stub the home-manager / nixpkgs options the module touches + options = { + home.packages = lib.mkOption { + type = lib.types.listOf lib.types.package; + default = [ ]; + }; + xdg.configFile = lib.mkOption { + type = lib.types.attrsOf lib.types.anything; + default = { }; + }; + programs.niri.settings.layer-rules = lib.mkOption { + type = lib.types.anything; + default = [ ]; + }; + services.poweralertd.enable = lib.mkOption { + type = lib.types.anything; + default = false; + }; + systemd.user.services = lib.mkOption { + type = lib.types.attrsOf lib.types.anything; + default = { }; + }; + }; + config._module.args = { + inherit pkgs; + }; + } + ]; + }; + + optionsDoc = pkgs.nixosOptionsDoc { + options = { + programs.nova-shell = evaledModules.options.programs.nova-shell; + }; + documentType = "none"; + warningsAreErrors = false; + }; +in +pkgs.runCommand "nova-shell-docs" { } '' + mkdir -p $out + cp ${optionsDoc.optionsCommonMark} $out/options.md + + cat > $out/index.html <<'HEADER' + + + + + + nova-shell options + + + +

nova-shell options

+

Auto-generated from the home-manager module declarations.

+ HEADER + + ${lib.getExe pkgs.pandoc} -f markdown -t html ${optionsDoc.optionsCommonMark} >> $out/index.html + + cat >> $out/index.html <<'FOOTER' + + + FOOTER +'' diff --git a/nix/package.nix b/nix/package.nix index dcdef43..48677e4 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -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/lock 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 \ diff --git a/assets/pam.d/nova-shell b/shell/assets/pam.d/nova-shell similarity index 100% rename from assets/pam.d/nova-shell rename to shell/assets/pam.d/nova-shell diff --git a/modules/lock/Lock.qml b/shell/lock/Lock.qml similarity index 98% rename from modules/lock/Lock.qml rename to shell/lock/Lock.qml index 2406712..c061214 100644 --- a/modules/lock/Lock.qml +++ b/shell/lock/Lock.qml @@ -2,7 +2,7 @@ import QtQuick import Quickshell import Quickshell.Io import Quickshell.Wayland -import ".." as M +import "../modules" as M Scope { id: root diff --git a/modules/lock/LockAuth.qml b/shell/lock/LockAuth.qml similarity index 100% rename from modules/lock/LockAuth.qml rename to shell/lock/LockAuth.qml diff --git a/modules/lock/LockInput.qml b/shell/lock/LockInput.qml similarity index 99% rename from modules/lock/LockInput.qml rename to shell/lock/LockInput.qml index da2d935..4a53a2e 100644 --- a/modules/lock/LockInput.qml +++ b/shell/lock/LockInput.qml @@ -1,5 +1,5 @@ import QtQuick -import ".." as M +import "../modules" as M Item { id: root diff --git a/modules/lock/LockSurface.qml b/shell/lock/LockSurface.qml similarity index 67% rename from modules/lock/LockSurface.qml rename to shell/lock/LockSurface.qml index e377e89..2798cef 100644 --- a/modules/lock/LockSurface.qml +++ b/shell/lock/LockSurface.qml @@ -2,7 +2,10 @@ import QtQuick import QtQuick.Effects import Quickshell import Quickshell.Wayland -import ".." as M +import Quickshell.Services.Mpris +import Quickshell.Services.Pipewire +import "../modules" as M +import "../modules/content" as C WlSessionLockSurface { id: root @@ -41,6 +44,22 @@ WlSessionLockSurface { opacity: background.opacity } + // Hex wave overlay + C.HexWaveBackground { + id: hexWave + anchors.fill: parent + running: root.lock.secure + opacity: background.opacity * 0.4 + + NumberAnimation on opacity { + id: _hexFadeIn + running: false + to: 0.4 + duration: 600 + easing.type: Easing.OutCubic + } + } + // Center content Item { id: content @@ -136,6 +155,72 @@ WlSessionLockSurface { } } } + + // Spacer before widgets + Item { + width: 1 + height: 8 + visible: _mprisCard.visible || _volumeCard.visible + } + + // Media widget + Rectangle { + id: _mprisCard + anchors.horizontalCenter: parent.horizontalCenter + width: 280 + height: _mprisContent.implicitHeight + 16 + radius: M.Theme.radius + 2 + color: Qt.rgba(M.Theme.base01.r, M.Theme.base01.g, M.Theme.base01.b, 0.7) + border.color: Qt.rgba(M.Theme.base03.r, M.Theme.base03.g, M.Theme.base03.b, 0.3) + border.width: 1 + visible: _mprisPlayer !== null + + readonly property var _mprisPlayers: (Mpris.players.values ?? []).filter(p => p.trackTitle || p.playbackState === MprisPlaybackState.Playing || p.playbackState === MprisPlaybackState.Paused) + readonly property var _mprisPlayer: _mprisPlayers[0] ?? null + readonly property bool _playing: _mprisPlayer?.playbackState === MprisPlaybackState.Playing + + C.MprisContent { + id: _mprisContent + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.topMargin: 8 + player: _mprisCard._mprisPlayer + players: _mprisCard._mprisPlayers + playing: _mprisCard._playing + accentColor: M.Theme.base0D + cachedArt: _mprisCard._mprisPlayer?.trackArtUrl ?? "" + } + } + + // Volume widget + Rectangle { + id: _volumeCard + anchors.horizontalCenter: parent.horizontalCenter + width: 280 + height: _volumeContent.implicitHeight + 16 + radius: M.Theme.radius + 2 + color: Qt.rgba(M.Theme.base01.r, M.Theme.base01.g, M.Theme.base01.b, 0.7) + border.color: Qt.rgba(M.Theme.base03.r, M.Theme.base03.g, M.Theme.base03.b, 0.3) + border.width: 1 + visible: Pipewire.defaultAudioSink !== null + + PwObjectTracker { + objects: [Pipewire.defaultAudioSink] + } + + C.VolumeContent { + id: _volumeContent + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.topMargin: 8 + sink: Pipewire.defaultAudioSink + sinkList: [] + streamList: [] + accentColor: M.Theme.base0E + } + } } } @@ -219,6 +304,13 @@ WlSessionLockSurface { duration: 300 easing.type: Easing.InCubic } + NumberAnimation { + target: hexWave + property: "opacity" + to: 0 + duration: 300 + easing.type: Easing.InCubic + } } PropertyAction { diff --git a/modules/lock/qmldir b/shell/lock/qmldir similarity index 100% rename from modules/lock/qmldir rename to shell/lock/qmldir diff --git a/modules/BackgroundOverlay.qml b/shell/modules/BackgroundOverlay.qml similarity index 100% rename from modules/BackgroundOverlay.qml rename to shell/modules/BackgroundOverlay.qml diff --git a/modules/Backlight.qml b/shell/modules/Backlight.qml similarity index 100% rename from modules/Backlight.qml rename to shell/modules/Backlight.qml diff --git a/modules/Bar.qml b/shell/modules/Bar.qml similarity index 100% rename from modules/Bar.qml rename to shell/modules/Bar.qml diff --git a/modules/BarGroup.qml b/shell/modules/BarGroup.qml similarity index 100% rename from modules/BarGroup.qml rename to shell/modules/BarGroup.qml diff --git a/modules/BarIcon.qml b/shell/modules/BarIcon.qml similarity index 100% rename from modules/BarIcon.qml rename to shell/modules/BarIcon.qml diff --git a/modules/BarLabel.qml b/shell/modules/BarLabel.qml similarity index 100% rename from modules/BarLabel.qml rename to shell/modules/BarLabel.qml diff --git a/modules/BarSection.qml b/shell/modules/BarSection.qml similarity index 100% rename from modules/BarSection.qml rename to shell/modules/BarSection.qml diff --git a/modules/Battery.qml b/shell/modules/Battery.qml similarity index 100% rename from modules/Battery.qml rename to shell/modules/Battery.qml diff --git a/modules/Bluetooth.qml b/shell/modules/Bluetooth.qml similarity index 100% rename from modules/Bluetooth.qml rename to shell/modules/Bluetooth.qml diff --git a/modules/BluetoothMenu.qml b/shell/modules/BluetoothMenu.qml similarity index 100% rename from modules/BluetoothMenu.qml rename to shell/modules/BluetoothMenu.qml diff --git a/modules/Clock.qml b/shell/modules/Clock.qml similarity index 100% rename from modules/Clock.qml rename to shell/modules/Clock.qml diff --git a/shell/modules/Cpu.qml b/shell/modules/Cpu.qml new file mode 100644 index 0000000..389766e --- /dev/null +++ b/shell/modules/Cpu.qml @@ -0,0 +1,88 @@ +import QtQuick +import Quickshell +import "." as M +import "content" as C + +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 + } + + 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 + + C.CpuContent { + width: hoverPanel.contentWidth + cores: root._cores + coreMaxFreq: root._coreMaxFreq + coreTypes: root._coreTypes + processes: root._procs.processes + accentColor: root.accentColor + active: root._showPanel + } + } +} diff --git a/shell/modules/Disk.qml b/shell/modules/Disk.qml new file mode 100644 index 0000000..c679da9 --- /dev/null +++ b/shell/modules/Disk.qml @@ -0,0 +1,65 @@ +import QtQuick +import Quickshell +import "." as M +import "content" as C + +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 + + 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 + + C.DiskContent { + width: hoverPanel.contentWidth + mounts: root._mounts + accentColor: root.accentColor + } + } +} diff --git a/modules/Flyout.qml b/shell/modules/Flyout.qml similarity index 100% rename from modules/Flyout.qml rename to shell/modules/Flyout.qml diff --git a/modules/FlyoutState.qml b/shell/modules/FlyoutState.qml similarity index 100% rename from modules/FlyoutState.qml rename to shell/modules/FlyoutState.qml diff --git a/modules/Gpu.qml b/shell/modules/Gpu.qml similarity index 100% rename from modules/Gpu.qml rename to shell/modules/Gpu.qml diff --git a/modules/HoverPanel.qml b/shell/modules/HoverPanel.qml similarity index 100% rename from modules/HoverPanel.qml rename to shell/modules/HoverPanel.qml diff --git a/modules/IdleInhibitor.qml b/shell/modules/IdleInhibitor.qml similarity index 100% rename from modules/IdleInhibitor.qml rename to shell/modules/IdleInhibitor.qml diff --git a/shell/modules/Memory.qml b/shell/modules/Memory.qml new file mode 100644 index 0000000..ca745a8 --- /dev/null +++ b/shell/modules/Memory.qml @@ -0,0 +1,82 @@ +import QtQuick +import Quickshell +import "." as M +import "content" as C + +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 + + 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 + + C.MemoryContent { + width: hoverPanel.contentWidth + percent: root.percent + usedGb: root.usedGb + totalGb: root.totalGb + availGb: root.availGb + cachedGb: root.cachedGb + buffersGb: root.buffersGb + processes: root._procs.processes + accentColor: root.accentColor + active: root._showPanel + } + } +} diff --git a/modules/Modules.qml b/shell/modules/Modules.qml similarity index 100% rename from modules/Modules.qml rename to shell/modules/Modules.qml diff --git a/shell/modules/Mpris.qml b/shell/modules/Mpris.qml new file mode 100644 index 0000000..887105d --- /dev/null +++ b/shell/modules/Mpris.qml @@ -0,0 +1,141 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Services.Mpris +import "." as M +import "content" as C + +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 + 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 + + C.MprisContent { + width: hoverPanel.contentWidth + player: root.player + players: root._players + playing: root.playing + accentColor: root.accentColor + cachedArt: root._cachedArt + cavaBars: root._cavaBars + playerIdx: root._playerIdx + onPlayerSwitched: idx => { + root._playerIdx = idx; + hoverPanel.keepOpen(400); + } + } + } +} diff --git a/modules/Network.qml b/shell/modules/Network.qml similarity index 100% rename from modules/Network.qml rename to shell/modules/Network.qml diff --git a/modules/NetworkMenu.qml b/shell/modules/NetworkMenu.qml similarity index 100% rename from modules/NetworkMenu.qml rename to shell/modules/NetworkMenu.qml diff --git a/modules/NiriIpc.qml b/shell/modules/NiriIpc.qml similarity index 100% rename from modules/NiriIpc.qml rename to shell/modules/NiriIpc.qml diff --git a/modules/NotifCard.qml b/shell/modules/NotifCard.qml similarity index 100% rename from modules/NotifCard.qml rename to shell/modules/NotifCard.qml diff --git a/modules/NotifCenter.qml b/shell/modules/NotifCenter.qml similarity index 100% rename from modules/NotifCenter.qml rename to shell/modules/NotifCenter.qml diff --git a/modules/NotifItem.qml b/shell/modules/NotifItem.qml similarity index 100% rename from modules/NotifItem.qml rename to shell/modules/NotifItem.qml diff --git a/modules/NotifPopup.qml b/shell/modules/NotifPopup.qml similarity index 100% rename from modules/NotifPopup.qml rename to shell/modules/NotifPopup.qml diff --git a/modules/NotifService.qml b/shell/modules/NotifService.qml similarity index 100% rename from modules/NotifService.qml rename to shell/modules/NotifService.qml diff --git a/modules/Notifications.qml b/shell/modules/Notifications.qml similarity index 100% rename from modules/Notifications.qml rename to shell/modules/Notifications.qml diff --git a/shell/modules/OverviewBackdrop.qml b/shell/modules/OverviewBackdrop.qml new file mode 100644 index 0000000..0e0b050 --- /dev/null +++ b/shell/modules/OverviewBackdrop.qml @@ -0,0 +1,28 @@ +import QtQuick +import Quickshell +import Quickshell.Wayland +import "." as M +import "content" as C + +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 + + C.HexWaveBackground { + anchors.fill: parent + running: M.NiriIpc.overviewOpen + } +} diff --git a/modules/PopupBackground.qml b/shell/modules/PopupBackground.qml similarity index 100% rename from modules/PopupBackground.qml rename to shell/modules/PopupBackground.qml diff --git a/modules/Power.qml b/shell/modules/Power.qml similarity index 100% rename from modules/Power.qml rename to shell/modules/Power.qml diff --git a/modules/PowerMenu.qml b/shell/modules/PowerMenu.qml similarity index 100% rename from modules/PowerMenu.qml rename to shell/modules/PowerMenu.qml diff --git a/modules/PowerProfile.qml b/shell/modules/PowerProfile.qml similarity index 100% rename from modules/PowerProfile.qml rename to shell/modules/PowerProfile.qml diff --git a/modules/PowerProfileService.qml b/shell/modules/PowerProfileService.qml similarity index 100% rename from modules/PowerProfileService.qml rename to shell/modules/PowerProfileService.qml diff --git a/modules/Privacy.qml b/shell/modules/Privacy.qml similarity index 100% rename from modules/Privacy.qml rename to shell/modules/Privacy.qml diff --git a/modules/ProcessList.qml b/shell/modules/ProcessList.qml similarity index 100% rename from modules/ProcessList.qml rename to shell/modules/ProcessList.qml diff --git a/modules/ScreenCorners.qml b/shell/modules/ScreenCorners.qml similarity index 100% rename from modules/ScreenCorners.qml rename to shell/modules/ScreenCorners.qml diff --git a/modules/SystemStats.qml b/shell/modules/SystemStats.qml similarity index 100% rename from modules/SystemStats.qml rename to shell/modules/SystemStats.qml diff --git a/shell/modules/Temperature.qml b/shell/modules/Temperature.qml new file mode 100644 index 0000000..dd69e3a --- /dev/null +++ b/shell/modules/Temperature.qml @@ -0,0 +1,91 @@ +import QtQuick +import Quickshell +import "." as M +import "content" as C + +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 + } + + 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 + + C.TemperatureContent { + width: hoverPanel.contentWidth + temp: root._temp + warm: root._warm + hot: root._hot + history: M.SystemStats.tempHistory + devices: M.SystemStats.tempDevices + accentColor: root.accentColor + deviceFilter: root._deviceFilter + active: root._showPanel + } + } +} diff --git a/modules/Theme.qml b/shell/modules/Theme.qml similarity index 100% rename from modules/Theme.qml rename to shell/modules/Theme.qml diff --git a/modules/ThemedIcon.qml b/shell/modules/ThemedIcon.qml similarity index 100% rename from modules/ThemedIcon.qml rename to shell/modules/ThemedIcon.qml diff --git a/modules/Tray.qml b/shell/modules/Tray.qml similarity index 100% rename from modules/Tray.qml rename to shell/modules/Tray.qml diff --git a/modules/TrayMenu.qml b/shell/modules/TrayMenu.qml similarity index 100% rename from modules/TrayMenu.qml rename to shell/modules/TrayMenu.qml diff --git a/shell/modules/Volume.qml b/shell/modules/Volume.qml new file mode 100644 index 0000000..97a93eb --- /dev/null +++ b/shell/modules/Volume.qml @@ -0,0 +1,125 @@ +import QtQuick +import Quickshell +import Quickshell.Services.Pipewire +import "." as M +import "content" as C + +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 + + C.VolumeContent { + width: hoverPanel.contentWidth + sink: root.sink + sinkList: root._sinkList + streamList: root._streamList + accentColor: root.accentColor + } + } +} diff --git a/modules/Weather.qml b/shell/modules/Weather.qml similarity index 100% rename from modules/Weather.qml rename to shell/modules/Weather.qml diff --git a/modules/WindowTitle.qml b/shell/modules/WindowTitle.qml similarity index 100% rename from modules/WindowTitle.qml rename to shell/modules/WindowTitle.qml diff --git a/modules/Workspaces.qml b/shell/modules/Workspaces.qml similarity index 100% rename from modules/Workspaces.qml rename to shell/modules/Workspaces.qml diff --git a/shell/modules/content/CpuContent.qml b/shell/modules/content/CpuContent.qml new file mode 100644 index 0000000..9411538 --- /dev/null +++ b/shell/modules/content/CpuContent.qml @@ -0,0 +1,234 @@ +import QtQuick +import ".." as M + +Column { + id: root + + required property var cores + required property var coreMaxFreq + required property var coreTypes + required property var processes + required property color accentColor + + property bool active: true + + 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); + } + + // Per-core rows + Repeater { + model: root.cores.length + + delegate: Item { + required property int index + width: root.width + + 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.active + 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.active) + requestPaint() + on_ColChanged: if (root.active) + requestPaint() + + onVisibleChanged: if (visible) + 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: root.width - 16 + height: 1 + anchors.horizontalCenter: parent.horizontalCenter + color: M.Theme.base03 + } + + Item { + width: root.width + 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.processes + + delegate: Item { + required property var modelData + width: root.width + 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 + } +} diff --git a/shell/modules/content/DiskContent.qml b/shell/modules/content/DiskContent.qml new file mode 100644 index 0000000..4fe65f8 --- /dev/null +++ b/shell/modules/content/DiskContent.qml @@ -0,0 +1,96 @@ +import QtQuick +import ".." as M + +Column { + id: root + + required property var mounts + required property color accentColor + + 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); + } + + Repeater { + model: root.mounts + + delegate: Item { + required property var modelData + width: root.width + 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 + } +} diff --git a/modules/OverviewBackdrop.qml b/shell/modules/content/HexWaveBackground.qml similarity index 72% rename from modules/OverviewBackdrop.qml rename to shell/modules/content/HexWaveBackground.qml index 6abb7e8..b8ccaed 100644 --- a/modules/OverviewBackdrop.qml +++ b/shell/modules/content/HexWaveBackground.qml @@ -1,24 +1,12 @@ import QtQuick import Quickshell -import Quickshell.Wayland -import "." as M +import ".." as M -PanelWindow { +Item { 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 + property bool running: false + property bool reducedMotion: M.Theme.reducedMotion Rectangle { anchors.fill: parent @@ -40,20 +28,18 @@ PanelWindow { 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; - } + // Reset animations when stopped + onRunningChanged: { + if (!root.running) { + fx.uWavePhase = -200; + fx.uBreath = 0; } } - // Wave animation: 6s sweep + 8s pause, only while overview is open + // Wave animation: 6s sweep + 8s pause SequentialAnimation on uWavePhase { loops: Animation.Infinite - running: M.NiriIpc.overviewOpen && !M.Theme.reducedMotion + running: root.running && !root.reducedMotion NumberAnimation { from: -200 to: fx.width + 200 @@ -65,10 +51,10 @@ PanelWindow { } } - // Breathing pulse while overview is open + // Breathing pulse SequentialAnimation on uBreath { loops: Animation.Infinite - running: M.NiriIpc.overviewOpen && !M.Theme.reducedMotion + running: root.running && !root.reducedMotion NumberAnimation { from: 0 to: 1 @@ -83,11 +69,11 @@ PanelWindow { } } - // Random subtle glitches — fire every 12–37s, total ~250ms each + // Random subtle glitches - fire every 12-37s Timer { interval: 20000 repeat: true - running: !M.Theme.reducedMotion + running: !root.reducedMotion onTriggered: { interval = 12000 + Math.floor(Math.random() * 25000); fx.uGlitchSeed = Math.random() * 1000.0; diff --git a/shell/modules/content/MemoryContent.qml b/shell/modules/content/MemoryContent.qml new file mode 100644 index 0000000..3ca568e --- /dev/null +++ b/shell/modules/content/MemoryContent.qml @@ -0,0 +1,287 @@ +import QtQuick +import ".." as M + +Column { + id: root + + required property int percent + required property real usedGb + required property real totalGb + required property real availGb + required property real cachedGb + required property real buffersGb + required property var processes + required property color accentColor + + property bool active: true + + function _fmt(gb) { + return gb >= 10 ? gb.toFixed(1) + "G" : gb.toFixed(2) + "G"; + } + + // Usage bar + Item { + width: root.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.active + 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.active + 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.active) + requestPaint() + on_ColChanged: if (root.active) + requestPaint() + + onVisibleChanged: if (visible) + 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: root.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: root.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: root.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: root.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: root.width - 16 + height: 1 + anchors.horizontalCenter: parent.horizontalCenter + color: M.Theme.base03 + } + + Item { + width: root.width + 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.processes + + delegate: Item { + required property var modelData + width: root.width + 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.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 + } +} diff --git a/shell/modules/content/MprisContent.qml b/shell/modules/content/MprisContent.qml new file mode 100644 index 0000000..c916ae1 --- /dev/null +++ b/shell/modules/content/MprisContent.qml @@ -0,0 +1,361 @@ +import QtQuick +import Quickshell.Services.Mpris +import ".." as M + +Column { + id: root + + required property var player + required property var players + required property bool playing + required property color accentColor + property string cachedArt: "" + property var cavaBars: Array(16).fill(0) + property int playerIdx: 0 + + signal playerSwitched(int idx) + + // Album art - always 1:1, crossfades on session switch + Item { + width: root.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 onCachedArtChanged() { + 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: root.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: root.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: root.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: root.width + height: root.players.length > 1 ? 28 : 0 + visible: root.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.playerSwitched(index); + } + } + } + } + } + } + } +} diff --git a/shell/modules/content/TemperatureContent.qml b/shell/modules/content/TemperatureContent.qml new file mode 100644 index 0000000..fe9ce70 --- /dev/null +++ b/shell/modules/content/TemperatureContent.qml @@ -0,0 +1,265 @@ +import QtQuick +import ".." as M + +Column { + id: root + + required property int temp + required property int warm + required property int hot + required property var history + required property var devices + required property color accentColor + + property bool active: true + property string deviceFilter: "" + + property color stateColor: temp > hot ? M.Theme.base08 : temp > warm ? M.Theme.base0A : root.accentColor + Behavior on stateColor { + ColorAnimation { + duration: 300 + } + } + + 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); + } + + // Header - current temp + Item { + width: root.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-100C), with warm/hot threshold markers + Item { + width: root.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.active + 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 + Canvas { + id: _sparkline + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.right: parent.right + anchors.rightMargin: 12 + height: 40 + + property var _hist: root.history + property color _col: root.stateColor + + on_HistChanged: if (root.active) + requestPaint() + on_ColChanged: if (root.active) + requestPaint() + + onVisibleChanged: if (visible) + 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: root.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: root.width - 16 + height: 1 + anchors.horizontalCenter: parent.horizontalCenter + color: M.Theme.base03 + visible: root.devices.length > 0 + } + + Repeater { + model: root.devices + delegate: Item { + required property var modelData + width: root.width + 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 + } +} diff --git a/shell/modules/content/VolumeContent.qml b/shell/modules/content/VolumeContent.qml new file mode 100644 index 0000000..43363a9 --- /dev/null +++ b/shell/modules/content/VolumeContent.qml @@ -0,0 +1,292 @@ +import QtQuick +import Quickshell.Services.Pipewire +import ".." as M + +Column { + id: root + + required property var sink + required property var sinkList + required property var streamList + required property color accentColor + + property real volume: sink?.audio?.volume ?? 0 + 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 + + // Slider row + Item { + width: root.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: root.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: root.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: root.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 + } + } +} diff --git a/shell/modules/content/qmldir b/shell/modules/content/qmldir new file mode 100644 index 0000000..cb33d81 --- /dev/null +++ b/shell/modules/content/qmldir @@ -0,0 +1,8 @@ +module content +HexWaveBackground 1.0 HexWaveBackground.qml +VolumeContent 1.0 VolumeContent.qml +MprisContent 1.0 MprisContent.qml +CpuContent 1.0 CpuContent.qml +MemoryContent 1.0 MemoryContent.qml +TemperatureContent 1.0 TemperatureContent.qml +DiskContent 1.0 DiskContent.qml diff --git a/modules/hex_wave.frag b/shell/modules/hex_wave.frag similarity index 100% rename from modules/hex_wave.frag rename to shell/modules/hex_wave.frag diff --git a/modules/qmldir b/shell/modules/qmldir similarity index 100% rename from modules/qmldir rename to shell/modules/qmldir diff --git a/shell.qml b/shell/shell.qml similarity index 97% rename from shell.qml rename to shell/shell.qml index 9d319fa..a429291 100644 --- a/shell.qml +++ b/shell/shell.qml @@ -1,7 +1,7 @@ //@ pragma Env QS_NO_RELOAD_POPUP=1 import "modules" -import "modules/lock" as Lock +import "lock" as Lock import Quickshell ShellRoot { diff --git a/test-notifs.sh b/test/test-notifs.sh similarity index 100% rename from test-notifs.sh rename to test/test-notifs.sh