diff --git a/README.bak.md b/README.bak.md
new file mode 100644
index 0000000..2aea5aa
--- /dev/null
+++ b/README.bak.md
@@ -0,0 +1,8 @@
+# quickshell
+
+## **current modules**
+
+#### [activate-linux](activate-linux.qml)
+
+Puts in a small windows like 'Activate Linux' watermark on the bottom right
+screen
diff --git a/README.md b/README.md
index 2aea5aa..c7901ef 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,62 @@
-# quickshell
+
caelestia-shell
-## **current modules**
+
+
+
+
+
+
-#### [activate-linux](activate-linux.qml)
+
-Puts in a small windows like 'Activate Linux' watermark on the bottom right
-screen
+https://github.com/user-attachments/assets/0840f496-575c-4ca6-83a8-87bb01a85c5f
+
+## Components
+
+- Widgets: [`Quickshell`](https://quickshell.outfoxxed.me)
+- Window manager: [`Hyprland`](https://hyprland.org)
+- Dots: [`caelestia`](https://github.com/caelestia-dots)
+
+## Installation
+
+### Automated installation (recommended)
+
+Install [`caelestia-scripts`](https://github.com/caelestia-dots/scripts) and run `caelestia install shell`.
+
+### Manual installation
+
+Install all [dependencies](https://github.com/caelestia-dots/scripts/blob/main/install/shell.fish#L10), then
+clone this repo into `$XDG_CONFIG_HOME/quickshell/caelestia` and run `qs -c caelestia`.
+
+## Usage
+
+All keybinds are accessible via Hyprland [global shortcuts](https://wiki.hyprland.org/Configuring/Binds/#dbus-global-shortcuts).
+For a preconfigured setup, install [`caelestia-hypr`](https://github.com/caelestia-dots/hypr) via `caelestia install hypr` or see
+[this file](https://github.com/caelestia-dots/hypr/blob/main/hyprland/keybinds.conf#L1-L29) for an example on how to use global
+shortcuts.
+
+There is only one IPC command as of now which can be used to get details about the currently active MPRIS player.
+```sh
+caelestia shell mpris getActive
+```
+
+## Credits
+
+Thanks to the Hyprland discord community (especially the homies in #rice-discussion) for all the help and suggestions
+for improving these dots!
+
+A special thanks to [@outfoxxed](https://github.com/outfoxxed) for making Quickshell and the effort put into fixing issues
+and implementing various feature requests.
+
+Another special thanks to [@end_4](https://github.com/end-4) for his [config](https://github.com/end-4/dots-hyprland)
+which helped me a lot with learning how to use Quickshell.
+
+## Stonks 📈
+
+
+
+
+
+
+
+
diff --git a/assets/bongocat.gif b/assets/bongocat.gif
new file mode 100644
index 0000000..4a50ff1
Binary files /dev/null and b/assets/bongocat.gif differ
diff --git a/assets/kurukuru.gif b/assets/kurukuru.gif
new file mode 100644
index 0000000..38d203d
Binary files /dev/null and b/assets/kurukuru.gif differ
diff --git a/assets/realtime-beat-detector.py b/assets/realtime-beat-detector.py
new file mode 100755
index 0000000..dfecad6
--- /dev/null
+++ b/assets/realtime-beat-detector.py
@@ -0,0 +1,73 @@
+#!/bin/python
+
+import pyaudio
+import numpy as np
+import aubio
+import signal
+import sys
+
+from typing import List, Tuple
+
+
+class BeatDetector:
+ def __init__(self, buf_size: int):
+ self.buf_size: int = buf_size
+
+ # Set up pyaudio and aubio beat detector
+ self.audio: pyaudio.PyAudio = pyaudio.PyAudio()
+ samplerate: int = 44100
+
+ self.stream: pyaudio.Stream = self.audio.open(
+ format=pyaudio.paFloat32,
+ channels=1,
+ rate=samplerate,
+ input=True,
+ frames_per_buffer=self.buf_size,
+ stream_callback=self._pyaudio_callback
+ )
+
+ fft_size: int = self.buf_size * 2
+
+ # tempo detection
+ self.tempo: aubio.tempo = aubio.tempo("default", fft_size, self.buf_size, samplerate)
+
+ # this one is called every time enough audio data (buf_size) has been read by the stream
+ def _pyaudio_callback(self, in_data, frame_count, time_info, status):
+ # Interpret a buffer as a 1-dimensional array (aubio do not work with raw data)
+ audio_data = np.frombuffer(in_data, dtype=np.float32)
+ # true if beat present
+ beat = self.tempo(audio_data)
+
+ # if beat detected, calculate BPM and send to OSC
+ if beat[0]:
+ print(self.tempo.get_bpm(), flush=True)
+
+ return None, pyaudio.paContinue # Tell pyAudio to continue
+
+ def __del__(self):
+ self.stream.close()
+ self.audio.terminate()
+ print('--- Stopped ---')
+
+
+# main
+def main():
+ bd = BeatDetector(512)
+
+ # capture ctrl+c to stop gracefully process
+ def signal_handler(none, frame):
+ bd.stream.stop_stream()
+ bd.stream.close()
+ bd.audio.terminate()
+ print(' ===> Ctrl + C')
+ sys.exit(0)
+
+ signal.signal(signal.SIGINT, signal_handler)
+
+ # Audio processing happens in separate thread, so put this thread to sleep
+ signal.pause()
+
+
+# main run
+if __name__ == "__main__":
+ main()
diff --git a/config/Appearance.qml b/config/Appearance.qml
new file mode 100644
index 0000000..1751228
--- /dev/null
+++ b/config/Appearance.qml
@@ -0,0 +1,84 @@
+pragma Singleton
+
+import Quickshell
+import QtQuick
+
+Singleton {
+ id: root
+
+ readonly property Rounding rounding: Rounding {}
+ readonly property Spacing spacing: Spacing {}
+ readonly property Padding padding: Padding {}
+ readonly property Font font: Font {}
+ readonly property Anim anim: Anim {}
+
+ component Rounding: QtObject {
+ readonly property int small: 12
+ readonly property int normal: 17
+ readonly property int large: 25
+ readonly property int full: 1000
+ }
+
+ component Spacing: QtObject {
+ readonly property int small: 7
+ readonly property int smaller: 10
+ readonly property int normal: 12
+ readonly property int larger: 15
+ readonly property int large: 20
+ }
+
+ component Padding: QtObject {
+ readonly property int small: 5
+ readonly property int smaller: 7
+ readonly property int normal: 10
+ readonly property int larger: 12
+ readonly property int large: 15
+ }
+
+ component FontFamily: QtObject {
+ readonly property string sans: "IBM Plex Sans"
+ readonly property string mono: "JetBrains Mono NF"
+ readonly property string material: "Material Symbols Rounded"
+ }
+
+ component FontSize: QtObject {
+ readonly property int small: 11
+ readonly property int smaller: 12
+ readonly property int normal: 13
+ readonly property int larger: 15
+ readonly property int large: 18
+ readonly property int extraLarge: 28
+ }
+
+ component Font: QtObject {
+ readonly property FontFamily family: FontFamily {}
+ readonly property FontSize size: FontSize {}
+ }
+
+ component AnimCurves: QtObject {
+ readonly property list emphasized: [0.05, 0, 2 / 15, 0.06, 1 / 6, 0.4, 5 / 24, 0.82, 0.25, 1, 1, 1]
+ readonly property list emphasizedAccel: [0.3, 0, 0.8, 0.15, 1, 1]
+ readonly property list emphasizedDecel: [0.05, 0.7, 0.1, 1, 1, 1]
+ readonly property list standard: [0.2, 0, 0, 1, 1, 1]
+ readonly property list standardAccel: [0.3, 0, 1, 1, 1, 1]
+ readonly property list standardDecel: [0, 0, 0, 1, 1, 1]
+ readonly property list expressiveFastSpatial: [0.42, 1.67, 0.21, 0.9, 1, 1]
+ readonly property list expressiveDefaultSpatial: [0.38, 1.21, 0.22, 1, 1, 1]
+ readonly property list expressiveEffects: [0.34, 0.8, 0.34, 1, 1, 1]
+ }
+
+ component AnimDurations: QtObject {
+ readonly property int small: 200
+ readonly property int normal: 400
+ readonly property int large: 600
+ readonly property int extraLarge: 1000
+ readonly property int expressiveFastSpatial: 350
+ readonly property int expressiveDefaultSpatial: 500
+ readonly property int expressiveEffects: 200
+ }
+
+ component Anim: QtObject {
+ readonly property AnimCurves curves: AnimCurves {}
+ readonly property AnimDurations durations: AnimDurations {}
+ }
+}
diff --git a/config/BarConfig.qml b/config/BarConfig.qml
new file mode 100644
index 0000000..2b5e7d5
--- /dev/null
+++ b/config/BarConfig.qml
@@ -0,0 +1,30 @@
+pragma Singleton
+
+import Quickshell
+import QtQuick
+
+Singleton {
+ id: root
+
+ readonly property Sizes sizes: Sizes {}
+ readonly property Workspaces workspaces: Workspaces {}
+
+ component Sizes: QtObject {
+ property int innerHeight: 30
+ property int windowPreviewSize: 400
+ property int trayMenuWidth: 300
+ property int batteryWidth: 200
+ }
+
+ component Workspaces: QtObject {
+ property int shown: 5
+ property bool rounded: true
+ property bool activeIndicator: true
+ property bool occupiedBg: false
+ property bool showWindows: true
+ property bool activeTrail: false
+ property string label: " "
+ property string occupiedLabel: " "
+ property string activeLabel: " "
+ }
+}
diff --git a/config/BorderConfig.qml b/config/BorderConfig.qml
new file mode 100644
index 0000000..a69cf59
--- /dev/null
+++ b/config/BorderConfig.qml
@@ -0,0 +1,13 @@
+pragma Singleton
+
+import "root:/services"
+import Quickshell
+import QtQuick
+
+Singleton {
+ id: root
+
+ readonly property color colour: Colours.palette.m3surface
+ readonly property int thickness: Appearance.padding.normal
+ readonly property int rounding: Appearance.rounding.large
+}
diff --git a/config/DashboardConfig.qml b/config/DashboardConfig.qml
new file mode 100644
index 0000000..3c32e07
--- /dev/null
+++ b/config/DashboardConfig.qml
@@ -0,0 +1,26 @@
+pragma Singleton
+
+import Quickshell
+import QtQuick
+
+Singleton {
+ readonly property int mediaUpdateInterval: 500
+ readonly property int visualiserBars: 45
+ readonly property Sizes sizes: Sizes {}
+
+ component Sizes: QtObject {
+ readonly property int tabIndicatorHeight: 3
+ readonly property int tabIndicatorSpacing: 5
+ readonly property int infoWidth: 200
+ readonly property int infoIconSize: 25
+ readonly property int dateTimeWidth: 110
+ readonly property int mediaWidth: 200
+ readonly property int mediaProgressSweep: 180
+ readonly property int mediaProgressThickness: 8
+ readonly property int resourceProgessThickness: 10
+ readonly property int weatherWidth: 250
+ readonly property int mediaCoverArtSize: 150
+ readonly property int mediaVisualiserSize: 80
+ readonly property int resourceSize: 200
+ }
+}
diff --git a/config/LauncherConfig.qml b/config/LauncherConfig.qml
new file mode 100644
index 0000000..18489f7
--- /dev/null
+++ b/config/LauncherConfig.qml
@@ -0,0 +1,18 @@
+pragma Singleton
+
+import Quickshell
+import QtQuick
+
+Singleton {
+ readonly property int maxShown: 8
+ readonly property int maxWallpapers: 9 // Warning: even numbers look bad
+ readonly property string actionPrefix: ">"
+ readonly property Sizes sizes: Sizes {}
+
+ component Sizes: QtObject {
+ readonly property int itemWidth: 600
+ readonly property int itemHeight: 57
+ readonly property int wallpaperWidth: 280
+ readonly property int wallpaperHeight: 200
+ }
+}
diff --git a/config/NotifsConfig.qml b/config/NotifsConfig.qml
new file mode 100644
index 0000000..34b9226
--- /dev/null
+++ b/config/NotifsConfig.qml
@@ -0,0 +1,19 @@
+pragma Singleton
+
+import Quickshell
+import QtQuick
+
+Singleton {
+ readonly property bool expire: false
+ readonly property int defaultExpireTimeout: 3000
+ readonly property real clearThreshold: 0.3
+ readonly property int expandThreshold: 20
+ readonly property bool actionOnClick: false
+ readonly property Sizes sizes: Sizes {}
+
+ component Sizes: QtObject {
+ readonly property int width: 400
+ readonly property int image: 41
+ readonly property int badge: 20
+ }
+}
diff --git a/config/OsdConfig.qml b/config/OsdConfig.qml
new file mode 100644
index 0000000..467d8e7
--- /dev/null
+++ b/config/OsdConfig.qml
@@ -0,0 +1,14 @@
+pragma Singleton
+
+import Quickshell
+import QtQuick
+
+Singleton {
+ readonly property int hideDelay: 2000
+ readonly property Sizes sizes: Sizes {}
+
+ component Sizes: QtObject {
+ readonly property int sliderWidth: 30
+ readonly property int sliderHeight: 150
+ }
+}
diff --git a/config/SessionConfig.qml b/config/SessionConfig.qml
new file mode 100644
index 0000000..c72d2f1
--- /dev/null
+++ b/config/SessionConfig.qml
@@ -0,0 +1,13 @@
+pragma Singleton
+
+import Quickshell
+import QtQuick
+
+Singleton {
+ readonly property int dragThreshold: 30
+ readonly property Sizes sizes: Sizes {}
+
+ component Sizes: QtObject {
+ readonly property int button: 80
+ }
+}
diff --git a/modules/Shortcuts.qml b/modules/Shortcuts.qml
new file mode 100644
index 0000000..f6cc9e3
--- /dev/null
+++ b/modules/Shortcuts.qml
@@ -0,0 +1,37 @@
+import "root:/widgets"
+import "root:/services"
+import Quickshell
+
+Scope {
+ id: root
+
+ property bool launcherInterrupted
+
+ CustomShortcut {
+ name: "session"
+ description: "Toggle session menu"
+ onPressed: {
+ const visibilities = Visibilities.getForActive();
+ visibilities.session = !visibilities.session;
+ }
+ }
+
+ CustomShortcut {
+ name: "launcher"
+ description: "Toggle launcher"
+ onPressed: root.launcherInterrupted = false
+ onReleased: {
+ if (!root.launcherInterrupted) {
+ const visibilities = Visibilities.getForActive();
+ visibilities.launcher = !visibilities.launcher;
+ }
+ root.launcherInterrupted = false;
+ }
+ }
+
+ CustomShortcut {
+ name: "launcherInterrupt"
+ description: "Interrupt launcher keybind"
+ onPressed: root.launcherInterrupted = true
+ }
+}
diff --git a/modules/background/Background.qml b/modules/background/Background.qml
new file mode 100644
index 0000000..11c9151
--- /dev/null
+++ b/modules/background/Background.qml
@@ -0,0 +1,26 @@
+import "root:/widgets"
+import Quickshell
+import Quickshell.Wayland
+
+Variants {
+ model: Quickshell.screens
+
+ StyledWindow {
+ id: win
+
+ required property ShellScreen modelData
+
+ screen: modelData
+ name: "background"
+ WlrLayershell.exclusionMode: ExclusionMode.Ignore
+ WlrLayershell.layer: WlrLayer.Background
+ color: "black"
+
+ anchors.top: true
+ anchors.bottom: true
+ anchors.left: true
+ anchors.right: true
+
+ Wallpaper {}
+ }
+}
diff --git a/modules/background/Wallpaper.qml b/modules/background/Wallpaper.qml
new file mode 100644
index 0000000..2734034
--- /dev/null
+++ b/modules/background/Wallpaper.qml
@@ -0,0 +1,77 @@
+pragma ComponentBehavior: Bound
+
+import "root:/widgets"
+import "root:/services"
+import "root:/config"
+import QtQuick
+
+Item {
+ id: root
+
+ property url source: Wallpapers.current ? `file://${Wallpapers.current}` : ""
+ property Image current: one
+
+ anchors.fill: parent
+
+ onSourceChanged: {
+ if (current === one)
+ two.update();
+ else
+ one.update();
+ }
+
+ Img {
+ id: one
+ }
+
+ Img {
+ id: two
+ }
+
+ component Img: CachingImage {
+ id: img
+
+ function update(): void {
+ const srcPath = `${root.source}`.slice(7);
+ if (thumbnail.originalPath === srcPath) {
+ root.current = this;
+ } else
+ path = srcPath;
+ }
+
+ anchors.fill: parent
+
+ loadOriginal: true
+ asynchronous: true
+ cache: false
+ fillMode: Image.PreserveAspectCrop
+
+ opacity: 0
+ scale: Wallpapers.showPreview ? 1 : 0.8
+
+ onStatusChanged: {
+ if (status === Image.Ready)
+ root.current = this;
+ }
+
+ states: State {
+ name: "visible"
+ when: root.current === img
+
+ PropertyChanges {
+ img.opacity: 1
+ img.scale: 1
+ }
+ }
+
+ transitions: Transition {
+ NumberAnimation {
+ target: img
+ properties: "opacity,scale"
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+ }
+}
diff --git a/modules/bar/Bar.qml b/modules/bar/Bar.qml
new file mode 100644
index 0000000..2e8ab89
--- /dev/null
+++ b/modules/bar/Bar.qml
@@ -0,0 +1,174 @@
+import "root:/widgets"
+import "root:/services"
+import "root:/config"
+import "root:/modules/bar/popouts" as BarPopouts
+import "components"
+import "components/workspaces"
+import Quickshell
+import QtQuick
+
+Item {
+ id: root
+
+ required property ShellScreen screen
+ required property BarPopouts.Wrapper popouts
+
+ function checkPopout(y: real): void {
+ const spacing = Appearance.spacing.small;
+ const aw = activeWindow.child;
+ const awy = activeWindow.y + aw.y;
+
+ const ty = tray.y;
+ const th = tray.implicitHeight;
+ const trayItems = tray.items;
+
+ const n = statusIconsInner.network;
+ const ny = statusIcons.y + statusIconsInner.y + n.y - spacing / 2;
+
+ const bls = statusIcons.y + statusIconsInner.y + statusIconsInner.bs - spacing / 2;
+ const ble = statusIcons.y + statusIconsInner.y + statusIconsInner.be + spacing / 2;
+
+ const b = statusIconsInner.battery;
+ const by = statusIcons.y + statusIconsInner.y + b.y - spacing / 2;
+
+ if (y >= awy && y <= awy + aw.implicitHeight) {
+ popouts.currentName = "activewindow";
+ popouts.currentCenter = Qt.binding(() => activeWindow.y + aw.y + aw.implicitHeight / 2);
+ popouts.hasCurrent = true;
+ } else if (y > ty && y < ty + th) {
+ const index = Math.floor(((y - ty) / th) * trayItems.count);
+ const item = trayItems.itemAt(index);
+
+ popouts.currentName = `traymenu${index}`;
+ popouts.currentCenter = Qt.binding(() => tray.y + item.y + item.implicitHeight / 2);
+ popouts.hasCurrent = true;
+ } else if (y >= ny && y <= ny + n.implicitHeight + spacing) {
+ popouts.currentName = "network";
+ popouts.currentCenter = Qt.binding(() => statusIcons.y + statusIconsInner.y + n.y + n.implicitHeight / 2);
+ popouts.hasCurrent = true;
+ } else if (y >= bls && y <= ble) {
+ popouts.currentName = "bluetooth";
+ popouts.currentCenter = Qt.binding(() => statusIcons.y + statusIconsInner.y + statusIconsInner.bs + (statusIconsInner.be - statusIconsInner.bs) / 2);
+ popouts.hasCurrent = true;
+ } else if (y >= by && y <= by + b.implicitHeight + spacing) {
+ popouts.currentName = "battery";
+ popouts.currentCenter = Qt.binding(() => statusIcons.y + statusIconsInner.y + b.y + b.implicitHeight / 2);
+ popouts.hasCurrent = true;
+ } else {
+ popouts.hasCurrent = false;
+ }
+ }
+
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ anchors.left: parent.left
+
+ implicitWidth: child.implicitWidth + BorderConfig.thickness * 2
+
+ Item {
+ id: child
+
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ implicitWidth: Math.max(osIcon.implicitWidth, workspaces.implicitWidth, activeWindow.implicitWidth, tray.implicitWidth, clock.implicitWidth, statusIcons.implicitWidth, power.implicitWidth)
+
+ OsIcon {
+ id: osIcon
+
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.top: parent.top
+ anchors.topMargin: Appearance.padding.large
+ }
+
+ StyledRect {
+ id: workspaces
+
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.top: osIcon.bottom
+ anchors.topMargin: Appearance.spacing.normal
+
+ radius: Appearance.rounding.full
+ color: Colours.palette.m3surfaceContainer
+
+ implicitWidth: workspacesInner.implicitWidth + Appearance.padding.small * 2
+ implicitHeight: workspacesInner.implicitHeight + Appearance.padding.small * 2
+
+ MouseArea {
+ anchors.fill: parent
+ anchors.leftMargin: -BorderConfig.thickness
+ anchors.rightMargin: -BorderConfig.thickness
+
+ onWheel: event => {
+ const activeWs = Hyprland.activeClient?.workspace?.name;
+ if (activeWs?.startsWith("special:"))
+ Hyprland.dispatch(`togglespecialworkspace ${activeWs.slice(8)}`);
+ else if (event.angleDelta.y < 0 || Hyprland.activeWsId > 1)
+ Hyprland.dispatch(`workspace r${event.angleDelta.y > 0 ? "-" : "+"}1`);
+ }
+ }
+
+ Workspaces {
+ id: workspacesInner
+
+ anchors.centerIn: parent
+ }
+ }
+
+ ActiveWindow {
+ id: activeWindow
+
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.top: workspaces.bottom
+ anchors.bottom: tray.top
+ anchors.margins: Appearance.spacing.large
+
+ monitor: Brightness.getMonitorForScreen(root.screen)
+ }
+
+ Tray {
+ id: tray
+
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.bottom: clock.top
+ anchors.bottomMargin: Appearance.spacing.larger
+ }
+
+ Clock {
+ id: clock
+
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.bottom: statusIcons.top
+ anchors.bottomMargin: Appearance.spacing.normal
+ }
+
+ StyledRect {
+ id: statusIcons
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: power.top
+ anchors.bottomMargin: Appearance.spacing.normal
+
+ radius: Appearance.rounding.full
+ color: Colours.palette.m3surfaceContainer
+
+ implicitHeight: statusIconsInner.implicitHeight + Appearance.padding.normal * 2
+
+ StatusIcons {
+ id: statusIconsInner
+
+ anchors.centerIn: parent
+ }
+ }
+
+ Power {
+ id: power
+
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.bottom: parent.bottom
+ anchors.bottomMargin: Appearance.padding.large
+ }
+ }
+}
diff --git a/modules/bar/components/ActiveWindow.qml b/modules/bar/components/ActiveWindow.qml
new file mode 100644
index 0000000..f6204ae
--- /dev/null
+++ b/modules/bar/components/ActiveWindow.qml
@@ -0,0 +1,140 @@
+pragma ComponentBehavior: Bound
+
+import "root:/widgets"
+import "root:/services"
+import "root:/utils"
+import "root:/config"
+import QtQuick
+
+Item {
+ id: root
+
+ required property Brightness.Monitor monitor
+ property color colour: Colours.palette.m3primary
+ readonly property Item child: child
+
+ implicitWidth: child.implicitWidth
+ implicitHeight: child.implicitHeight
+
+ MouseArea {
+ anchors.top: parent.top
+ anchors.bottom: child.top
+ anchors.left: parent.left
+ anchors.right: parent.right
+
+ onWheel: event => {
+ if (event.angleDelta.y > 0)
+ Audio.setVolume(Audio.volume + 0.1);
+ else if (event.angleDelta.y < 0)
+ Audio.setVolume(Audio.volume - 0.1);
+ }
+ }
+
+ MouseArea {
+ anchors.top: child.bottom
+ anchors.bottom: parent.bottom
+ anchors.left: parent.left
+ anchors.right: parent.right
+
+ onWheel: event => {
+ const monitor = root.monitor;
+ if (event.angleDelta.y > 0)
+ monitor.setBrightness(monitor.brightness + 0.1);
+ else if (event.angleDelta.y < 0)
+ monitor.setBrightness(monitor.brightness - 0.1);
+ }
+ }
+
+ Item {
+ id: child
+
+ property Item current: text1
+
+ anchors.centerIn: parent
+
+ clip: true
+ implicitWidth: Math.max(icon.implicitWidth, current.implicitHeight)
+ implicitHeight: icon.implicitHeight + current.implicitWidth + current.anchors.topMargin
+
+ MaterialIcon {
+ id: icon
+
+ animate: true
+ text: Icons.getAppCategoryIcon(Hyprland.activeClient?.wmClass, "desktop_windows")
+ color: root.colour
+
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+
+ Title {
+ id: text1
+ }
+
+ Title {
+ id: text2
+ }
+
+ TextMetrics {
+ id: metrics
+
+ text: Hyprland.activeClient?.title ?? qsTr("Desktop")
+ font.pointSize: Appearance.font.size.smaller
+ font.family: Appearance.font.family.mono
+ elide: Qt.ElideRight
+ elideWidth: root.height - icon.height
+
+ onTextChanged: {
+ const next = child.current === text1 ? text2 : text1;
+ next.text = elidedText;
+ child.current = next;
+ }
+ onElideWidthChanged: child.current.text = elidedText
+ }
+
+ Behavior on implicitWidth {
+ NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+
+ Behavior on implicitHeight {
+ NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+ }
+
+ component Title: StyledText {
+ id: text
+
+ anchors.horizontalCenter: icon.horizontalCenter
+ anchors.top: icon.bottom
+ anchors.topMargin: Appearance.spacing.small
+
+ font.pointSize: metrics.font.pointSize
+ font.family: metrics.font.family
+ color: root.colour
+ opacity: child.current === this ? 1 : 0
+
+ transform: Rotation {
+ angle: 90
+ origin.x: text.implicitHeight / 2
+ origin.y: text.implicitHeight / 2
+ }
+
+ width: implicitHeight
+ height: implicitWidth
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+ }
+}
diff --git a/modules/bar/components/Clock.qml b/modules/bar/components/Clock.qml
new file mode 100644
index 0000000..ea57e2b
--- /dev/null
+++ b/modules/bar/components/Clock.qml
@@ -0,0 +1,33 @@
+import "root:/widgets"
+import "root:/services"
+import "root:/config"
+import QtQuick
+
+Column {
+ id: root
+
+ property color colour: Colours.palette.m3tertiary
+
+ spacing: Appearance.spacing.small
+
+ MaterialIcon {
+ id: icon
+
+ text: "calendar_month"
+ color: root.colour
+
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+
+ StyledText {
+ id: text
+
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ horizontalAlignment: StyledText.AlignHCenter
+ text: Time.format("hh\nmm")
+ font.pointSize: Appearance.font.size.smaller
+ font.family: Appearance.font.family.mono
+ color: root.colour
+ }
+}
diff --git a/modules/bar/components/OsIcon.qml b/modules/bar/components/OsIcon.qml
new file mode 100644
index 0000000..e040cf0
--- /dev/null
+++ b/modules/bar/components/OsIcon.qml
@@ -0,0 +1,11 @@
+import "root:/widgets"
+import "root:/services"
+import "root:/utils"
+import "root:/config"
+
+StyledText {
+ text: Icons.osIcon
+ font.pointSize: Appearance.font.size.smaller
+ font.family: Appearance.font.family.mono
+ color: Colours.palette.m3tertiary
+}
diff --git a/modules/bar/components/Power.qml b/modules/bar/components/Power.qml
new file mode 100644
index 0000000..a870cb3
--- /dev/null
+++ b/modules/bar/components/Power.qml
@@ -0,0 +1,27 @@
+import "root:/widgets"
+import "root:/services"
+import "root:/config"
+import Quickshell
+
+MaterialIcon {
+ text: "power_settings_new"
+ color: Colours.palette.m3error
+ font.bold: true
+ font.pointSize: Appearance.font.size.normal
+
+ StateLayer {
+ anchors.fill: undefined
+ anchors.centerIn: parent
+ anchors.horizontalCenterOffset: 1
+
+ implicitWidth: parent.implicitHeight + Appearance.padding.small * 2
+ implicitHeight: implicitWidth
+
+ radius: Appearance.rounding.full
+
+ function onClicked(): void {
+ const v = Visibilities.screens[QsWindow.window.screen];
+ v.session = !v.session;
+ }
+ }
+}
diff --git a/modules/bar/components/StatusIcons.qml b/modules/bar/components/StatusIcons.qml
new file mode 100644
index 0000000..f92159b
--- /dev/null
+++ b/modules/bar/components/StatusIcons.qml
@@ -0,0 +1,114 @@
+import "root:/widgets"
+import "root:/services"
+import "root:/utils"
+import "root:/config"
+import Quickshell
+import Quickshell.Services.UPower
+import QtQuick
+
+Item {
+ id: root
+
+ property color colour: Colours.palette.m3secondary
+
+ readonly property Item network: network
+ readonly property real bs: bluetooth.y
+ readonly property real be: repeater.count > 0 ? devices.y + devices.implicitHeight : bluetooth.y + bluetooth.implicitHeight
+ readonly property Item battery: battery
+
+ clip: true
+ implicitWidth: Math.max(network.implicitWidth, bluetooth.implicitWidth, devices.implicitWidth, battery.implicitWidth)
+ implicitHeight: network.implicitHeight + bluetooth.implicitHeight + bluetooth.anchors.topMargin + (repeater.count > 0 ? devices.implicitHeight + devices.anchors.topMargin : 0) + battery.implicitHeight + battery.anchors.topMargin
+
+ MaterialIcon {
+ id: network
+
+ animate: true
+ text: Network.active ? Icons.getNetworkIcon(Network.active.strength ?? 0) : "wifi_off"
+ color: root.colour
+
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+
+ MaterialIcon {
+ id: bluetooth
+
+ anchors.horizontalCenter: network.horizontalCenter
+ anchors.top: network.bottom
+ anchors.topMargin: Appearance.spacing.small
+
+ animate: true
+ text: Bluetooth.powered ? "bluetooth" : "bluetooth_disabled"
+ color: root.colour
+ }
+
+ Column {
+ id: devices
+
+ anchors.horizontalCenter: bluetooth.horizontalCenter
+ anchors.top: bluetooth.bottom
+ anchors.topMargin: Appearance.spacing.small
+
+ Repeater {
+ id: repeater
+
+ model: ScriptModel {
+ values: Bluetooth.devices.filter(d => d.connected)
+ }
+
+ MaterialIcon {
+ required property Bluetooth.Device modelData
+
+ animate: true
+ text: Icons.getBluetoothIcon(modelData.icon)
+ color: root.colour
+ }
+ }
+ }
+
+ MaterialIcon {
+ id: battery
+
+ anchors.horizontalCenter: devices.horizontalCenter
+ anchors.top: repeater.count > 0 ? devices.bottom : bluetooth.bottom
+ anchors.topMargin: Appearance.spacing.small
+
+ animate: true
+ text: {
+ if (!UPower.displayDevice.isLaptopBattery) {
+ if (PowerProfiles.profile === PowerProfile.PowerSaver)
+ return "energy_savings_leaf";
+ if (PowerProfiles.profile === PowerProfile.Performance)
+ return "rocket_launch";
+ return "balance";
+ }
+
+ const perc = UPower.displayDevice.percentage;
+ const charging = !UPower.onBattery;
+ if (perc === 1)
+ return charging ? "battery_charging_full" : "battery_full";
+ let level = Math.floor(perc * 7);
+ if (charging && (level === 4 || level === 1))
+ level--;
+ return charging ? `battery_charging_${(level + 3) * 10}` : `battery_${level}_bar`;
+ }
+ color: !UPower.onBattery || UPower.displayDevice.percentage > 0.2 ? root.colour : Colours.palette.m3error
+ fill: 1
+ }
+
+ Behavior on implicitWidth {
+ NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+
+ Behavior on implicitHeight {
+ NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+}
diff --git a/modules/bar/components/Tray.qml b/modules/bar/components/Tray.qml
new file mode 100644
index 0000000..6280a30
--- /dev/null
+++ b/modules/bar/components/Tray.qml
@@ -0,0 +1,56 @@
+import "root:/config"
+import Quickshell.Services.SystemTray
+import QtQuick
+
+Item {
+ id: root
+
+ readonly property Repeater items: items
+
+ clip: true
+ visible: width > 0 && height > 0 // To avoid warnings about being visible with no size
+
+ implicitWidth: layout.implicitWidth
+ implicitHeight: layout.implicitHeight
+
+ Column {
+ id: layout
+
+ spacing: Appearance.spacing.small
+
+ add: Transition {
+ NumberAnimation {
+ properties: "scale"
+ from: 0
+ to: 1
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standardDecel
+ }
+ }
+
+ Repeater {
+ id: items
+
+ model: SystemTray.items
+
+ TrayItem {}
+ }
+ }
+
+ Behavior on implicitWidth {
+ NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+
+ Behavior on implicitHeight {
+ NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+}
diff --git a/modules/bar/components/TrayItem.qml b/modules/bar/components/TrayItem.qml
new file mode 100644
index 0000000..b8e5d42
--- /dev/null
+++ b/modules/bar/components/TrayItem.qml
@@ -0,0 +1,48 @@
+pragma ComponentBehavior: Bound
+
+import "root:/widgets"
+import "root:/config"
+import Quickshell
+import Quickshell.Widgets
+import Quickshell.Services.SystemTray
+import QtQuick
+
+MouseArea {
+ id: root
+
+ required property SystemTrayItem modelData
+
+ acceptedButtons: Qt.LeftButton | Qt.RightButton
+ implicitWidth: Appearance.font.size.small * 2
+ implicitHeight: Appearance.font.size.small * 2
+
+ onClicked: event => {
+ if (event.button === Qt.LeftButton)
+ modelData.activate();
+ else if (modelData.hasMenu)
+ menu.open();
+ }
+
+ // TODO custom menu
+ QsMenuAnchor {
+ id: menu
+
+ menu: root.modelData.menu
+ anchor.window: this.QsWindow.window
+ }
+
+ IconImage {
+ id: icon
+
+ source: {
+ let icon = root.modelData.icon;
+ if (icon.includes("?path=")) {
+ const [name, path] = icon.split("?path=");
+ icon = `file://${path}/${name.slice(name.lastIndexOf("/") + 1)}`;
+ }
+ return icon;
+ }
+ asynchronous: true
+ anchors.fill: parent
+ }
+}
diff --git a/modules/bar/components/workspaces/ActiveIndicator.qml b/modules/bar/components/workspaces/ActiveIndicator.qml
new file mode 100644
index 0000000..3f3ca62
--- /dev/null
+++ b/modules/bar/components/workspaces/ActiveIndicator.qml
@@ -0,0 +1,111 @@
+import "root:/widgets"
+import "root:/services"
+import "root:/config"
+import QtQuick
+import QtQuick.Effects
+
+StyledRect {
+ id: root
+
+ required property list workspaces
+ required property Item mask
+ required property real maskWidth
+ required property real maskHeight
+ required property int groupOffset
+
+ readonly property int currentWsIdx: Hyprland.activeWsId - 1 - groupOffset
+ property real leading: getWsY(currentWsIdx)
+ property real trailing: getWsY(currentWsIdx)
+ property real currentSize: workspaces[currentWsIdx]?.size ?? 0
+ property real offset: Math.min(leading, trailing)
+ property real size: {
+ const s = Math.abs(leading - trailing) + currentSize;
+ if (BarConfig.workspaces.activeTrail && lastWs > currentWsIdx)
+ return Math.min(getWsY(lastWs) + (workspaces[lastWs]?.size ?? 0) - offset, s);
+ return s;
+ }
+
+ property int cWs
+ property int lastWs
+
+ function getWsY(idx: int): real {
+ let y = 0;
+ for (let i = 0; i < idx; i++)
+ y += workspaces[i]?.size ?? 0;
+ return y;
+ }
+
+ onCurrentWsIdxChanged: {
+ lastWs = cWs;
+ cWs = currentWsIdx;
+ }
+
+ clip: true
+ x: 1
+ y: offset + 1
+ implicitWidth: BarConfig.sizes.innerHeight - 2
+ implicitHeight: size - 2
+ radius: BarConfig.workspaces.rounded ? Appearance.rounding.full : 0
+ color: Colours.palette.m3primary
+
+ StyledRect {
+ id: base
+
+ visible: false
+ anchors.fill: parent
+ color: Colours.palette.m3onPrimary
+ }
+
+ MultiEffect {
+ source: base
+ maskSource: root.mask
+ maskEnabled: true
+ maskSpreadAtMin: 1
+ maskThresholdMin: 0.5
+
+ x: 0
+ y: -parent.offset
+ implicitWidth: root.maskWidth
+ implicitHeight: root.maskHeight
+
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+
+ Behavior on leading {
+ enabled: BarConfig.workspaces.activeTrail
+
+ Anim {}
+ }
+
+ Behavior on trailing {
+ enabled: BarConfig.workspaces.activeTrail
+
+ Anim {
+ duration: Appearance.anim.durations.normal * 2
+ }
+ }
+
+ Behavior on currentSize {
+ enabled: BarConfig.workspaces.activeTrail
+
+ Anim {}
+ }
+
+ Behavior on offset {
+ enabled: !BarConfig.workspaces.activeTrail
+
+ Anim {}
+ }
+
+ Behavior on size {
+ enabled: !BarConfig.workspaces.activeTrail
+
+ Anim {}
+ }
+
+ component Anim: NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+}
diff --git a/modules/bar/components/workspaces/OccupiedBg.qml b/modules/bar/components/workspaces/OccupiedBg.qml
new file mode 100644
index 0000000..4fba2f4
--- /dev/null
+++ b/modules/bar/components/workspaces/OccupiedBg.qml
@@ -0,0 +1,99 @@
+pragma ComponentBehavior: Bound
+
+import "root:/widgets"
+import "root:/services"
+import "root:/config"
+import Quickshell
+import QtQuick
+
+Item {
+ id: root
+
+ required property list workspaces
+ required property var occupied
+ required property int groupOffset
+
+ property list pills: []
+
+ onOccupiedChanged: {
+ let count = 0;
+ const start = groupOffset;
+ const end = start + BarConfig.workspaces.shown;
+ for (const [ws, occ] of Object.entries(occupied)) {
+ if (ws > start && ws <= end && occ) {
+ if (!occupied[ws - 1]) {
+ if (pills[count])
+ pills[count].start = ws;
+ else
+ pills.push(pillComp.createObject(root, {
+ start: ws
+ }));
+ count++;
+ }
+ if (!occupied[ws + 1])
+ pills[count - 1].end = ws;
+ }
+ }
+ if (pills.length > count)
+ pills.splice(count, pills.length - count).forEach(p => p.destroy());
+ }
+
+ Repeater {
+ model: ScriptModel {
+ values: root.pills.filter(p => p)
+ }
+
+ StyledRect {
+ id: rect
+
+ required property var modelData
+
+ readonly property Workspace start: root.workspaces[modelData.start - 1 - root.groupOffset] ?? null
+ readonly property Workspace end: root.workspaces[modelData.end - 1 - root.groupOffset] ?? null
+
+ color: Colours.alpha(Colours.palette.m3surfaceContainerHigh, true)
+ radius: BarConfig.workspaces.rounded ? Appearance.rounding.full : 0
+
+ x: start?.x ?? 0
+ y: start?.y ?? 0
+ implicitWidth: BarConfig.sizes.innerHeight
+ implicitHeight: end?.y + end?.height - start?.y
+
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ scale: 0
+ Component.onCompleted: scale = 1
+
+ Behavior on scale {
+ Anim {
+ easing.bezierCurve: Appearance.anim.curves.standardDecel
+ }
+ }
+
+ Behavior on x {
+ Anim {}
+ }
+
+ Behavior on y {
+ Anim {}
+ }
+ }
+ }
+
+ component Anim: NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+
+ component Pill: QtObject {
+ property int start
+ property int end
+ }
+
+ Component {
+ id: pillComp
+
+ Pill {}
+ }
+}
diff --git a/modules/bar/components/workspaces/Workspace.qml b/modules/bar/components/workspaces/Workspace.qml
new file mode 100644
index 0000000..fa5fe62
--- /dev/null
+++ b/modules/bar/components/workspaces/Workspace.qml
@@ -0,0 +1,93 @@
+import "root:/widgets"
+import "root:/services"
+import "root:/utils"
+import "root:/config"
+import Quickshell
+import QtQuick
+import QtQuick.Layouts
+
+Item {
+ id: root
+
+ required property int index
+ required property var occupied
+ required property int groupOffset
+
+ readonly property bool isWorkspace: true // Flag for finding workspace children
+ // Unanimated prop for others to use as reference
+ readonly property real size: childrenRect.height + (hasWindows ? Appearance.padding.normal : 0)
+
+ readonly property int ws: groupOffset + index + 1
+ readonly property bool isOccupied: occupied[ws] ?? false
+ readonly property bool hasWindows: isOccupied && BarConfig.workspaces.showWindows
+
+ Layout.preferredWidth: childrenRect.width
+ Layout.preferredHeight: size
+
+ StyledText {
+ id: indicator
+
+ readonly property string label: BarConfig.workspaces.label || root.ws
+ readonly property string occupiedLabel: BarConfig.workspaces.occupiedLabel || label
+ readonly property string activeLabel: BarConfig.workspaces.activeLabel || (root.isOccupied ? occupiedLabel : label)
+
+ animate: true
+ text: Hyprland.activeWsId === root.ws ? activeLabel : root.isOccupied ? occupiedLabel : label
+ color: BarConfig.workspaces.occupiedBg || root.isOccupied || Hyprland.activeWsId === root.ws ? Colours.palette.m3onSurface : Colours.palette.m3outlineVariant
+ horizontalAlignment: StyledText.AlignHCenter
+ verticalAlignment: StyledText.AlignVCenter
+
+ width: BarConfig.sizes.innerHeight
+ height: BarConfig.sizes.innerHeight
+ }
+
+ Loader {
+ id: windows
+
+ active: BarConfig.workspaces.showWindows
+ asynchronous: true
+
+ anchors.horizontalCenter: indicator.horizontalCenter
+ anchors.top: indicator.bottom
+
+ sourceComponent: Column {
+ spacing: Appearance.spacing.small
+
+ add: Transition {
+ Anim {
+ properties: "scale"
+ from: 0
+ to: 1
+ easing.bezierCurve: Appearance.anim.curves.standardDecel
+ }
+ }
+
+ Repeater {
+ model: ScriptModel {
+ values: Hyprland.clients.filter(c => c.workspace?.id === root.ws)
+ }
+
+ MaterialIcon {
+ required property Hyprland.Client modelData
+
+ text: Icons.getAppCategoryIcon(modelData.wmClass, "terminal")
+ color: Colours.palette.m3onSurfaceVariant
+ }
+ }
+ }
+ }
+
+ Behavior on Layout.preferredWidth {
+ Anim {}
+ }
+
+ Behavior on Layout.preferredHeight {
+ Anim {}
+ }
+
+ component Anim: NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+}
diff --git a/modules/bar/components/workspaces/Workspaces.qml b/modules/bar/components/workspaces/Workspaces.qml
new file mode 100644
index 0000000..4f4c75b
--- /dev/null
+++ b/modules/bar/components/workspaces/Workspaces.qml
@@ -0,0 +1,75 @@
+pragma ComponentBehavior: Bound
+
+import "root:/widgets"
+import "root:/services"
+import "root:/config"
+import QtQuick
+import QtQuick.Layouts
+
+Item {
+ id: root
+
+ readonly property list workspaces: layout.children.filter(c => c.isWorkspace).sort((w1, w2) => w1.ws - w2.ws)
+ readonly property var occupied: Hyprland.workspaces.values.reduce((acc, curr) => {
+ acc[curr.id] = curr.lastIpcObject.windows > 0;
+ return acc;
+ }, {})
+ readonly property int groupOffset: Math.floor((Hyprland.activeWsId - 1) / BarConfig.workspaces.shown) * BarConfig.workspaces.shown
+
+ implicitWidth: layout.implicitWidth
+ implicitHeight: layout.implicitHeight
+
+ ColumnLayout {
+ id: layout
+
+ spacing: 0
+ layer.enabled: true
+ layer.smooth: true
+
+ Repeater {
+ model: BarConfig.workspaces.shown
+
+ Workspace {
+ occupied: root.occupied
+ groupOffset: root.groupOffset
+ }
+ }
+ }
+
+ Loader {
+ active: BarConfig.workspaces.occupiedBg
+ asynchronous: true
+
+ z: -1
+ anchors.fill: parent
+
+ sourceComponent: OccupiedBg {
+ workspaces: root.workspaces
+ occupied: root.occupied
+ groupOffset: root.groupOffset
+ }
+ }
+
+ Loader {
+ active: BarConfig.workspaces.activeIndicator
+ asynchronous: true
+
+ sourceComponent: ActiveIndicator {
+ workspaces: root.workspaces
+ mask: layout
+ maskWidth: root.width
+ maskHeight: root.height
+ groupOffset: root.groupOffset
+ }
+ }
+
+ MouseArea {
+ anchors.fill: parent
+
+ onPressed: event => {
+ const ws = layout.childAt(event.x, event.y).index + root.groupOffset + 1;
+ if (Hyprland.activeWsId !== ws)
+ Hyprland.dispatch(`workspace ${ws}`);
+ }
+ }
+}
diff --git a/modules/bar/popouts/ActiveWindow.qml b/modules/bar/popouts/ActiveWindow.qml
new file mode 100644
index 0000000..f3a5269
--- /dev/null
+++ b/modules/bar/popouts/ActiveWindow.qml
@@ -0,0 +1,75 @@
+import "root:/widgets"
+import "root:/services"
+import "root:/utils"
+import "root:/config"
+import Quickshell.Widgets
+import Quickshell.Wayland
+import QtQuick
+
+Item {
+ id: root
+
+ implicitWidth: Hyprland.activeClient ? child.implicitWidth : -Appearance.padding.large * 2
+ implicitHeight: child.implicitHeight
+
+ Column {
+ id: child
+
+ anchors.centerIn: parent
+ spacing: Appearance.spacing.normal
+
+ Row {
+ id: detailsRow
+
+ spacing: Appearance.spacing.normal
+
+ IconImage {
+ id: icon
+
+ implicitSize: details.implicitHeight
+ source: Icons.getAppIcon(Hyprland.activeClient?.wmClass ?? "", "image-missing")
+ }
+
+ Column {
+ id: details
+
+ StyledText {
+ text: Hyprland.activeClient?.title ?? ""
+ font.pointSize: Appearance.font.size.normal
+
+ elide: Text.ElideRight
+ width: preview.implicitWidth - icon.implicitWidth - detailsRow.spacing
+ }
+
+ StyledText {
+ text: Hyprland.activeClient?.wmClass ?? ""
+ color: Colours.palette.m3onSurfaceVariant
+
+ elide: Text.ElideRight
+ width: preview.implicitWidth - icon.implicitWidth - detailsRow.spacing
+ }
+ }
+ }
+
+ ClippingWrapperRectangle {
+ color: "transparent"
+ radius: Appearance.rounding.small
+
+ ScreencopyView {
+ id: preview
+
+ captureSource: ToplevelManager.toplevels.values.find(t => t.title === Hyprland.activeClient?.title) ?? null
+ live: visible
+
+ constraintSize.width: BarConfig.sizes.windowPreviewSize
+ constraintSize.height: BarConfig.sizes.windowPreviewSize
+ }
+ }
+ }
+
+ component Anim: NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+}
diff --git a/modules/bar/popouts/Background.qml b/modules/bar/popouts/Background.qml
new file mode 100644
index 0000000..a56b0b7
--- /dev/null
+++ b/modules/bar/popouts/Background.qml
@@ -0,0 +1,74 @@
+import "root:/services"
+import "root:/config"
+import QtQuick
+import QtQuick.Shapes
+
+ShapePath {
+ id: root
+
+ required property Wrapper wrapper
+ required property bool invertBottomRounding
+ readonly property real rounding: BorderConfig.rounding
+ readonly property bool flatten: wrapper.width < rounding * 2
+ readonly property real roundingX: flatten ? wrapper.width / 2 : rounding
+ property real ibr: invertBottomRounding ? -1 : 1
+
+ strokeWidth: -1
+ fillColor: BorderConfig.colour
+
+ PathArc {
+ relativeX: root.roundingX
+ relativeY: root.rounding
+ radiusX: Math.min(root.rounding, root.wrapper.width)
+ radiusY: root.rounding
+ direction: PathArc.Counterclockwise
+ }
+ PathLine {
+ relativeX: root.wrapper.width - root.roundingX * 2
+ relativeY: 0
+ }
+ PathArc {
+ relativeX: root.roundingX
+ relativeY: root.rounding
+ radiusX: Math.min(root.rounding, root.wrapper.width)
+ radiusY: root.rounding
+ }
+ PathLine {
+ relativeX: 0
+ relativeY: root.wrapper.height - root.rounding * 2
+ }
+ PathArc {
+ relativeX: -root.roundingX * root.ibr
+ relativeY: root.rounding
+ radiusX: Math.min(root.rounding, root.wrapper.width)
+ radiusY: root.rounding
+ direction: root.ibr < 0 ? PathArc.Counterclockwise : PathArc.Clockwise
+ }
+ PathLine {
+ relativeX: -(root.wrapper.width - root.roundingX - root.roundingX * root.ibr)
+ relativeY: 0
+ }
+ PathArc {
+ relativeX: -root.roundingX
+ relativeY: root.rounding
+ radiusX: Math.min(root.rounding, root.wrapper.width)
+ radiusY: root.rounding
+ direction: PathArc.Counterclockwise
+ }
+
+ Behavior on fillColor {
+ ColorAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+
+ Behavior on ibr {
+ NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+}
diff --git a/modules/bar/popouts/Battery.qml b/modules/bar/popouts/Battery.qml
new file mode 100644
index 0000000..44e51ce
--- /dev/null
+++ b/modules/bar/popouts/Battery.qml
@@ -0,0 +1,235 @@
+pragma ComponentBehavior: Bound
+
+import "root:/widgets"
+import "root:/services"
+import "root:/config"
+import Quickshell.Services.UPower
+import QtQuick
+
+Column {
+ id: root
+
+ spacing: Appearance.spacing.normal
+ width: BarConfig.sizes.batteryWidth
+
+ StyledText {
+ text: UPower.displayDevice.isLaptopBattery ? qsTr("Remaining: %1%").arg(Math.round(UPower.displayDevice.percentage * 100)) : qsTr("No battery detected")
+ }
+
+ StyledText {
+ function formatSeconds(s: int, fallback: string): string {
+ const day = Math.floor(s / 86400);
+ const hr = Math.floor(s / 3600) % 60;
+ const min = Math.floor(s / 60) % 60;
+
+ let comps = [];
+ if (day > 0)
+ comps.push(`${day} days`);
+ if (hr > 0)
+ comps.push(`${hr} hours`);
+ if (min > 0)
+ comps.push(`${min} mins`);
+
+ return comps.join(", ") || fallback;
+ }
+
+ text: UPower.displayDevice.isLaptopBattery ? qsTr("Time %1: %2").arg(UPower.onBattery ? "remaining" : "until charged").arg(UPower.onBattery ? formatSeconds(UPower.displayDevice.timeToEmpty, "Calculating...") : formatSeconds(UPower.displayDevice.timeToFull, "Fully charged!")) : qsTr("Power profile: %1").arg(PowerProfile.toString(PowerProfiles.profile))
+ }
+
+ Loader {
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ active: PowerProfiles.degradationReason !== PerformanceDegradationReason.None
+ asynchronous: true
+
+ height: active ? (item?.implicitHeight ?? 0) : 0
+
+ sourceComponent: StyledRect {
+ implicitWidth: child.implicitWidth + Appearance.padding.normal * 2
+ implicitHeight: child.implicitHeight + Appearance.padding.smaller * 2
+
+ color: Colours.palette.m3error
+ radius: Appearance.rounding.normal
+
+ Column {
+ id: child
+
+ anchors.centerIn: parent
+
+ Row {
+ anchors.horizontalCenter: parent.horizontalCenter
+ spacing: Appearance.spacing.small
+
+ MaterialIcon {
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.verticalCenterOffset: -font.pointSize / 10
+
+ text: "warning"
+ color: Colours.palette.m3onError
+ }
+
+ StyledText {
+ anchors.verticalCenter: parent.verticalCenter
+ text: qsTr("Performance Degraded")
+ color: Colours.palette.m3onError
+ font.family: Appearance.font.family.mono
+ font.weight: 500
+ }
+
+ MaterialIcon {
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.verticalCenterOffset: -font.pointSize / 10
+
+ text: "warning"
+ color: Colours.palette.m3onError
+ }
+ }
+
+ StyledText {
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ text: qsTr("Reason: %1").arg(PerformanceDegradationReason.toString(PowerProfiles.degradationReason))
+ color: Colours.palette.m3onError
+ }
+ }
+ }
+ }
+
+ StyledRect {
+ id: profiles
+
+ property string current: {
+ const p = PowerProfiles.profile;
+ if (p === PowerProfile.PowerSaver)
+ return saver.icon;
+ if (p === PowerProfile.Performance)
+ return perf.icon;
+ return balance.icon;
+ }
+
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ implicitWidth: saver.implicitHeight + balance.implicitHeight + perf.implicitHeight + Appearance.padding.normal * 2 + Appearance.spacing.large * 2
+ implicitHeight: Math.max(saver.implicitHeight, balance.implicitHeight, perf.implicitHeight) + Appearance.padding.small * 2
+
+ color: Colours.palette.m3surfaceContainer
+ radius: Appearance.rounding.full
+
+ StyledRect {
+ id: indicator
+
+ color: Colours.palette.m3primary
+ radius: Appearance.rounding.full
+ state: profiles.current
+
+ states: [
+ State {
+ name: saver.icon
+
+ Fill {
+ item: saver
+ }
+ },
+ State {
+ name: balance.icon
+
+ Fill {
+ item: balance
+ }
+ },
+ State {
+ name: perf.icon
+
+ Fill {
+ item: perf
+ }
+ }
+ ]
+
+ transitions: Transition {
+ AnchorAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+ }
+
+ Profile {
+ id: saver
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: parent.left
+ anchors.leftMargin: Appearance.padding.small
+
+ profile: PowerProfile.PowerSaver
+ icon: "energy_savings_leaf"
+ }
+
+ Profile {
+ id: balance
+
+ anchors.centerIn: parent
+
+ profile: PowerProfile.Balanced
+ icon: "balance"
+ }
+
+ Profile {
+ id: perf
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.right: parent.right
+ anchors.rightMargin: Appearance.padding.small
+
+ profile: PowerProfile.Performance
+ icon: "rocket_launch"
+ }
+ }
+
+ component Fill: AnchorChanges {
+ required property Item item
+
+ target: indicator
+ anchors.left: item.left
+ anchors.right: item.right
+ anchors.top: item.top
+ anchors.bottom: item.bottom
+ }
+
+ component Profile: Item {
+ required property string icon
+ required property int profile
+
+ implicitWidth: icon.implicitHeight + Appearance.padding.small * 2
+ implicitHeight: icon.implicitHeight + Appearance.padding.small * 2
+
+ StateLayer {
+ radius: Appearance.rounding.full
+ color: profiles.current === parent.icon ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface
+
+ function onClicked(): void {
+ PowerProfiles.profile = parent.profile;
+ }
+ }
+
+ MaterialIcon {
+ id: icon
+
+ anchors.centerIn: parent
+
+ text: parent.icon
+ font.pointSize: Appearance.font.size.large
+ color: profiles.current === text ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface
+ fill: profiles.current === text ? 1 : 0
+
+ Behavior on fill {
+ NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+ }
+ }
+}
diff --git a/modules/bar/popouts/Bluetooth.qml b/modules/bar/popouts/Bluetooth.qml
new file mode 100644
index 0000000..ad7d087
--- /dev/null
+++ b/modules/bar/popouts/Bluetooth.qml
@@ -0,0 +1,18 @@
+import "root:/widgets"
+import "root:/services"
+import "root:/config"
+import QtQuick
+
+Column {
+ id: root
+
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ text: qsTr("Bluetooth %1").arg(Bluetooth.powered ? "enabled" : "disabled")
+ }
+
+ StyledText {
+ text: Bluetooth.devices.some(d => d.connected) ? qsTr("Connected to: %1").arg(Bluetooth.devices.filter(d => d.connected).map(d => d.alias).join(", ")) : qsTr("No devices connected")
+ }
+}
diff --git a/modules/bar/popouts/Content.qml b/modules/bar/popouts/Content.qml
new file mode 100644
index 0000000..43a0a09
--- /dev/null
+++ b/modules/bar/popouts/Content.qml
@@ -0,0 +1,175 @@
+pragma ComponentBehavior: Bound
+
+import "root:/services"
+import "root:/config"
+import Quickshell
+import Quickshell.Services.SystemTray
+import QtQuick
+
+Item {
+ id: root
+
+ required property ShellScreen screen
+
+ property string currentName
+ property real currentCenter
+ property bool hasCurrent
+
+ anchors.centerIn: parent
+
+ implicitWidth: hasCurrent ? (content.children.find(c => c.shouldBeActive)?.implicitWidth ?? 0) + Appearance.padding.large * 2 : 0
+ implicitHeight: (content.children.find(c => c.shouldBeActive)?.implicitHeight ?? 0) + Appearance.padding.large * 2
+
+ Item {
+ id: content
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.large
+
+ clip: true
+
+ Popout {
+ name: "activewindow"
+ source: "ActiveWindow.qml"
+ }
+
+ Popout {
+ name: "network"
+ source: "Network.qml"
+ }
+
+ Popout {
+ name: "bluetooth"
+ source: "Bluetooth.qml"
+ }
+
+ Popout {
+ name: "battery"
+ source: "Battery.qml"
+ }
+
+ Repeater {
+ model: ScriptModel {
+ values: [...SystemTray.items.values]
+ }
+
+ Popout {
+ id: trayMenu
+
+ required property SystemTrayItem modelData
+ required property int index
+
+ name: `traymenu${index}`
+ sourceComponent: trayMenuComp
+
+ Connections {
+ target: root
+
+ function onHasCurrentChanged(): void {
+ if (root.hasCurrent && trayMenu.shouldBeActive) {
+ trayMenu.sourceComponent = null;
+ trayMenu.sourceComponent = trayMenuComp;
+ }
+ }
+ }
+
+ Component {
+ id: trayMenuComp
+
+ TrayMenu {
+ popouts: root
+ trayItem: trayMenu.modelData.menu
+ }
+ }
+ }
+ }
+ }
+
+ Behavior on implicitWidth {
+ Anim {
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+
+ Behavior on implicitHeight {
+ enabled: root.implicitWidth > 0
+
+ Anim {
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+
+ Behavior on currentCenter {
+ enabled: root.implicitWidth > 0
+
+ NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+
+ component Popout: Loader {
+ id: popout
+
+ required property string name
+ property bool shouldBeActive: root.currentName === name
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.right: parent.right
+
+ opacity: 0
+ scale: 0.8
+ active: false
+ asynchronous: true
+
+ states: State {
+ name: "active"
+ when: popout.shouldBeActive
+
+ PropertyChanges {
+ popout.active: true
+ popout.opacity: 1
+ popout.scale: 1
+ }
+ }
+
+ transitions: [
+ Transition {
+ from: "active"
+ to: ""
+
+ SequentialAnimation {
+ Anim {
+ properties: "opacity,scale"
+ duration: Appearance.anim.durations.small
+ }
+ PropertyAction {
+ target: popout
+ property: "active"
+ }
+ }
+ },
+ Transition {
+ from: ""
+ to: "active"
+
+ SequentialAnimation {
+ PropertyAction {
+ target: popout
+ property: "active"
+ }
+ Anim {
+ properties: "opacity,scale"
+ }
+ }
+ }
+ ]
+ }
+
+ component Anim: NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+}
diff --git a/modules/bar/popouts/Network.qml b/modules/bar/popouts/Network.qml
new file mode 100644
index 0000000..7a79ef1
--- /dev/null
+++ b/modules/bar/popouts/Network.qml
@@ -0,0 +1,22 @@
+import "root:/widgets"
+import "root:/services"
+import "root:/config"
+import QtQuick
+
+Column {
+ id: root
+
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ text: qsTr("Connected to: %1").arg(Network.active?.ssid ?? "None")
+ }
+
+ StyledText {
+ text: qsTr("Strength: %1/100").arg(Network.active?.strength ?? 0)
+ }
+
+ StyledText {
+ text: qsTr("Frequency: %1 MHz").arg(Network.active?.frequency ?? 0)
+ }
+}
diff --git a/modules/bar/popouts/TrayMenu.qml b/modules/bar/popouts/TrayMenu.qml
new file mode 100644
index 0000000..d69bf38
--- /dev/null
+++ b/modules/bar/popouts/TrayMenu.qml
@@ -0,0 +1,237 @@
+pragma ComponentBehavior: Bound
+
+import "root:/widgets"
+import "root:/services"
+import "root:/config"
+import Quickshell
+import Quickshell.Widgets
+import QtQuick
+import QtQuick.Controls
+
+StackView {
+ id: root
+
+ required property Item popouts
+ required property QsMenuHandle trayItem
+
+ implicitWidth: currentItem.implicitWidth
+ implicitHeight: currentItem.implicitHeight
+
+ initialItem: SubMenu {
+ handle: root.trayItem
+ }
+
+ pushEnter: Anim {}
+ pushExit: Anim {}
+ popEnter: Anim {}
+ popExit: Anim {}
+
+ component Anim: Transition {
+ NumberAnimation {
+ duration: 0
+ }
+ }
+
+ component SubMenu: Column {
+ id: menu
+
+ required property QsMenuHandle handle
+ property bool isSubMenu
+ property bool shown
+
+ padding: Appearance.padding.smaller
+ spacing: Appearance.spacing.small
+
+ opacity: shown ? 1 : 0
+ scale: shown ? 1 : 0.8
+
+ Component.onCompleted: shown = true
+ StackView.onActivating: shown = true
+ StackView.onDeactivating: shown = false
+ StackView.onRemoved: destroy()
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+
+ Behavior on scale {
+ NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+
+ QsMenuOpener {
+ id: menuOpener
+
+ menu: menu.handle
+ }
+
+ Repeater {
+ model: menuOpener.children
+
+ StyledRect {
+ id: item
+
+ required property QsMenuEntry modelData
+
+ implicitWidth: BarConfig.sizes.trayMenuWidth
+ implicitHeight: modelData.isSeparator ? 1 : children.implicitHeight
+
+ radius: Appearance.rounding.full
+ color: modelData.isSeparator ? Colours.palette.m3outlineVariant : "transparent"
+
+ Loader {
+ id: children
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+
+ active: !item.modelData.isSeparator
+ asynchronous: true
+
+ sourceComponent: Item {
+ implicitHeight: label.implicitHeight
+
+ StateLayer {
+ anchors.margins: -Appearance.padding.small / 2
+ anchors.leftMargin: -Appearance.padding.smaller
+ anchors.rightMargin: -Appearance.padding.smaller
+
+ radius: item.radius
+ disabled: !item.modelData.enabled
+
+ function onClicked(): void {
+ const entry = item.modelData;
+ if (entry.hasChildren)
+ root.push(subMenuComp.createObject(null, {
+ handle: entry,
+ isSubMenu: true
+ }));
+ else {
+ item.modelData.triggered();
+ root.popouts.hasCurrent = false;
+ }
+ }
+ }
+
+ Loader {
+ id: icon
+
+ anchors.left: parent.left
+
+ active: item.modelData.icon !== ""
+ asynchronous: true
+
+ sourceComponent: IconImage {
+ implicitSize: label.implicitHeight
+
+ source: item.modelData.icon
+ }
+ }
+
+ StyledText {
+ id: label
+
+ anchors.left: icon.right
+ anchors.leftMargin: icon.active ? Appearance.spacing.smaller : 0
+
+ text: labelMetrics.elidedText
+ color: item.modelData.enabled ? Colours.palette.m3onSurface : Colours.palette.m3outline
+ }
+
+ TextMetrics {
+ id: labelMetrics
+
+ text: item.modelData.text
+ font.pointSize: label.font.pointSize
+ font.family: label.font.family
+
+ elide: Text.ElideRight
+ elideWidth: BarConfig.sizes.trayMenuWidth - (icon.active ? icon.implicitWidth + label.anchors.leftMargin : 0) - (expand.active ? expand.implicitWidth + Appearance.spacing.normal : 0)
+ }
+
+ Loader {
+ id: expand
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.right: parent.right
+
+ active: item.modelData.hasChildren
+ asynchronous: true
+
+ sourceComponent: MaterialIcon {
+ text: "chevron_right"
+ color: item.modelData.enabled ? Colours.palette.m3onSurface : Colours.palette.m3outline
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Loader {
+ active: menu.isSubMenu
+ asynchronous: true
+
+ sourceComponent: Item {
+ implicitWidth: back.implicitWidth
+ implicitHeight: back.implicitHeight + Appearance.spacing.small / 2
+
+ Item {
+ anchors.bottom: parent.bottom
+ implicitWidth: back.implicitWidth
+ implicitHeight: back.implicitHeight
+
+ StyledRect {
+ anchors.fill: parent
+ anchors.margins: -Appearance.padding.small / 2
+ anchors.leftMargin: -Appearance.padding.smaller
+ anchors.rightMargin: -Appearance.padding.smaller * 2
+
+ radius: Appearance.rounding.full
+ color: Colours.palette.m3secondaryContainer
+
+ StateLayer {
+ radius: parent.radius
+ color: Colours.palette.m3onSecondaryContainer
+
+ function onClicked(): void {
+ root.pop();
+ }
+ }
+ }
+
+ Row {
+ id: back
+
+ anchors.verticalCenter: parent.verticalCenter
+
+ MaterialIcon {
+ anchors.verticalCenter: parent.verticalCenter
+ text: "chevron_left"
+ color: Colours.palette.m3onSecondaryContainer
+ }
+
+ StyledText {
+ anchors.verticalCenter: parent.verticalCenter
+ text: qsTr("Back")
+ color: Colours.palette.m3onSecondaryContainer
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Component {
+ id: subMenuComp
+
+ SubMenu {}
+ }
+}
diff --git a/modules/bar/popouts/Wrapper.qml b/modules/bar/popouts/Wrapper.qml
new file mode 100644
index 0000000..f304ea7
--- /dev/null
+++ b/modules/bar/popouts/Wrapper.qml
@@ -0,0 +1,25 @@
+import "root:/services"
+import "root:/config"
+import Quickshell
+import QtQuick
+
+Item {
+ id: root
+
+ required property ShellScreen screen
+
+ property alias currentName: content.currentName
+ property alias currentCenter: content.currentCenter
+ property alias hasCurrent: content.hasCurrent
+
+ visible: width > 0 && height > 0
+
+ implicitWidth: content.implicitWidth
+ implicitHeight: content.implicitHeight
+
+ Content {
+ id: content
+
+ screen: root.screen
+ }
+}
diff --git a/modules/dashboard/Background.qml b/modules/dashboard/Background.qml
new file mode 100644
index 0000000..ebc60c8
--- /dev/null
+++ b/modules/dashboard/Background.qml
@@ -0,0 +1,69 @@
+import QtQuick
+import QtQuick.Shapes
+import "root:/config"
+import "root:/services"
+
+ShapePath {
+ id: root
+
+ required property Wrapper wrapper
+ readonly property real rounding: BorderConfig.rounding
+ readonly property bool flatten: wrapper.height < rounding * 2
+ readonly property real roundingY: flatten ? wrapper.height / 2 : rounding
+
+ strokeWidth: -1
+ fillColor: BorderConfig.colour
+
+ PathArc {
+ relativeX: root.rounding
+ relativeY: root.roundingY
+ radiusX: root.rounding
+ radiusY: Math.min(root.rounding, root.wrapper.height)
+ }
+
+ PathLine {
+ relativeX: 0
+ relativeY: root.wrapper.height - root.roundingY * 2
+ }
+
+ PathArc {
+ relativeX: root.rounding
+ relativeY: root.roundingY
+ radiusX: root.rounding
+ radiusY: Math.min(root.rounding, root.wrapper.height)
+ direction: PathArc.Counterclockwise
+ }
+
+ PathLine {
+ relativeX: root.wrapper.width - root.rounding * 2
+ relativeY: 0
+ }
+
+ PathArc {
+ relativeX: root.rounding
+ relativeY: -root.roundingY
+ radiusX: root.rounding
+ radiusY: Math.min(root.rounding, root.wrapper.height)
+ direction: PathArc.Counterclockwise
+ }
+
+ PathLine {
+ relativeX: 0
+ relativeY: -(root.wrapper.height - root.roundingY * 2)
+ }
+
+ PathArc {
+ relativeX: root.rounding
+ relativeY: -root.roundingY
+ radiusX: root.rounding
+ radiusY: Math.min(root.rounding, root.wrapper.height)
+ }
+
+ Behavior on fillColor {
+ ColorAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+}
diff --git a/modules/dashboard/Content.qml b/modules/dashboard/Content.qml
new file mode 100644
index 0000000..84079ef
--- /dev/null
+++ b/modules/dashboard/Content.qml
@@ -0,0 +1,123 @@
+import "root:/widgets"
+import "root:/services"
+import "root:/config"
+import Quickshell
+import Quickshell.Widgets
+import QtQuick
+
+Item {
+ id: root
+
+ required property PersistentProperties visibilities
+ readonly property real nonAnimWidth: view.implicitWidth + viewWrapper.anchors.margins * 2
+
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.bottom: parent.bottom
+
+ implicitWidth: nonAnimWidth
+ implicitHeight: tabs.implicitHeight + tabs.anchors.topMargin + view.implicitHeight + viewWrapper.anchors.margins * 2
+
+ Tabs {
+ id: tabs
+
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.topMargin: Appearance.padding.normal
+ anchors.margins: Appearance.padding.large
+
+ nonAnimWidth: root.nonAnimWidth
+ currentIndex: view.currentIndex
+ }
+
+ ClippingRectangle {
+ id: viewWrapper
+
+ anchors.top: tabs.bottom
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ anchors.margins: Appearance.padding.large
+
+ radius: Appearance.rounding.normal
+ color: "transparent"
+
+ Flickable {
+ id: view
+
+ readonly property int currentIndex: tabs.currentIndex
+ readonly property Item currentItem: row.children[currentIndex]
+
+ anchors.fill: parent
+
+ flickableDirection: Flickable.HorizontalFlick
+
+ implicitWidth: currentItem.implicitWidth
+ implicitHeight: currentItem.implicitHeight
+
+ contentX: currentItem.x
+ contentWidth: row.implicitWidth
+ contentHeight: row.implicitHeight
+
+ onContentXChanged: {
+ if (!moving)
+ return;
+
+ const x = contentX - currentItem.x;
+ if (x > currentItem.implicitWidth / 2)
+ tabs.bar.incrementCurrentIndex();
+ else if (x < -currentItem.implicitWidth / 2)
+ tabs.bar.decrementCurrentIndex();
+ }
+
+ onDragEnded: {
+ const x = contentX - currentItem.x;
+ if (x > currentItem.implicitWidth / 10)
+ tabs.bar.incrementCurrentIndex();
+ else if (x < -currentItem.implicitWidth / 10)
+ tabs.bar.decrementCurrentIndex();
+ else
+ contentX = Qt.binding(() => currentItem.x);
+ }
+
+ Row {
+ id: row
+
+ Dash {
+ shouldUpdate: visible && this === view.currentItem
+ }
+
+ Media {
+ shouldUpdate: visible && this === view.currentItem
+ visibilities: root.visibilities
+ }
+
+ Performance {}
+ }
+
+ Behavior on contentX {
+ NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+ }
+ }
+
+ Behavior on implicitWidth {
+ NumberAnimation {
+ duration: Appearance.anim.durations.large
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+
+ Behavior on implicitHeight {
+ NumberAnimation {
+ duration: Appearance.anim.durations.large
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+}
diff --git a/modules/dashboard/Dash.qml b/modules/dashboard/Dash.qml
new file mode 100644
index 0000000..eaf4344
--- /dev/null
+++ b/modules/dashboard/Dash.qml
@@ -0,0 +1,86 @@
+import "root:/widgets"
+import "root:/services"
+import "root:/config"
+import "dash"
+import QtQuick.Layouts
+
+GridLayout {
+ id: root
+
+ required property bool shouldUpdate
+
+ rowSpacing: Appearance.spacing.normal
+ columnSpacing: Appearance.spacing.normal
+
+ Rect {
+ Layout.column: 2
+ Layout.columnSpan: 3
+ Layout.preferredWidth: user.implicitWidth
+ Layout.preferredHeight: user.implicitHeight
+
+ User {
+ id: user
+ }
+ }
+
+ Rect {
+ Layout.row: 0
+ Layout.columnSpan: 2
+ Layout.preferredWidth: DashboardConfig.sizes.weatherWidth
+ Layout.fillHeight: true
+
+ Weather {}
+ }
+
+ Rect {
+ Layout.row: 1
+ Layout.preferredWidth: dateTime.implicitWidth
+ Layout.fillHeight: true
+
+ DateTime {
+ id: dateTime
+ }
+ }
+
+ Rect {
+ Layout.row: 1
+ Layout.column: 1
+ Layout.columnSpan: 3
+ Layout.fillWidth: true
+ Layout.preferredHeight: calendar.implicitHeight
+
+ Calendar {
+ id: calendar
+ }
+ }
+
+ Rect {
+ Layout.row: 1
+ Layout.column: 4
+ Layout.preferredWidth: resources.implicitWidth
+ Layout.fillHeight: true
+
+ Resources {
+ id: resources
+ }
+ }
+
+ Rect {
+ Layout.row: 0
+ Layout.column: 5
+ Layout.rowSpan: 2
+ Layout.preferredWidth: media.implicitWidth
+ Layout.fillHeight: true
+
+ Media {
+ id: media
+
+ shouldUpdate: root.shouldUpdate
+ }
+ }
+
+ component Rect: StyledRect {
+ radius: Appearance.rounding.small
+ color: Colours.palette.m3surfaceContainer
+ }
+}
diff --git a/modules/dashboard/Media.qml b/modules/dashboard/Media.qml
new file mode 100644
index 0000000..43ef597
--- /dev/null
+++ b/modules/dashboard/Media.qml
@@ -0,0 +1,594 @@
+pragma ComponentBehavior: Bound
+
+import "root:/widgets"
+import "root:/services"
+import "root:/utils"
+import "root:/config"
+import Quickshell
+import Quickshell.Widgets
+import Quickshell.Services.Mpris
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Effects
+
+Item {
+ id: root
+
+ required property bool shouldUpdate
+ required property PersistentProperties visibilities
+
+ property real playerProgress: {
+ const active = Players.active;
+ return active?.length ? active.position / active.length : 0;
+ }
+
+ function lengthStr(length: int): string {
+ if (length < 0)
+ return "-1:-1";
+ return `${Math.floor(length / 60)}:${Math.floor(length % 60).toString().padStart(2, "0")}`;
+ }
+
+ implicitWidth: cover.implicitWidth + DashboardConfig.sizes.mediaVisualiserSize * 2 + details.implicitWidth + details.anchors.leftMargin + bongocat.implicitWidth + bongocat.anchors.leftMargin * 2 + Appearance.padding.large * 2
+ implicitHeight: Math.max(cover.implicitHeight + DashboardConfig.sizes.mediaVisualiserSize * 2, details.implicitHeight, bongocat.implicitHeight) + Appearance.padding.large * 2
+
+ Behavior on playerProgress {
+ NumberAnimation {
+ duration: Appearance.anim.durations.large
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+
+ Timer {
+ running: root.shouldUpdate && (Players.active?.isPlaying ?? false)
+ interval: DashboardConfig.mediaUpdateInterval
+ triggeredOnStart: true
+ repeat: true
+ onTriggered: Players.active?.positionChanged()
+ }
+
+ Connections {
+ target: Cava
+
+ function onValuesChanged(): void {
+ if (root.shouldUpdate)
+ visualiser.requestPaint();
+ }
+ }
+
+ Canvas {
+ id: visualiser
+
+ readonly property real centerX: width / 2
+ readonly property real centerY: height / 2
+ readonly property real innerX: cover.implicitWidth / 2 + Appearance.spacing.small
+ readonly property real innerY: cover.implicitHeight / 2 + Appearance.spacing.small
+ property color colour: Colours.palette.m3primary
+
+ anchors.fill: cover
+ anchors.margins: -DashboardConfig.sizes.mediaVisualiserSize
+
+ onColourChanged: requestPaint()
+
+ onPaint: {
+ const ctx = getContext("2d");
+ ctx.reset();
+
+ const values = Cava.values;
+ const len = values.length;
+
+ ctx.strokeStyle = colour;
+ ctx.lineWidth = 360 / len - Appearance.spacing.small / 4;
+ ctx.lineCap = "round";
+
+ const size = DashboardConfig.sizes.mediaVisualiserSize;
+ const cx = centerX;
+ const cy = centerY;
+ const rx = innerX + ctx.lineWidth / 2;
+ const ry = innerY + ctx.lineWidth / 2;
+
+ for (let i = 0; i < len; i++) {
+ const v = Math.max(1, Math.min(100, values[i]));
+
+ const angle = i * 2 * Math.PI / len;
+ const magnitude = v / 100 * size;
+ const cos = Math.cos(angle);
+ const sin = Math.sin(angle);
+
+ ctx.moveTo(cx + rx * cos, cy + ry * sin);
+ ctx.lineTo(cx + (rx + magnitude) * cos, cy + (ry + magnitude) * sin);
+ }
+
+ ctx.stroke();
+ }
+
+ Behavior on colour {
+ ColorAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+ }
+
+ StyledClippingRect {
+ id: cover
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: parent.left
+ anchors.leftMargin: Appearance.padding.large + DashboardConfig.sizes.mediaVisualiserSize
+
+ implicitWidth: DashboardConfig.sizes.mediaCoverArtSize
+ implicitHeight: DashboardConfig.sizes.mediaCoverArtSize
+
+ color: Colours.palette.m3surfaceContainerHigh
+ radius: Appearance.rounding.full
+
+ MaterialIcon {
+ anchors.centerIn: parent
+
+ text: "art_track"
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: (parent.width * 0.4) || 1
+ }
+
+ Image {
+ id: image
+
+ anchors.fill: parent
+
+ source: Players.active?.trackArtUrl ?? ""
+ asynchronous: true
+ fillMode: Image.PreserveAspectCrop
+ sourceSize.width: width
+ sourceSize.height: height
+ }
+ }
+
+ Column {
+ id: details
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: visualiser.right
+ anchors.leftMargin: Appearance.spacing.normal
+
+ spacing: Appearance.spacing.small
+
+ StyledText {
+ id: title
+
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ animate: true
+ horizontalAlignment: Text.AlignHCenter
+ text: (Players.active?.trackTitle ?? qsTr("No media")) || qsTr("Unknown title")
+ color: Colours.palette.m3primary
+ font.pointSize: Appearance.font.size.normal
+
+ width: parent.implicitWidth
+ elide: Text.ElideRight
+ }
+
+ StyledText {
+ id: album
+
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ animate: true
+ horizontalAlignment: Text.AlignHCenter
+ text: (Players.active?.trackAlbum ?? qsTr("No media")) || qsTr("Unknown album")
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+
+ width: parent.implicitWidth
+ elide: Text.ElideRight
+ }
+
+ StyledText {
+ id: artist
+
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ animate: true
+ horizontalAlignment: Text.AlignHCenter
+ text: (Players.active?.trackArtist ?? qsTr("No media")) || qsTr("Unknown artist")
+ color: Colours.palette.m3secondary
+
+ width: parent.implicitWidth
+ elide: Text.ElideRight
+ }
+
+ Row {
+ id: controls
+
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ spacing: Appearance.spacing.small
+
+ Control {
+ icon: "skip_previous"
+ canUse: Players.active?.canGoPrevious ?? false
+
+ function onClicked(): void {
+ Players.active?.previous();
+ }
+ }
+
+ Control {
+ icon: Players.active?.isPlaying ? "pause" : "play_arrow"
+ canUse: Players.active?.canTogglePlaying ?? false
+ primary: true
+
+ function onClicked(): void {
+ Players.active?.togglePlaying();
+ }
+ }
+
+ Control {
+ icon: "skip_next"
+ canUse: Players.active?.canGoNext ?? false
+
+ function onClicked(): void {
+ Players.active?.next();
+ }
+ }
+ }
+
+ Slider {
+ id: slider
+
+ implicitWidth: controls.implicitWidth * 1.5
+ implicitHeight: Appearance.padding.normal * 3
+
+ value: root.playerProgress
+ onMoved: {
+ const active = Players.active;
+ if (active?.canSeek && active?.positionSupported)
+ active.position = value * active.length;
+ }
+
+ background: Item {
+ StyledRect {
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ anchors.left: parent.left
+ anchors.topMargin: slider.implicitHeight / 3
+ anchors.bottomMargin: slider.implicitHeight / 3
+
+ implicitWidth: slider.handle.x - slider.implicitHeight / 6
+
+ color: Colours.palette.m3primary
+ radius: Appearance.rounding.full
+ topRightRadius: slider.implicitHeight / 15
+ bottomRightRadius: slider.implicitHeight / 15
+ }
+
+ StyledRect {
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ anchors.right: parent.right
+ anchors.topMargin: slider.implicitHeight / 3
+ anchors.bottomMargin: slider.implicitHeight / 3
+
+ implicitWidth: parent.width - slider.handle.x - slider.handle.implicitWidth - slider.implicitHeight / 6
+
+ color: Colours.palette.m3surfaceContainer
+ radius: Appearance.rounding.full
+ topLeftRadius: slider.implicitHeight / 15
+ bottomLeftRadius: slider.implicitHeight / 15
+ }
+ }
+
+ handle: StyledRect {
+ id: rect
+
+ x: slider.visualPosition * slider.availableWidth
+
+ implicitWidth: slider.implicitHeight / 4.5
+ implicitHeight: slider.implicitHeight
+
+ color: Colours.palette.m3primary
+ radius: Appearance.rounding.full
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onPressed: event => event.accepted = false
+ }
+ }
+ }
+
+ Item {
+ anchors.left: parent.left
+ anchors.right: parent.right
+
+ implicitHeight: Math.max(position.implicitHeight, length.implicitHeight)
+
+ StyledText {
+ id: position
+
+ anchors.left: parent.left
+
+ text: root.lengthStr(Players.active?.position ?? -1)
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.small
+ }
+
+ StyledText {
+ id: length
+
+ anchors.right: parent.right
+
+ text: root.lengthStr(Players.active?.length ?? -1)
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.small
+ }
+ }
+
+ Row {
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ spacing: Appearance.spacing.small
+
+ Control {
+ icon: "flip_to_front"
+ canUse: Players.active?.canRaise ?? false
+ fontSize: Appearance.font.size.larger
+ padding: Appearance.padding.small
+ fill: false
+ color: Colours.palette.m3surfaceContainer
+
+ function onClicked(): void {
+ Players.active?.raise();
+ root.visibilities.dashboard = false;
+ }
+ }
+
+ MouseArea {
+ id: playerSelector
+
+ property bool expanded
+
+ anchors.verticalCenter: parent.verticalCenter
+
+ implicitWidth: slider.implicitWidth / 2
+ implicitHeight: currentPlayer.implicitHeight + Appearance.padding.small * 2
+
+ cursorShape: Qt.PointingHandCursor
+ onClicked: expanded = !expanded
+
+ RectangularShadow {
+ anchors.fill: playerSelectorBg
+
+ opacity: playerSelector.expanded ? 1 : 0
+ radius: playerSelectorBg.radius
+ color: Colours.palette.m3shadow
+ blur: 5
+ spread: 0
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+ }
+
+ StyledRect {
+ id: playerSelectorBg
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+
+ implicitHeight: playersWrapper.implicitHeight + Appearance.padding.small * 2
+
+ color: Colours.palette.m3secondaryContainer
+ radius: Appearance.rounding.normal
+
+ Item {
+ id: playersWrapper
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ anchors.margins: Appearance.padding.small
+
+ clip: true
+ implicitHeight: playerSelector.expanded && Players.list.length > 1 ? players.implicitHeight : currentPlayer.implicitHeight
+
+ Column {
+ id: players
+
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.bottom: parent.bottom
+
+ spacing: Appearance.spacing.small
+
+ Repeater {
+ model: Players.list.filter(p => p !== Players.active)
+
+ Row {
+ id: player
+
+ required property MprisPlayer modelData
+
+ anchors.horizontalCenter: parent.horizontalCenter
+ spacing: Appearance.spacing.small
+
+ IconImage {
+ id: playerIcon
+
+ source: Icons.getAppIcon(player.modelData.identity, "image-missing")
+ implicitSize: Math.round(identity.implicitHeight * 0.9)
+ }
+
+ StyledText {
+ id: identity
+
+ text: identityMetrics.elidedText
+ color: Colours.palette.m3onSecondaryContainer
+
+ TextMetrics {
+ id: identityMetrics
+
+ text: player.modelData.identity
+ font.family: identity.font.family
+ font.pointSize: identity.font.pointSize
+ elide: Text.ElideRight
+ elideWidth: playerSelector.implicitWidth - playerIcon.implicitWidth - player.spacing - Appearance.padding.smaller * 2
+ }
+
+ MouseArea {
+
+ anchors.fill: parent
+
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ Players.manualActive = player.modelData;
+ playerSelector.expanded = false;
+ }
+ }
+ }
+ }
+ }
+
+ Item {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ implicitHeight: 1
+
+ StyledRect {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.margins: -Appearance.padding.normal
+ color: Colours.palette.m3secondary
+ implicitHeight: 1
+ }
+ }
+
+ Row {
+ id: currentPlayer
+
+ anchors.horizontalCenter: parent.horizontalCenter
+ spacing: Appearance.spacing.small
+
+ IconImage {
+ id: currentIcon
+
+ source: Icons.getAppIcon(Players.active?.identity ?? "", "multimedia-player")
+ implicitSize: Math.round(currentIdentity.implicitHeight * 0.9)
+ }
+
+ StyledText {
+ id: currentIdentity
+
+ animate: true
+ text: currentIdentityMetrics.elidedText
+ color: Colours.palette.m3onSecondaryContainer
+
+ TextMetrics {
+ id: currentIdentityMetrics
+
+ text: Players.active?.identity ?? "No players"
+ font.family: currentIdentity.font.family
+ font.pointSize: currentIdentity.font.pointSize
+ elide: Text.ElideRight
+ elideWidth: playerSelector.implicitWidth - currentIcon.implicitWidth - currentPlayer.spacing - Appearance.padding.smaller * 2
+ }
+ }
+ }
+ }
+
+ Behavior on implicitHeight {
+ NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+ }
+ }
+ }
+
+ Control {
+ icon: "delete"
+ canUse: Players.active?.canQuit ?? false
+ fontSize: Appearance.font.size.larger
+ padding: Appearance.padding.small
+ fill: false
+ color: Colours.palette.m3surfaceContainer
+
+ function onClicked(): void {
+ Players.active?.quit();
+ }
+ }
+ }
+ }
+
+ Item {
+ id: bongocat
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: details.right
+ anchors.leftMargin: Appearance.spacing.normal
+
+ implicitWidth: visualiser.width
+ implicitHeight: visualiser.height
+
+ AnimatedImage {
+ anchors.centerIn: parent
+
+ width: visualiser.width * 0.75
+ height: visualiser.height * 0.75
+
+ playing: root.shouldUpdate && (Players.active?.isPlaying ?? false)
+ speed: BeatDetector.bpm / 300
+ source: "root:/assets/bongocat.gif"
+ asynchronous: true
+ fillMode: AnimatedImage.PreserveAspectFit
+ }
+ }
+ component Control: StyledRect {
+ id: control
+
+ required property string icon
+ required property bool canUse
+ property int fontSize: Appearance.font.size.extraLarge
+ property int padding
+ property bool fill: true
+ property bool primary
+ function onClicked(): void {
+ }
+
+ implicitWidth: Math.max(icon.implicitWidth, icon.implicitHeight) + padding * 2
+ implicitHeight: implicitWidth
+
+ radius: Appearance.rounding.full
+ color: primary && canUse ? Colours.palette.m3primary : "transparent"
+
+ StateLayer {
+ disabled: !control.canUse
+ radius: parent.radius
+ color: control.primary ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface
+
+ function onClicked(): void {
+ control.onClicked();
+ }
+ }
+
+ MaterialIcon {
+ id: icon
+
+ anchors.centerIn: parent
+ anchors.verticalCenterOffset: font.pointSize * 0.05
+
+ animate: true
+ fill: control.fill ? 1 : 0
+ text: control.icon
+ color: control.canUse ? control.primary ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface : Colours.palette.m3outline
+ font.pointSize: control.fontSize
+ }
+ }
+}
diff --git a/modules/dashboard/Performance.qml b/modules/dashboard/Performance.qml
new file mode 100644
index 0000000..8513d19
--- /dev/null
+++ b/modules/dashboard/Performance.qml
@@ -0,0 +1,230 @@
+import "root:/widgets"
+import "root:/services"
+import "root:/config"
+import QtQuick
+
+Row {
+ id: root
+
+ spacing: Appearance.spacing.large * 3
+ padding: Appearance.padding.large
+ leftPadding: padding * 2
+ rightPadding: padding * 3
+
+ Resource {
+ value1: Math.min(1, SystemUsage.gpuTemp / 90)
+ value2: SystemUsage.gpuPerc
+
+ label1: `${Math.ceil(SystemUsage.gpuTemp)}°C`
+ label2: `${Math.round(SystemUsage.gpuPerc * 100)}%`
+
+ sublabel1: qsTr("GPU temp")
+ sublabel2: qsTr("Usage")
+ }
+
+ Resource {
+ primary: true
+
+ value1: Math.min(1, SystemUsage.cpuTemp / 90)
+ value2: SystemUsage.cpuPerc
+
+ label1: `${Math.ceil(SystemUsage.cpuTemp)}°C`
+ label2: `${Math.round(SystemUsage.cpuPerc * 100)}%`
+
+ sublabel1: qsTr("CPU temp")
+ sublabel2: qsTr("Usage")
+ }
+
+ Resource {
+ value1: SystemUsage.memPerc
+ value2: SystemUsage.storagePerc
+
+ label1: {
+ const fmt = SystemUsage.formatKib(SystemUsage.memUsed);
+ return `${+fmt.value.toFixed(1)}${fmt.unit}`;
+ }
+ label2: {
+ const fmt = SystemUsage.formatKib(SystemUsage.storageUsed);
+ return `${Math.floor(fmt.value)}${fmt.unit}`;
+ }
+
+ sublabel1: qsTr("Memory")
+ sublabel2: qsTr("Storage")
+ }
+
+ component Resource: Item {
+ id: res
+
+ required property real value1
+ required property real value2
+ required property string sublabel1
+ required property string sublabel2
+ required property string label1
+ required property string label2
+
+ property bool primary
+ readonly property real primaryMult: primary ? 1.2 : 1
+
+ readonly property real thickness: DashboardConfig.sizes.resourceProgessThickness * primaryMult
+
+ property color fg1: Colours.palette.m3primary
+ property color fg2: Colours.palette.m3secondary
+ property color bg1: Colours.palette.m3primaryContainer
+ property color bg2: Colours.palette.m3secondaryContainer
+
+ anchors.verticalCenter: parent.verticalCenter
+
+ implicitWidth: DashboardConfig.sizes.resourceSize * primaryMult
+ implicitHeight: DashboardConfig.sizes.resourceSize * primaryMult
+
+ onValue1Changed: canvas.requestPaint()
+ onValue2Changed: canvas.requestPaint()
+ onFg1Changed: canvas.requestPaint()
+ onFg2Changed: canvas.requestPaint()
+ onBg1Changed: canvas.requestPaint()
+ onBg2Changed: canvas.requestPaint()
+
+ Column {
+ anchors.centerIn: parent
+
+ StyledText {
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ text: res.label1
+ font.pointSize: Appearance.font.size.extraLarge * res.primaryMult
+ }
+
+ StyledText {
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ text: res.sublabel1
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.smaller * res.primaryMult
+ }
+ }
+
+ Column {
+ anchors.horizontalCenter: parent.right
+ anchors.top: parent.verticalCenter
+ anchors.horizontalCenterOffset: -res.thickness / 2
+ anchors.topMargin: res.thickness / 2 + Appearance.spacing.small
+
+ StyledText {
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ text: res.label2
+ font.pointSize: Appearance.font.size.smaller * res.primaryMult
+ }
+
+ StyledText {
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ text: res.sublabel2
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.small * res.primaryMult
+ }
+ }
+
+ Canvas {
+ id: canvas
+
+ readonly property real centerX: width / 2
+ readonly property real centerY: height / 2
+
+ readonly property real arc1Start: degToRad(45)
+ readonly property real arc1End: degToRad(220)
+ readonly property real arc2Start: degToRad(230)
+ readonly property real arc2End: degToRad(360)
+
+ function degToRad(deg: int): real {
+ return deg * Math.PI / 180;
+ }
+
+ anchors.fill: parent
+
+ onPaint: {
+ const ctx = getContext("2d");
+ ctx.reset();
+
+ ctx.lineWidth = res.thickness;
+ ctx.lineCap = "round";
+
+ const radius = (Math.min(width, height) - ctx.lineWidth) / 2;
+ const cx = centerX;
+ const cy = centerY;
+ const a1s = arc1Start;
+ const a1e = arc1End;
+ const a2s = arc2Start;
+ const a2e = arc2End;
+
+ ctx.beginPath();
+ ctx.arc(cx, cy, radius, a1s, a1e, false);
+ ctx.strokeStyle = res.bg1;
+ ctx.stroke();
+
+ ctx.beginPath();
+ ctx.arc(cx, cy, radius, a1s, (a1e - a1s) * res.value1 + a1s, false);
+ ctx.strokeStyle = res.fg1;
+ ctx.stroke();
+
+ ctx.beginPath();
+ ctx.arc(cx, cy, radius, a2s, a2e, false);
+ ctx.strokeStyle = res.bg2;
+ ctx.stroke();
+
+ ctx.beginPath();
+ ctx.arc(cx, cy, radius, a2s, (a2e - a2s) * res.value2 + a2s, false);
+ ctx.strokeStyle = res.fg2;
+ ctx.stroke();
+ }
+ }
+
+ Behavior on value1 {
+ NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+
+ Behavior on value2 {
+ NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+
+ Behavior on fg1 {
+ ColorAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+
+ Behavior on fg2 {
+ ColorAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+
+ Behavior on bg1 {
+ ColorAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+
+ Behavior on bg2 {
+ ColorAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+ }
+}
diff --git a/modules/dashboard/Tabs.qml b/modules/dashboard/Tabs.qml
new file mode 100644
index 0000000..db7796a
--- /dev/null
+++ b/modules/dashboard/Tabs.qml
@@ -0,0 +1,249 @@
+import "root:/widgets"
+import "root:/services"
+import "root:/config"
+import Quickshell.Widgets
+import QtQuick
+import QtQuick.Controls
+
+Item {
+ id: root
+
+ required property real nonAnimWidth
+ property alias currentIndex: bar.currentIndex
+ readonly property TabBar bar: bar
+
+ implicitHeight: bar.implicitHeight + indicator.implicitHeight + indicator.anchors.topMargin + separator.implicitHeight
+
+ TabBar {
+ id: bar
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+
+ background: null
+
+ Tab {
+ iconName: "dashboard"
+ text: qsTr("Dashboard")
+ }
+
+ Tab {
+ iconName: "queue_music"
+ text: qsTr("Media")
+ }
+
+ Tab {
+ iconName: "speed"
+ text: qsTr("Performance")
+ }
+
+ Tab {
+ iconName: "workspaces"
+ text: qsTr("Workspaces")
+ }
+ }
+
+ Item {
+ id: indicator
+
+ anchors.top: bar.bottom
+ anchors.topMargin: DashboardConfig.sizes.tabIndicatorSpacing
+
+ implicitWidth: bar.currentItem.implicitWidth
+ implicitHeight: DashboardConfig.sizes.tabIndicatorHeight
+
+ x: {
+ const tab = bar.currentItem;
+ const width = (root.nonAnimWidth - DashboardConfig.sizes.tabIndicatorSpacing * (bar.count - 1) * 2) / bar.count
+ return width * tab.TabBar.index + (width - tab.implicitWidth) / 2;
+ }
+
+ clip: true
+
+ StyledRect {
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.right: parent.right
+ implicitHeight: parent.implicitHeight * 2
+
+ color: Colours.palette.m3primary
+ radius: Appearance.rounding.full
+
+ }
+
+ Behavior on x {
+ Anim {}
+ }
+
+ Behavior on implicitWidth {
+ Anim {}
+ }
+ }
+
+ StyledRect {
+ id: separator
+
+ anchors.top: indicator.bottom
+ anchors.left: parent.left
+ anchors.right: parent.right
+
+ implicitHeight: 1
+ color: Colours.palette.m3outlineVariant
+ }
+
+ component Tab: TabButton {
+ id: tab
+
+ required property string iconName
+ readonly property bool current: TabBar.tabBar.currentItem === this
+
+ background: null
+
+ contentItem: MouseArea {
+ id: mouse
+
+ implicitWidth: Math.max(icon.width, label.width)
+ implicitHeight: icon.height + label.height
+
+ cursorShape: Qt.PointingHandCursor
+
+ onPressed: ({x,y}) => {
+ tab.TabBar.tabBar.setCurrentIndex(tab.TabBar.index);
+
+ const stateY = stateWrapper.y;
+ rippleAnim.x = x;
+ rippleAnim.y = y - stateY;
+
+ const dist = (ox,oy) => ox * ox + oy * oy;
+ const stateEndY = stateY + stateWrapper.height;
+ rippleAnim.radius = Math.sqrt(Math.max(dist(0, stateY), dist(0, stateEndY), dist(width, stateY), dist(width, stateEndY)));
+
+ rippleAnim.restart();
+ }
+ onWheel: event => {
+ if (event.angleDelta.y < 0)
+ tab.TabBar.tabBar.incrementCurrentIndex();
+ else if (event.angleDelta.y > 0)
+ tab.TabBar.tabBar.decrementCurrentIndex();
+ }
+
+ SequentialAnimation {
+ id: rippleAnim
+
+ property real x
+ property real y
+ property real radius
+
+ PropertyAction {
+ target: ripple
+ property: "x"
+ value: rippleAnim.x
+ }
+ PropertyAction {
+ target: ripple
+ property: "y"
+ value: rippleAnim.y
+ }
+ PropertyAction {
+ target: ripple
+ property: "opacity"
+ value: 0.1
+ }
+ ParallelAnimation {
+ Anim {
+ target: ripple
+ properties: "implicitWidth,implicitHeight"
+ from: 0
+ to: rippleAnim.radius * 2
+ duration: Appearance.anim.durations.large
+ easing.bezierCurve: Appearance.anim.curves.standardDecel
+ }
+ Anim {
+ target: ripple
+ property: "opacity"
+ to: 0
+ duration: Appearance.anim.durations.large
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standardDecel
+ }
+ }
+ }
+
+ ClippingRectangle {
+ id: stateWrapper
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ implicitHeight: parent.height + DashboardConfig.sizes.tabIndicatorSpacing * 2
+
+ color: "transparent"
+ radius: Appearance.rounding.small
+
+ StyledRect {
+ id: stateLayer
+
+ anchors.fill: parent
+
+ color: tab.current ? Colours.palette.m3primary : Colours.palette.m3onSurface
+ opacity: mouse.pressed ? 0.1 : tab.hovered ? 0.08 : 0
+
+ Behavior on opacity {
+ Anim {}
+ }
+ }
+
+ StyledRect {
+ id: ripple
+
+ radius: Appearance.rounding.full
+ color: tab.current ? Colours.palette.m3primary : Colours.palette.m3onSurface
+ opacity: 0
+
+ transform: Translate {
+ x: -ripple.width / 2
+ y: -ripple.height / 2
+ }
+ }
+ }
+
+ MaterialIcon {
+ id: icon
+
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.bottom: label.top
+
+ text: tab.iconName
+ color: tab.current ? Colours.palette.m3primary : Colours.palette.m3onSurfaceVariant
+ fill: tab.current ? 1 : 0
+ font.pointSize: Appearance.font.size.large
+
+ Behavior on fill {
+ NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+ }
+
+ StyledText {
+ id: label
+
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.bottom: parent.bottom
+
+ text: tab.text
+ color: tab.current ? Colours.palette.m3primary : Colours.palette.m3onSurfaceVariant
+ }
+
+ }
+ }
+
+ component Anim: NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+}
diff --git a/modules/dashboard/Wrapper.qml b/modules/dashboard/Wrapper.qml
new file mode 100644
index 0000000..e043904
--- /dev/null
+++ b/modules/dashboard/Wrapper.qml
@@ -0,0 +1,55 @@
+import QtQuick
+import Quickshell
+import "root:/config"
+
+Item {
+ id: root
+
+ required property PersistentProperties visibilities
+
+ visible: height > 0
+ implicitHeight: 0
+ implicitWidth: content.implicitWidth
+
+ states: State {
+ name: "visible"
+ when: root.visibilities.dashboard
+
+ PropertyChanges {
+ root.implicitHeight: content.implicitHeight
+ }
+ }
+
+ transitions: [
+ Transition {
+ from: ""
+ to: "visible"
+
+ NumberAnimation {
+ target: root
+ property: "implicitHeight"
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ },
+ Transition {
+ from: "visible"
+ to: ""
+
+ NumberAnimation {
+ target: root
+ property: "implicitHeight"
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+ ]
+
+ Content {
+ id: content
+
+ visibilities: root.visibilities
+ }
+}
diff --git a/modules/dashboard/dash/Calendar.qml b/modules/dashboard/dash/Calendar.qml
new file mode 100644
index 0000000..7b69f98
--- /dev/null
+++ b/modules/dashboard/dash/Calendar.qml
@@ -0,0 +1,70 @@
+import "root:/widgets"
+import "root:/services"
+import "root:/config"
+import QtQuick
+import QtQuick.Controls
+
+Column {
+ id: root
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ padding: Appearance.padding.large
+ spacing: Appearance.spacing.small
+
+ DayOfWeekRow {
+ id: days
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.margins: parent.padding
+
+ delegate: StyledText {
+ required property var model
+
+ horizontalAlignment: Text.AlignHCenter
+ text: model.shortName
+ font.family: Appearance.font.family.sans
+ font.weight: 500
+ }
+ }
+
+ MonthGrid {
+ id: grid
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.margins: parent.padding
+
+ spacing: 3
+
+ delegate: Item {
+ id: day
+
+ required property var model
+
+ implicitWidth: implicitHeight
+ implicitHeight: text.implicitHeight + Appearance.padding.small * 2
+
+ StyledRect {
+ anchors.centerIn: parent
+
+ implicitWidth: parent.implicitHeight
+ implicitHeight: parent.implicitHeight
+
+ radius: Appearance.rounding.full
+ color: model.today ? Colours.palette.m3primary : "transparent"
+
+ StyledText {
+ id: text
+
+ anchors.centerIn: parent
+
+ horizontalAlignment: Text.AlignHCenter
+ text: grid.locale.toString(day.model.date, "d")
+ color: day.model.today ? Colours.palette.m3onPrimary : day.model.month === grid.month ? Colours.palette.m3onSurfaceVariant : Colours.palette.m3outline
+ }
+ }
+ }
+ }
+}
diff --git a/modules/dashboard/dash/DateTime.qml b/modules/dashboard/dash/DateTime.qml
new file mode 100644
index 0000000..5f44227
--- /dev/null
+++ b/modules/dashboard/dash/DateTime.qml
@@ -0,0 +1,71 @@
+import "root:/widgets"
+import "root:/services"
+import "root:/config"
+import QtQuick
+
+Item {
+ id: root
+
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ implicitWidth: DashboardConfig.sizes.dateTimeWidth
+
+ StyledText {
+ id: hours
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.topMargin: (root.height - (hours.implicitHeight + sep.implicitHeight + sep.anchors.topMargin + mins.implicitHeight + mins.anchors.topMargin + date.implicitHeight + date.anchors.topMargin)) / 2
+
+ horizontalAlignment: Text.AlignHCenter
+ text: Time.format("HH")
+ color: Colours.palette.m3secondary
+ font.pointSize: Appearance.font.size.extraLarge
+ font.weight: 500
+ }
+
+ StyledText {
+ id: sep
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: hours.bottom
+ anchors.topMargin: -font.pointSize * 0.5
+
+ horizontalAlignment: Text.AlignHCenter
+ text: "•••"
+ color: Colours.palette.m3primary
+ font.pointSize: Appearance.font.size.extraLarge * 0.9
+ }
+
+ StyledText {
+ id: mins
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: sep.bottom
+ anchors.topMargin: -sep.font.pointSize * 0.45
+
+ horizontalAlignment: Text.AlignHCenter
+ text: Time.format("MM")
+ color: Colours.palette.m3secondary
+ font.pointSize: Appearance.font.size.extraLarge
+ font.weight: 500
+ }
+
+ StyledText {
+ id: date
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: mins.bottom
+ anchors.topMargin: Appearance.spacing.normal
+
+ horizontalAlignment: Text.AlignHCenter
+ text: Time.format("ddd, d")
+ color: Colours.palette.m3tertiary
+ font.pointSize: Appearance.font.size.normal
+ font.weight: 500
+ }
+}
diff --git a/modules/dashboard/dash/Media.qml b/modules/dashboard/dash/Media.qml
new file mode 100644
index 0000000..7122d69
--- /dev/null
+++ b/modules/dashboard/dash/Media.qml
@@ -0,0 +1,262 @@
+import "root:/widgets"
+import "root:/services"
+import "root:/config"
+import Quickshell
+import Quickshell.Io
+import Quickshell.Widgets
+import QtQuick
+import QtQuick.Shapes
+
+Item {
+ id: root
+
+ required property bool shouldUpdate
+
+ property real playerProgress: {
+ const active = Players.active;
+ return active?.length ? active.position / active.length : 0;
+ }
+
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ implicitWidth: DashboardConfig.sizes.mediaWidth
+
+ Behavior on playerProgress {
+ NumberAnimation {
+ duration: Appearance.anim.durations.large
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+
+ Timer {
+ running: root.shouldUpdate && (Players.active?.isPlaying ?? false)
+ interval: DashboardConfig.mediaUpdateInterval
+ triggeredOnStart: true
+ repeat: true
+ onTriggered: Players.active?.positionChanged()
+ }
+
+ Shape {
+ preferredRendererType: Shape.CurveRenderer
+
+ ShapePath {
+ fillColor: "transparent"
+ strokeColor: Colours.palette.m3surfaceContainerHigh
+ strokeWidth: DashboardConfig.sizes.mediaProgressThickness
+ capStyle: ShapePath.RoundCap
+
+ PathAngleArc {
+ centerX: cover.x + cover.width / 2
+ centerY: cover.y + cover.height / 2
+ radiusX: (cover.width + DashboardConfig.sizes.mediaProgressThickness) / 2 + Appearance.spacing.small
+ radiusY: (cover.height + DashboardConfig.sizes.mediaProgressThickness) / 2 + Appearance.spacing.small
+ startAngle: -90 - DashboardConfig.sizes.mediaProgressSweep / 2
+ sweepAngle: DashboardConfig.sizes.mediaProgressSweep
+ }
+
+ Behavior on strokeColor {
+ ColorAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+ }
+
+ ShapePath {
+ fillColor: "transparent"
+ strokeColor: Colours.palette.m3primary
+ strokeWidth: DashboardConfig.sizes.mediaProgressThickness
+ capStyle: ShapePath.RoundCap
+
+ PathAngleArc {
+ centerX: cover.x + cover.width / 2
+ centerY: cover.y + cover.height / 2
+ radiusX: (cover.width + DashboardConfig.sizes.mediaProgressThickness) / 2 + Appearance.spacing.small
+ radiusY: (cover.height + DashboardConfig.sizes.mediaProgressThickness) / 2 + Appearance.spacing.small
+ startAngle: -90 - DashboardConfig.sizes.mediaProgressSweep / 2
+ sweepAngle: DashboardConfig.sizes.mediaProgressSweep * root.playerProgress
+ }
+
+ Behavior on strokeColor {
+ ColorAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+ }
+ }
+
+ StyledClippingRect {
+ id: cover
+
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.margins: Appearance.padding.large + DashboardConfig.sizes.mediaProgressThickness + Appearance.spacing.small
+
+ implicitHeight: width
+ color: Colours.palette.m3surfaceContainerHigh
+ radius: Appearance.rounding.full
+
+ MaterialIcon {
+ anchors.centerIn: parent
+
+ text: "art_track"
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: (parent.width * 0.4) || 1
+ }
+
+ Image {
+ id: image
+
+ anchors.fill: parent
+
+ source: Players.active?.trackArtUrl ?? ""
+ asynchronous: true
+ fillMode: Image.PreserveAspectCrop
+ sourceSize.width: width
+ sourceSize.height: height
+ }
+ }
+
+ StyledText {
+ id: title
+
+ anchors.top: cover.bottom
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.topMargin: Appearance.spacing.normal
+
+ animate: true
+ horizontalAlignment: Text.AlignHCenter
+ text: (Players.active?.trackTitle ?? qsTr("No media")) || qsTr("Unknown title")
+ color: Colours.palette.m3primary
+ font.pointSize: Appearance.font.size.normal
+
+ width: parent.implicitWidth - Appearance.padding.large * 2
+ elide: Text.ElideRight
+ }
+
+ StyledText {
+ id: album
+
+ anchors.top: title.bottom
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.topMargin: Appearance.spacing.small
+
+ animate: true
+ horizontalAlignment: Text.AlignHCenter
+ text: (Players.active?.trackAlbum ?? qsTr("No media")) || qsTr("Unknown album")
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+
+ width: parent.implicitWidth - Appearance.padding.large * 2
+ elide: Text.ElideRight
+ }
+
+ StyledText {
+ id: artist
+
+ anchors.top: album.bottom
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.topMargin: Appearance.spacing.small
+
+ animate: true
+ horizontalAlignment: Text.AlignHCenter
+ text: (Players.active?.trackArtist ?? qsTr("No media")) || qsTr("Unknown artist")
+ color: Colours.palette.m3secondary
+
+ width: parent.implicitWidth - Appearance.padding.large * 2
+ elide: Text.ElideRight
+ }
+
+ Row {
+ id: controls
+
+ anchors.top: artist.bottom
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.topMargin: Appearance.spacing.smaller
+
+ spacing: Appearance.spacing.small
+
+ Control {
+ icon: "skip_previous"
+ canUse: Players.active?.canGoPrevious ?? false
+
+ function onClicked(): void {
+ Players.active?.previous();
+ }
+ }
+
+ Control {
+ icon: Players.active?.isPlaying ? "pause" : "play_arrow"
+ canUse: Players.active?.canTogglePlaying ?? false
+
+ function onClicked(): void {
+ Players.active?.togglePlaying();
+ }
+ }
+
+ Control {
+ icon: "skip_next"
+ canUse: Players.active?.canGoNext ?? false
+
+ function onClicked(): void {
+ Players.active?.next();
+ }
+ }
+ }
+
+ AnimatedImage {
+ id: bongocat
+
+ anchors.top: controls.bottom
+ anchors.bottom: parent.bottom
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.topMargin: Appearance.spacing.small
+ anchors.bottomMargin: Appearance.padding.large
+ anchors.margins: Appearance.padding.large * 2
+
+ playing: root.shouldUpdate && (Players.active?.isPlaying ?? false)
+ speed: BeatDetector.bpm / 300
+ source: "root:/assets/bongocat.gif"
+ asynchronous: true
+ fillMode: AnimatedImage.PreserveAspectFit
+ }
+
+ component Control: StyledRect {
+ id: control
+
+ required property string icon
+ required property bool canUse
+ function onClicked(): void {
+ }
+
+ implicitWidth: Math.max(icon.implicitHeight, icon.implicitHeight) + Appearance.padding.small
+ implicitHeight: implicitWidth
+
+ StateLayer {
+ disabled: !control.canUse
+ radius: Appearance.rounding.full
+
+ function onClicked(): void {
+ control.onClicked();
+ }
+ }
+
+ MaterialIcon {
+ id: icon
+
+ anchors.centerIn: parent
+ anchors.verticalCenterOffset: font.pointSize * 0.05
+
+ animate: true
+ text: control.icon
+ color: control.canUse ? Colours.palette.m3onSurface : Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.large
+ }
+ }
+}
diff --git a/modules/dashboard/dash/Resources.qml b/modules/dashboard/dash/Resources.qml
new file mode 100644
index 0000000..b22b2d1
--- /dev/null
+++ b/modules/dashboard/dash/Resources.qml
@@ -0,0 +1,85 @@
+import "root:/widgets"
+import "root:/services"
+import "root:/config"
+import QtQuick
+import QtQuick.Controls
+
+Row {
+ id: root
+
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+
+ padding: Appearance.padding.large
+ spacing: Appearance.spacing.normal
+
+ Resource {
+ icon: "memory"
+ value: SystemUsage.cpuPerc
+ colour: Colours.palette.m3primary
+ }
+
+ Resource {
+ icon: "memory_alt"
+ value: SystemUsage.memPerc
+ colour: Colours.palette.m3secondary
+ }
+
+ Resource {
+ icon: "hard_disk"
+ value: SystemUsage.storagePerc
+ colour: Colours.palette.m3tertiary
+ }
+
+ component Resource: Item {
+ id: res
+
+ required property string icon
+ required property real value
+ required property color colour
+
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ anchors.margins: Appearance.padding.large
+ implicitWidth: icon.implicitWidth
+
+ StyledRect {
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.top: parent.top
+ anchors.bottom: icon.top
+ anchors.bottomMargin: Appearance.spacing.small
+
+ implicitWidth: DashboardConfig.sizes.resourceProgessThickness
+
+ color: Colours.palette.m3surfaceContainerHigh
+ radius: Appearance.rounding.full
+
+ StyledRect {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ implicitHeight: res.value * parent.height
+
+ color: res.colour
+ radius: Appearance.rounding.full
+ }
+ }
+
+ MaterialIcon {
+ id: icon
+
+ anchors.bottom: parent.bottom
+
+ text: res.icon
+ color: res.colour
+ }
+
+ Behavior on value {
+ NumberAnimation {
+ duration: Appearance.anim.durations.large
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+ }
+}
diff --git a/modules/dashboard/dash/User.qml b/modules/dashboard/dash/User.qml
new file mode 100644
index 0000000..c7d2bfd
--- /dev/null
+++ b/modules/dashboard/dash/User.qml
@@ -0,0 +1,116 @@
+import "root:/widgets"
+import "root:/services"
+import "root:/config"
+import "root:/utils"
+import Quickshell
+import Quickshell.Io
+import QtQuick
+
+Row {
+ id: root
+
+ padding: Appearance.padding.large
+ spacing: Appearance.spacing.large
+
+ StyledClippingRect {
+ implicitWidth: info.implicitHeight
+ implicitHeight: info.implicitHeight
+
+ radius: Appearance.rounding.full
+ color: Colours.palette.m3surfaceContainerHigh
+
+ MaterialIcon {
+ anchors.centerIn: parent
+
+ text: "person"
+ fill: 1
+ font.pointSize: (info.implicitHeight / 2) || 1
+ }
+
+ CachingImage {
+ anchors.fill: parent
+ path: `${Paths.home}/.face`
+ }
+ }
+
+ Column {
+ id: info
+
+ spacing: Appearance.spacing.normal
+
+ InfoLine {
+ icon: Icons.osIcon
+ text: Icons.osName
+ colour: Colours.palette.m3primary
+ }
+
+ InfoLine {
+ icon: "select_window_2"
+ text: Quickshell.env("XDG_CURRENT_DESKTOP") || Quickshell.env("XDG_SESSION_DESKTOP")
+ colour: Colours.palette.m3secondary
+ }
+
+ InfoLine {
+ icon: "timer"
+ text: uptimeProc.uptime
+ colour: Colours.palette.m3tertiary
+
+ Timer {
+ running: true
+ repeat: true
+ interval: 15000
+ onTriggered: uptimeProc.running = true
+ }
+
+ Process {
+ id: uptimeProc
+
+ property string uptime
+
+ running: true
+ command: ["uptime", "-p"]
+ stdout: SplitParser {
+ onRead: data => uptimeProc.uptime = data
+ }
+ }
+ }
+ }
+
+ component InfoLine: Item {
+ id: line
+
+ required property string icon
+ required property string text
+ required property color colour
+
+ implicitWidth: icon.implicitWidth + text.width + text.anchors.leftMargin
+ implicitHeight: Math.max(icon.implicitHeight, text.implicitHeight)
+
+ MaterialIcon {
+ id: icon
+
+ anchors.left: parent.left
+ anchors.leftMargin: (DashboardConfig.sizes.infoIconSize - implicitWidth) / 2
+
+ text: line.icon
+ color: line.colour
+ font.pointSize: Appearance.font.size.normal
+ font.variableAxes: ({
+ FILL: 1
+ })
+ }
+
+ StyledText {
+ id: text
+
+ anchors.verticalCenter: icon.verticalCenter
+ anchors.left: icon.right
+ anchors.leftMargin: icon.anchors.leftMargin
+ text: `: ${line.text}`
+ font.pointSize: Appearance.font.size.normal
+
+ width: DashboardConfig.sizes.infoWidth
+ elide: Text.ElideRight
+ }
+ }
+}
diff --git a/modules/dashboard/dash/Weather.qml b/modules/dashboard/dash/Weather.qml
new file mode 100644
index 0000000..468d542
--- /dev/null
+++ b/modules/dashboard/dash/Weather.qml
@@ -0,0 +1,63 @@
+import "root:/widgets"
+import "root:/services"
+import "root:/config"
+import "root:/utils"
+import QtQuick
+
+Item {
+ id: root
+
+ anchors.centerIn: parent
+
+ implicitWidth: icon.implicitWidth + info.implicitWidth + info.anchors.leftMargin
+
+ onVisibleChanged: {
+ if (visible)
+ Weather.reload();
+ }
+
+ MaterialIcon {
+ id: icon
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: parent.left
+
+ animate: true
+ text: Weather.icon || "cloud_alert"
+ color: Colours.palette.m3secondary
+ font.pointSize: Appearance.font.size.extraLarge * 2
+ font.variableAxes: ({
+ opsz: Appearance.font.size.extraLarge * 1.2
+ })
+ }
+
+ Column {
+ id: info
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: icon.right
+ anchors.leftMargin: Appearance.spacing.large
+
+ spacing: Appearance.spacing.small
+
+ StyledText {
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ animate: true
+ text: `${Weather.temperature}°C`
+ color: Colours.palette.m3primary
+ font.pointSize: Appearance.font.size.extraLarge
+ font.weight: 500
+ }
+
+ StyledText {
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ animate: true
+ text: Weather.description || qsTr("No weather")
+
+ elide: Text.ElideRight
+ width: Math.min(implicitWidth, root.parent.width - icon.implicitWidth - info.anchors.leftMargin - Appearance.padding.large * 2)
+ }
+ }
+}
diff --git a/modules/drawers/Backgrounds.qml b/modules/drawers/Backgrounds.qml
new file mode 100644
index 0000000..1840da1
--- /dev/null
+++ b/modules/drawers/Backgrounds.qml
@@ -0,0 +1,66 @@
+import "root:/services"
+import "root:/config"
+import "root:/modules/osd" as Osd
+import "root:/modules/notifications" as Notifications
+import "root:/modules/session" as Session
+import "root:/modules/launcher" as Launcher
+import "root:/modules/dashboard" as Dashboard
+import "root:/modules/bar/popouts" as BarPopouts
+import QtQuick
+import QtQuick.Shapes
+
+Shape {
+ id: root
+
+ required property Panels panels
+ required property Item bar
+
+ anchors.fill: parent
+ anchors.margins: BorderConfig.thickness
+ anchors.leftMargin: bar.implicitWidth
+ preferredRendererType: Shape.CurveRenderer
+ opacity: Colours.transparency.enabled ? Colours.transparency.base : 1
+
+ Osd.Background {
+ wrapper: panels.osd
+
+ startX: root.width - panels.session.width
+ startY: (root.height - wrapper.height) / 2 - rounding
+ }
+
+ Notifications.Background {
+ wrapper: panels.notifications
+
+ startX: root.width
+ startY: 0
+ }
+
+ Session.Background {
+ wrapper: panels.session
+
+ startX: root.width
+ startY: (root.height - wrapper.height) / 2 - rounding
+ }
+
+ Launcher.Background {
+ wrapper: panels.launcher
+
+ startX: (root.width - wrapper.width) / 2 - rounding
+ startY: root.height
+ }
+
+ Dashboard.Background {
+ wrapper: panels.dashboard
+
+ startX: (root.width - wrapper.width) / 2 - rounding
+ startY: 0
+ }
+
+ BarPopouts.Background {
+ wrapper: panels.popouts
+ invertBottomRounding: wrapper.y + wrapper.height + 1 >= root.height
+
+ startX: 0
+ startY: wrapper.y - rounding
+ }
+}
diff --git a/modules/drawers/Border.qml b/modules/drawers/Border.qml
new file mode 100644
index 0000000..9014d07
--- /dev/null
+++ b/modules/drawers/Border.qml
@@ -0,0 +1,47 @@
+import "root:/widgets"
+import "root:/services"
+import "root:/config"
+import Quickshell
+import QtQuick
+import QtQuick.Effects
+
+Item {
+ id: root
+
+ required property Item bar
+
+ anchors.fill: parent
+
+ StyledRect {
+ id: rect
+
+ anchors.fill: parent
+ color: Colours.alpha(BorderConfig.colour, false)
+ visible: false
+ }
+
+ Item {
+ id: mask
+
+ anchors.fill: parent
+ layer.enabled: true
+ visible: false
+
+ Rectangle {
+ anchors.fill: parent
+ anchors.margins: BorderConfig.thickness
+ anchors.leftMargin: root.bar.implicitWidth
+ radius: BorderConfig.rounding
+ }
+ }
+
+ MultiEffect {
+ anchors.fill: parent
+ maskEnabled: true
+ maskInverted: true
+ maskSource: mask
+ source: rect
+ maskThresholdMin: 0.5
+ maskSpreadAtMin: 1
+ }
+}
diff --git a/modules/drawers/Drawers.qml b/modules/drawers/Drawers.qml
new file mode 100644
index 0000000..9930c6d
--- /dev/null
+++ b/modules/drawers/Drawers.qml
@@ -0,0 +1,147 @@
+pragma ComponentBehavior: Bound
+
+import "root:/widgets"
+import "root:/services"
+import "root:/config"
+import "root:/modules/bar"
+import Quickshell
+import Quickshell.Wayland
+import Quickshell.Hyprland
+import QtQuick
+import QtQuick.Effects
+
+Variants {
+ model: Quickshell.screens
+
+ Scope {
+ id: scope
+
+ required property ShellScreen modelData
+
+ Exclusions {
+ screen: scope.modelData
+ bar: bar
+ }
+
+ StyledWindow {
+ id: win
+
+ screen: scope.modelData
+ name: "drawers"
+ WlrLayershell.exclusionMode: ExclusionMode.Ignore
+ WlrLayershell.keyboardFocus: visibilities.launcher || visibilities.session ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None
+
+ mask: Region {
+ x: bar.implicitWidth
+ y: BorderConfig.thickness
+ width: win.width - bar.implicitWidth - BorderConfig.thickness
+ height: win.height - BorderConfig.thickness * 2
+ intersection: Intersection.Xor
+
+ regions: regions.instances
+ }
+
+ anchors.top: true
+ anchors.bottom: true
+ anchors.left: true
+ anchors.right: true
+
+ Variants {
+ id: regions
+
+ model: panels.children
+
+ Region {
+ required property Item modelData
+
+ x: modelData.x + bar.implicitWidth
+ y: modelData.y + BorderConfig.thickness
+ width: modelData.width
+ height: modelData.height
+ intersection: Intersection.Subtract
+ }
+ }
+
+ HyprlandFocusGrab {
+ active: visibilities.launcher || visibilities.session
+ windows: [win]
+ onCleared: {
+ visibilities.launcher = false;
+ visibilities.session = false;
+ }
+ }
+
+ StyledRect {
+ anchors.fill: parent
+ opacity: visibilities.session ? 0.5 : 0
+ color: Colours.palette.m3scrim
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+ }
+
+ Item {
+ id: background
+
+ anchors.fill: parent
+ visible: false
+
+ Border {
+ bar: bar
+ }
+
+ Backgrounds {
+ panels: panels
+ bar: bar
+ }
+ }
+
+ MultiEffect {
+ anchors.fill: source
+ source: background
+ shadowEnabled: true
+ blurMax: 15
+ shadowColor: Qt.alpha(Colours.palette.m3shadow, 0.7)
+ }
+
+ PersistentProperties {
+ id: visibilities
+
+ property bool osd
+ property bool session
+ property bool launcher
+ property bool dashboard
+
+ Component.onCompleted: Visibilities.screens[scope.modelData] = this
+ }
+
+ Interactions {
+ screen: scope.modelData
+ popouts: panels.popouts
+ visibilities: visibilities
+ panels: panels
+ bar: bar
+
+ Panels {
+ id: panels
+
+ screen: scope.modelData
+ visibilities: visibilities
+ bar: bar
+ }
+ }
+
+ Bar {
+ id: bar
+
+ screen: scope.modelData
+ popouts: panels.popouts
+ }
+ }
+ }
+}
diff --git a/modules/drawers/Exclusions.qml b/modules/drawers/Exclusions.qml
new file mode 100644
index 0000000..188aadb
--- /dev/null
+++ b/modules/drawers/Exclusions.qml
@@ -0,0 +1,36 @@
+pragma ComponentBehavior: Bound
+
+import "root:/widgets"
+import "root:/config"
+import Quickshell
+import QtQuick
+
+Scope {
+ id: root
+
+ required property ShellScreen screen
+ required property Item bar
+
+ ExclusionZone {
+ anchors.left: true
+ exclusiveZone: root.bar.implicitWidth
+ }
+
+ ExclusionZone {
+ anchors.top: true
+ }
+
+ ExclusionZone {
+ anchors.right: true
+ }
+
+ ExclusionZone {
+ anchors.bottom: true
+ }
+
+ component ExclusionZone: StyledWindow {
+ screen: root.screen
+ name: "border-exclusion"
+ exclusiveZone: BorderConfig.thickness
+ }
+}
diff --git a/modules/drawers/Interactions.qml b/modules/drawers/Interactions.qml
new file mode 100644
index 0000000..3ec522b
--- /dev/null
+++ b/modules/drawers/Interactions.qml
@@ -0,0 +1,83 @@
+import "root:/services"
+import "root:/config"
+import "root:/modules/bar/popouts" as BarPopouts
+import "root:/modules/osd" as Osd
+import Quickshell
+import QtQuick
+
+MouseArea {
+ id: root
+
+ required property ShellScreen screen
+ required property BarPopouts.Wrapper popouts
+ required property PersistentProperties visibilities
+ required property Panels panels
+ required property Item bar
+
+ property bool osdHovered
+ property point dragStart
+
+ function withinPanelHeight(panel: Item, x: real, y: real): bool {
+ const panelY = BorderConfig.thickness + panel.y;
+ return y >= panelY - BorderConfig.rounding && y <= panelY + panel.height + BorderConfig.rounding;
+ }
+
+ function inRightPanel(panel: Item, x: real, y: real): bool {
+ return x > bar.implicitWidth + panel.x && withinPanelHeight(panel, x, y);
+ }
+
+ function inTopPanel(panel: Item, x: real, y: real): bool {
+ const panelX = bar.implicitWidth + panel.x;
+ return y < BorderConfig.thickness + panel.y + panel.height && x >= panelX - BorderConfig.rounding && x <= panelX + panel.width + BorderConfig.rounding;
+ }
+
+ anchors.fill: parent
+ hoverEnabled: true
+
+ onPressed: event => dragStart = Qt.point(event.x, event.y)
+ onContainsMouseChanged: {
+ if (!containsMouse) {
+ visibilities.osd = false;
+ osdHovered = false;
+ visibilities.dashboard = false;
+ popouts.hasCurrent = false;
+ }
+ }
+
+ onPositionChanged: ({x, y}) => {
+ // Show osd on hover
+ const showOsd = inRightPanel(panels.osd, x, y);
+ visibilities.osd = showOsd;
+ osdHovered = showOsd;
+
+ // Show/hide session on drag
+ if (pressed && withinPanelHeight(panels.session, x, y)) {
+ const dragX = x - dragStart.x;
+ if (dragX < -SessionConfig.dragThreshold)
+ visibilities.session = true;
+ else if (dragX > SessionConfig.dragThreshold)
+ visibilities.session = false;
+ }
+
+ // Show dashboard on hover
+ visibilities.dashboard = inTopPanel(panels.dashboard, x, y);
+
+ // Show popouts on hover
+ const popout = panels.popouts;
+ if (x < bar.implicitWidth + popout.width) {
+ if (x < bar.implicitWidth)
+ // Handle like part of bar
+ bar.checkPopout(y);
+ else
+ // Keep on hover
+ popouts.hasCurrent = withinPanelHeight(popout, x, y);
+ } else
+ popouts.hasCurrent = false;
+ }
+
+ Osd.Interactions {
+ screen: root.screen
+ visibilities: root.visibilities
+ hovered: root.osdHovered
+ }
+}
diff --git a/modules/drawers/Panels.qml b/modules/drawers/Panels.qml
new file mode 100644
index 0000000..6c6d892
--- /dev/null
+++ b/modules/drawers/Panels.qml
@@ -0,0 +1,93 @@
+import "root:/services"
+import "root:/config"
+import "root:/modules/osd" as Osd
+import "root:/modules/notifications" as Notifications
+import "root:/modules/session" as Session
+import "root:/modules/launcher" as Launcher
+import "root:/modules/dashboard" as Dashboard
+import "root:/modules/bar/popouts" as BarPopouts
+import Quickshell
+import QtQuick
+
+Item {
+ id: root
+
+ required property ShellScreen screen
+ required property PersistentProperties visibilities
+ required property Item bar
+
+ readonly property Osd.Wrapper osd: osd
+ readonly property Notifications.Wrapper notifications: notifications
+ readonly property Session.Wrapper session: session
+ readonly property Launcher.Wrapper launcher: launcher
+ readonly property Dashboard.Wrapper dashboard: dashboard
+ readonly property BarPopouts.Wrapper popouts: popouts
+
+ anchors.fill: parent
+ anchors.margins: BorderConfig.thickness
+ anchors.leftMargin: bar.implicitWidth
+
+ Component.onCompleted: Visibilities.panels[screen] = this
+
+ Osd.Wrapper {
+ id: osd
+
+ clip: root.visibilities.session
+ screen: root.screen
+ visibility: root.visibilities.osd
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.right: parent.right
+ anchors.rightMargin: session.width
+ }
+
+ Notifications.Wrapper {
+ id: notifications
+
+ anchors.top: parent.top
+ anchors.right: parent.right
+ }
+
+ Session.Wrapper {
+ id: session
+
+ visibilities: root.visibilities
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.right: parent.right
+ }
+
+ Launcher.Wrapper {
+ id: launcher
+
+ visibilities: root.visibilities
+
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.bottom: parent.bottom
+ }
+
+ Dashboard.Wrapper {
+ id: dashboard
+
+ visibilities: root.visibilities
+
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.top: parent.top
+ }
+
+ BarPopouts.Wrapper {
+ id: popouts
+
+ screen: root.screen
+
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.top
+ anchors.verticalCenterOffset: {
+ const off = root.popouts.currentCenter - BorderConfig.thickness;
+ const diff = root.height - Math.floor(off + implicitHeight / 2);
+ if (diff < 0)
+ return off + diff;
+ return off;
+ }
+ }
+}
diff --git a/modules/launcher/ActionItem.qml b/modules/launcher/ActionItem.qml
new file mode 100644
index 0000000..a650768
--- /dev/null
+++ b/modules/launcher/ActionItem.qml
@@ -0,0 +1,69 @@
+import "root:/widgets"
+import "root:/services"
+import "root:/config"
+import QtQuick
+
+Item {
+ id: root
+
+ required property Actions.Action modelData
+ required property var list
+
+ implicitHeight: LauncherConfig.sizes.itemHeight
+
+ anchors.left: parent?.left
+ anchors.right: parent?.right
+
+ StateLayer {
+ radius: Appearance.rounding.full
+
+ function onClicked(): void {
+ root.modelData?.onClicked(root.list);
+ }
+ }
+
+ Item {
+ anchors.fill: parent
+ anchors.leftMargin: Appearance.padding.larger
+ anchors.rightMargin: Appearance.padding.larger
+ anchors.margins: Appearance.padding.smaller
+
+ MaterialIcon {
+ id: icon
+
+ text: root.modelData?.icon ?? ""
+ font.pointSize: Appearance.font.size.extraLarge
+
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Item {
+ anchors.left: icon.right
+ anchors.leftMargin: Appearance.spacing.larger
+ anchors.verticalCenter: icon.verticalCenter
+
+ implicitWidth: parent.width - icon.width
+ implicitHeight: name.implicitHeight + desc.implicitHeight
+
+ StyledText {
+ id: name
+
+ text: root.modelData?.name ?? ""
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ StyledText {
+ id: desc
+
+ text: root.modelData?.desc ?? ""
+ font.pointSize: Appearance.font.size.small
+ color: Colours.alpha(Colours.palette.m3outline, true)
+
+ elide: Text.ElideRight
+ width: root.width - icon.width - Appearance.rounding.normal * 2
+
+ anchors.top: name.bottom
+ }
+ }
+ }
+}
diff --git a/modules/launcher/Actions.qml b/modules/launcher/Actions.qml
new file mode 100644
index 0000000..0cbc870
--- /dev/null
+++ b/modules/launcher/Actions.qml
@@ -0,0 +1,130 @@
+pragma Singleton
+
+import "root:/utils/scripts/fuzzysort.js" as Fuzzy
+import "root:/services"
+import "root:/config"
+import Quickshell
+import Quickshell.Io
+import QtQuick
+
+Singleton {
+ id: root
+
+ readonly property list list: [
+ Action {
+ name: qsTr("Scheme")
+ desc: qsTr("Change the current colour scheme")
+ icon: "palette"
+
+ function onClicked(list: AppList): void {
+ root.autocomplete(list, "scheme");
+ }
+ },
+ Action {
+ name: qsTr("Wallpaper")
+ desc: qsTr("Change the current wallpaper")
+ icon: "image"
+
+ function onClicked(list: AppList): void {
+ root.autocomplete(list, "wallpaper");
+ }
+ },
+ Action {
+ name: qsTr("Variant")
+ desc: qsTr("Change the current scheme variant")
+ icon: "colors"
+
+ function onClicked(list: AppList): void {
+ root.autocomplete(list, "variant");
+ }
+ },
+ Action {
+ name: qsTr("Transparency")
+ desc: qsTr("Change shell transparency")
+ icon: "opacity"
+
+ function onClicked(list: AppList): void {
+ root.autocomplete(list, "transparency");
+ }
+ },
+ Action {
+ name: qsTr("Light")
+ desc: qsTr("Change the scheme to light mode")
+ icon: "light_mode"
+
+ function onClicked(list: AppList): void {
+ list.visibilities.launcher = false;
+ Colours.setMode("light");
+ }
+ },
+ Action {
+ name: qsTr("Dark")
+ desc: qsTr("Change the scheme to dark mode")
+ icon: "dark_mode"
+
+ function onClicked(list: AppList): void {
+ list.visibilities.launcher = false;
+ Colours.setMode("dark");
+ }
+ },
+ Action {
+ name: qsTr("Lock")
+ desc: qsTr("Lock the current session")
+ icon: "lock"
+
+ function onClicked(list: AppList): void {
+ list.visibilities.launcher = false;
+ lock.running = true;
+ }
+ },
+ Action {
+ name: qsTr("Sleep")
+ desc: qsTr("Suspend then hibernate")
+ icon: "bedtime"
+
+ function onClicked(list: AppList): void {
+ list.visibilities.launcher = false;
+ sleep.running = true;
+ }
+ }
+ ]
+
+ readonly property list preppedActions: list.map(a => ({
+ name: Fuzzy.prepare(a.name),
+ desc: Fuzzy.prepare(a.desc),
+ action: a
+ }))
+
+ function fuzzyQuery(search: string): var {
+ return Fuzzy.go(search.slice(LauncherConfig.actionPrefix.length), preppedActions, {
+ all: true,
+ keys: ["name", "desc"],
+ scoreFn: r => r[0].score > 0 ? r[0].score * 0.9 + r[1].score * 0.1 : 0
+ }).map(r => r.obj.action);
+ }
+
+ function autocomplete(list: AppList, text: string): void {
+ list.search.text = `${LauncherConfig.actionPrefix}${text} `;
+ }
+
+ Process {
+ id: lock
+
+ command: ["loginctl", "lock-session"]
+ }
+
+ Process {
+ id: sleep
+
+ command: ["systemctl", "suspend-then-hibernate"]
+ }
+
+ component Action: QtObject {
+ required property string name
+ required property string desc
+ required property string icon
+
+ function onClicked(list: AppList): void {
+ }
+ }
+}
diff --git a/modules/launcher/AppItem.qml b/modules/launcher/AppItem.qml
new file mode 100644
index 0000000..b6a0bf5
--- /dev/null
+++ b/modules/launcher/AppItem.qml
@@ -0,0 +1,72 @@
+import "root:/widgets"
+import "root:/services"
+import "root:/config"
+import Quickshell
+import Quickshell.Widgets
+import QtQuick
+
+Item {
+ id: root
+
+ required property DesktopEntry modelData
+ required property PersistentProperties visibilities
+
+ implicitHeight: LauncherConfig.sizes.itemHeight
+
+ anchors.left: parent?.left
+ anchors.right: parent?.right
+
+ StateLayer {
+ radius: Appearance.rounding.full
+
+ function onClicked(): void {
+ Apps.launch(root.modelData);
+ root.visibilities.launcher = false;
+ }
+ }
+
+ Item {
+ anchors.fill: parent
+ anchors.leftMargin: Appearance.padding.larger
+ anchors.rightMargin: Appearance.padding.larger
+ anchors.margins: Appearance.padding.smaller
+
+ IconImage {
+ id: icon
+
+ source: Quickshell.iconPath(root.modelData?.icon, "image-missing")
+ implicitSize: parent.height * 0.8
+
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Item {
+ anchors.left: icon.right
+ anchors.leftMargin: Appearance.spacing.normal
+ anchors.verticalCenter: icon.verticalCenter
+
+ implicitWidth: parent.width - icon.width
+ implicitHeight: name.implicitHeight + comment.implicitHeight
+
+ StyledText {
+ id: name
+
+ text: root.modelData?.name ?? ""
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ StyledText {
+ id: comment
+
+ text: (root.modelData?.comment || root.modelData?.genericName || root.modelData?.name) ?? ""
+ font.pointSize: Appearance.font.size.small
+ color: Colours.alpha(Colours.palette.m3outline, true)
+
+ elide: Text.ElideRight
+ width: root.width - icon.width - Appearance.rounding.normal * 2
+
+ anchors.top: name.bottom
+ }
+ }
+ }
+}
diff --git a/modules/launcher/AppList.qml b/modules/launcher/AppList.qml
new file mode 100644
index 0000000..a431395
--- /dev/null
+++ b/modules/launcher/AppList.qml
@@ -0,0 +1,160 @@
+pragma ComponentBehavior: Bound
+
+import "root:/widgets"
+import "root:/services"
+import "root:/config"
+import Quickshell
+import QtQuick
+import QtQuick.Controls
+
+ListView {
+ id: root
+
+ required property int padding
+ required property TextField search
+ required property PersistentProperties visibilities
+
+ property bool isAction: search.text.startsWith(LauncherConfig.actionPrefix)
+
+ function getModelValues() {
+ let text = search.text;
+ if (isAction)
+ return Actions.fuzzyQuery(text);
+ if (text.startsWith(LauncherConfig.actionPrefix))
+ text = search.text.slice(LauncherConfig.actionPrefix.length);
+ return Apps.fuzzyQuery(text);
+ }
+
+ model: ScriptModel {
+ values: root.getModelValues()
+ onValuesChanged: root.currentIndex = 0
+ }
+
+ spacing: Appearance.spacing.small
+ orientation: Qt.Vertical
+ implicitHeight: (LauncherConfig.sizes.itemHeight + spacing) * Math.min(LauncherConfig.maxShown, count) - spacing
+
+ highlightMoveDuration: Appearance.anim.durations.normal
+ highlightResizeDuration: 0
+
+ highlight: StyledRect {
+ radius: Appearance.rounding.full
+ color: Colours.palette.m3onSurface
+ opacity: 0.08
+ }
+
+ delegate: isAction ? actionItem : appItem
+
+ ScrollBar.vertical: StyledScrollBar {}
+
+ add: Transition {
+ Anim {
+ properties: "opacity,scale"
+ from: 0
+ to: 1
+ }
+ }
+
+ remove: Transition {
+ Anim {
+ properties: "opacity,scale"
+ from: 1
+ to: 0
+ }
+ }
+
+ move: Transition {
+ Anim {
+ property: "y"
+ }
+ Anim {
+ properties: "opacity,scale"
+ to: 1
+ }
+ }
+
+ addDisplaced: Transition {
+ Anim {
+ property: "y"
+ duration: Appearance.anim.durations.small
+ }
+ Anim {
+ properties: "opacity,scale"
+ to: 1
+ }
+ }
+
+ displaced: Transition {
+ Anim {
+ property: "y"
+ }
+ Anim {
+ properties: "opacity,scale"
+ to: 1
+ }
+ }
+
+ Component {
+ id: appItem
+
+ AppItem {
+ visibilities: root.visibilities
+ }
+ }
+
+ Component {
+ id: actionItem
+
+ ActionItem {
+ list: root
+ }
+ }
+
+ Behavior on isAction {
+ SequentialAnimation {
+ ParallelAnimation {
+ Anim {
+ target: root
+ property: "opacity"
+ from: 1
+ to: 0
+ duration: Appearance.anim.durations.small
+ easing.bezierCurve: Appearance.anim.curves.standardAccel
+ }
+ Anim {
+ target: root
+ property: "scale"
+ from: 1
+ to: 0.9
+ duration: Appearance.anim.durations.small
+ easing.bezierCurve: Appearance.anim.curves.standardAccel
+ }
+ }
+ PropertyAction {}
+ ParallelAnimation {
+ Anim {
+ target: root
+ property: "opacity"
+ from: 0
+ to: 1
+ duration: Appearance.anim.durations.small
+ easing.bezierCurve: Appearance.anim.curves.standardDecel
+ }
+ Anim {
+ target: root
+ property: "scale"
+ from: 0.9
+ to: 1
+ duration: Appearance.anim.durations.small
+ easing.bezierCurve: Appearance.anim.curves.standardDecel
+ }
+ }
+ }
+ }
+
+ component Anim: NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+}
diff --git a/modules/launcher/Background.qml b/modules/launcher/Background.qml
new file mode 100644
index 0000000..5a3d5d4
--- /dev/null
+++ b/modules/launcher/Background.qml
@@ -0,0 +1,63 @@
+import "root:/services"
+import "root:/config"
+import QtQuick
+import QtQuick.Shapes
+
+ShapePath {
+ id: root
+
+ required property Wrapper wrapper
+ readonly property real rounding: BorderConfig.rounding
+ readonly property bool flatten: wrapper.height < rounding * 2
+ readonly property real roundingY: flatten ? wrapper.height / 2 : rounding
+
+ strokeWidth: -1
+ fillColor: BorderConfig.colour
+
+ PathArc {
+ relativeX: root.rounding
+ relativeY: -root.roundingY
+ radiusX: root.rounding
+ radiusY: Math.min(root.rounding, root.wrapper.height)
+ direction: PathArc.Counterclockwise
+ }
+ PathLine {
+ relativeX: 0
+ relativeY: -(root.wrapper.height - root.roundingY * 2)
+ }
+ PathArc {
+ relativeX: root.rounding
+ relativeY: -root.roundingY
+ radiusX: root.rounding
+ radiusY: Math.min(root.rounding, root.wrapper.height)
+ }
+ PathLine {
+ relativeX: root.wrapper.width - root.rounding * 2
+ relativeY: 0
+ }
+ PathArc {
+ relativeX: root.rounding
+ relativeY: root.roundingY
+ radiusX: root.rounding
+ radiusY: Math.min(root.rounding, root.wrapper.height)
+ }
+ PathLine {
+ relativeX: 0
+ relativeY: root.wrapper.height - root.roundingY * 2
+ }
+ PathArc {
+ relativeX: root.rounding
+ relativeY: root.roundingY
+ radiusX: root.rounding
+ radiusY: Math.min(root.rounding, root.wrapper.height)
+ direction: PathArc.Counterclockwise
+ }
+
+ Behavior on fillColor {
+ ColorAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+}
diff --git a/modules/launcher/Content.qml b/modules/launcher/Content.qml
new file mode 100644
index 0000000..54cc872
--- /dev/null
+++ b/modules/launcher/Content.qml
@@ -0,0 +1,168 @@
+pragma ComponentBehavior: Bound
+
+import "root:/widgets"
+import "root:/services"
+import "root:/config"
+import Quickshell
+import QtQuick
+
+Item {
+ id: root
+
+ required property PersistentProperties visibilities
+ readonly property int padding: Appearance.padding.large
+ readonly property int rounding: Appearance.rounding.large
+
+ implicitWidth: listWrapper.width + padding * 2
+ implicitHeight: searchWrapper.height + listWrapper.height + padding * 2
+
+ anchors.top: parent.top
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ Item {
+ id: listWrapper
+
+ implicitWidth: list.width
+ implicitHeight: list.height + root.padding
+
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.bottom: searchWrapper.top
+ anchors.bottomMargin: root.padding
+
+ ContentList {
+ id: list
+
+ visibilities: root.visibilities
+ search: search
+ padding: root.padding
+ rounding: root.rounding
+ }
+ }
+
+ StyledRect {
+ id: searchWrapper
+
+ color: Colours.alpha(Colours.palette.m3surfaceContainer, true)
+ radius: Appearance.rounding.full
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ anchors.margins: root.padding
+
+ implicitHeight: Math.max(searchIcon.implicitHeight, search.implicitHeight, clearIcon.implicitHeight)
+
+ MaterialIcon {
+ id: searchIcon
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: parent.left
+ anchors.leftMargin: root.padding
+
+ text: "search"
+ color: Colours.palette.m3onSurfaceVariant
+ }
+
+ StyledTextField {
+ id: search
+
+ anchors.left: searchIcon.right
+ anchors.right: clearIcon.left
+ anchors.leftMargin: Appearance.spacing.small
+ anchors.rightMargin: Appearance.spacing.small
+
+ topPadding: Appearance.padding.larger
+ bottomPadding: Appearance.padding.larger
+
+ placeholderText: qsTr("Type \"%1\" for commands").arg(LauncherConfig.actionPrefix)
+ background: null
+
+ onAccepted: {
+ const currentItem = list.currentList?.currentItem;
+ if (currentItem) {
+ if (list.showWallpapers) {
+ Wallpapers.setWallpaper(currentItem.modelData.path);
+ root.visibilities.launcher = false;
+ } else if (text.startsWith(LauncherConfig.actionPrefix)) {
+ currentItem.modelData.onClicked(list.currentList);
+ } else {
+ Apps.launch(currentItem.modelData);
+ root.visibilities.launcher = false;
+ }
+ }
+ }
+
+ Keys.onUpPressed: list.currentList?.decrementCurrentIndex()
+ Keys.onDownPressed: list.currentList?.incrementCurrentIndex()
+
+ Keys.onEscapePressed: root.visibilities.launcher = false
+
+ Connections {
+ target: root.visibilities
+
+ function onLauncherChanged(): void {
+ if (root.visibilities.launcher)
+ search.forceActiveFocus();
+ else {
+ search.text = "";
+ const current = list.currentList;
+ if (current)
+ current.currentIndex = 0;
+ }
+ }
+ }
+ }
+
+ MaterialIcon {
+ id: clearIcon
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.right: parent.right
+ anchors.rightMargin: root.padding
+
+ width: search.text ? implicitWidth : implicitWidth / 2
+ opacity: {
+ if (!search.text)
+ return 0;
+ if (mouse.pressed)
+ return 0.7;
+ if (mouse.hovered)
+ return 0.8;
+ return 1;
+ }
+
+ text: "close"
+ color: Colours.palette.m3onSurfaceVariant
+
+ MouseArea {
+ id: mouse
+
+ property bool hovered
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: search.text ? Qt.PointingHandCursor : undefined
+
+ onEntered: hovered = true
+ onExited: hovered = false
+ onClicked: search.text = ""
+ }
+
+ Behavior on width {
+ NumberAnimation {
+ duration: Appearance.anim.durations.small
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Appearance.anim.durations.small
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+ }
+ }
+}
diff --git a/modules/launcher/ContentList.qml b/modules/launcher/ContentList.qml
new file mode 100644
index 0000000..9818118
--- /dev/null
+++ b/modules/launcher/ContentList.qml
@@ -0,0 +1,188 @@
+pragma ComponentBehavior: Bound
+
+import "root:/widgets"
+import "root:/services"
+import "root:/config"
+import Quickshell
+import QtQuick
+import QtQuick.Controls
+
+Item {
+ id: root
+
+ required property PersistentProperties visibilities
+ required property TextField search
+ required property int padding
+ required property int rounding
+
+ property bool showWallpapers: search.text.startsWith(`${LauncherConfig.actionPrefix}wallpaper `)
+ property var currentList: (showWallpapers ? wallpaperList : appList).item
+
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.bottom: parent.bottom
+
+ clip: true
+ state: showWallpapers ? "wallpapers" : "apps"
+
+ states: [
+ State {
+ name: "apps"
+
+ PropertyChanges {
+ root.implicitWidth: LauncherConfig.sizes.itemWidth
+ root.implicitHeight: Math.max(empty.height, appList.height)
+ appList.active: true
+ }
+
+ AnchorChanges {
+ anchors.left: root.parent.left
+ anchors.right: root.parent.right
+ }
+ },
+ State {
+ name: "wallpapers"
+
+ PropertyChanges {
+ root.implicitWidth: Math.max(LauncherConfig.sizes.itemWidth, wallpaperList.width)
+ root.implicitHeight: LauncherConfig.sizes.wallpaperHeight
+ wallpaperList.active: true
+ }
+ }
+ ]
+
+ transitions: Transition {
+ SequentialAnimation {
+ NumberAnimation {
+ target: root
+ property: "opacity"
+ from: 1
+ to: 0
+ duration: Appearance.anim.durations.small
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ PropertyAction {
+ targets: [appList, wallpaperList]
+ properties: "active"
+ }
+ ParallelAnimation {
+ NumberAnimation {
+ target: root
+ properties: "implicitWidth,implicitHeight"
+ duration: Appearance.anim.durations.large
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ NumberAnimation {
+ target: root
+ property: "opacity"
+ from: 0
+ to: 1
+ duration: Appearance.anim.durations.large
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+ }
+ }
+
+ Loader {
+ id: appList
+
+ active: false
+ asynchronous: true
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+
+ sourceComponent: AppList {
+ padding: root.padding
+ search: root.search
+ visibilities: root.visibilities
+ }
+ }
+
+ Loader {
+ id: wallpaperList
+
+ active: false
+ asynchronous: true
+
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ sourceComponent: WallpaperList {
+ search: root.search
+ visibilities: root.visibilities
+ }
+ }
+
+ Item {
+ id: empty
+
+ opacity: root.currentList?.count === 0 ? 1 : 0
+ scale: root.currentList?.count === 0 ? 1 : 0.5
+
+ implicitWidth: icon.width + text.width + Appearance.spacing.small
+ implicitHeight: icon.height
+
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.verticalCenter: parent.verticalCenter
+
+ MaterialIcon {
+ id: icon
+
+ text: "manage_search"
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.extraLarge
+
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ id: text
+
+ anchors.left: icon.right
+ anchors.leftMargin: Appearance.spacing.small
+ anchors.verticalCenter: parent.verticalCenter
+
+ text: qsTr("No results")
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.larger
+ font.weight: 500
+ }
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+
+ Behavior on scale {
+ NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+ }
+
+ Behavior on implicitWidth {
+ NumberAnimation {
+ duration: Appearance.anim.durations.large
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.emphasizedDecel
+ }
+ }
+
+ Behavior on implicitHeight {
+ NumberAnimation {
+ duration: Appearance.anim.durations.large
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.emphasizedDecel
+ }
+ }
+}
diff --git a/modules/launcher/WallpaperItem.qml b/modules/launcher/WallpaperItem.qml
new file mode 100644
index 0000000..dbd482e
--- /dev/null
+++ b/modules/launcher/WallpaperItem.qml
@@ -0,0 +1,109 @@
+import "root:/widgets"
+import "root:/services"
+import "root:/config"
+import Quickshell
+import QtQuick
+import QtQuick.Effects
+
+StyledRect {
+ id: root
+
+ required property Wallpapers.Wallpaper modelData
+ required property PersistentProperties visibilities
+
+ scale: 0.5
+ opacity: 0
+ z: PathView.z ?? 0
+
+ Component.onCompleted: {
+ scale = Qt.binding(() => PathView.isCurrentItem ? 1 : PathView.onPath ? 0.8 : 0);
+ opacity = Qt.binding(() => PathView.onPath ? 1 : 0);
+ }
+
+ implicitWidth: image.width + Appearance.padding.larger * 2
+ implicitHeight: image.height + label.height + Appearance.spacing.small / 2 + Appearance.padding.large + Appearance.padding.normal
+
+ StateLayer {
+ radius: Appearance.rounding.normal
+
+ function onClicked(): void {
+ Wallpapers.setWallpaper(root.modelData.path);
+ root.visibilities.launcher = false;
+ }
+ }
+
+ CachingImage {
+ id: image
+
+ anchors.horizontalCenter: parent.horizontalCenter
+ y: Appearance.padding.large
+
+ visible: false
+ path: root.modelData.path
+ smooth: !root.PathView.view.moving
+
+ width: LauncherConfig.sizes.wallpaperWidth
+ height: width / 16 * 9
+ }
+
+ Rectangle {
+ id: mask
+
+ layer.enabled: true
+ layer.smooth: true
+ visible: false
+ anchors.fill: image
+ radius: Appearance.rounding.normal
+ }
+
+ RectangularShadow {
+ opacity: root.PathView.isCurrentItem ? 0.7 : 0
+ anchors.fill: mask
+ radius: mask.radius
+ color: Colours.palette.m3shadow
+ blur: 10
+ spread: 3
+
+ Behavior on opacity {
+ Anim {}
+ }
+ }
+
+ MultiEffect {
+ anchors.fill: image
+ source: image
+ maskEnabled: true
+ maskSource: mask
+ maskSpreadAtMin: 1
+ maskThresholdMin: 0.5
+ }
+
+ StyledText {
+ id: label
+
+ anchors.top: image.bottom
+ anchors.topMargin: Appearance.spacing.small / 2
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ width: image.width - Appearance.padding.normal * 2
+ horizontalAlignment: Text.AlignHCenter
+ elide: Text.ElideRight
+ renderType: Text.QtRendering
+ text: root.modelData.name
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ Behavior on scale {
+ Anim {}
+ }
+
+ Behavior on opacity {
+ Anim {}
+ }
+
+ component Anim: NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+}
diff --git a/modules/launcher/WallpaperList.qml b/modules/launcher/WallpaperList.qml
new file mode 100644
index 0000000..fef1726
--- /dev/null
+++ b/modules/launcher/WallpaperList.qml
@@ -0,0 +1,79 @@
+import "root:/widgets"
+import "root:/services"
+import "root:/config"
+import Quickshell
+import QtQuick
+import QtQuick.Controls
+
+PathView {
+ id: root
+
+ required property TextField search
+ required property PersistentProperties visibilities
+ readonly property int numItems: {
+ const screenWidth = QsWindow.window?.screen.width * 0.8;
+ if (!screenWidth)
+ return 0;
+ const itemWidth = LauncherConfig.sizes.wallpaperWidth * 0.8;
+ const max = LauncherConfig.maxWallpapers;
+ if (max * itemWidth > screenWidth) {
+ const items = Math.floor(screenWidth / itemWidth);
+ return items > 1 && items % 2 === 0 ? items - 1 : items;
+ }
+ return max;
+ }
+
+ model: ScriptModel {
+ readonly property string search: root.search.text.split(" ").slice(1).join(" ")
+
+ values: {
+ const list = Wallpapers.fuzzyQuery(search);
+ if (list.length > 1 && list.length % 2 === 0)
+ list.length -= 1; // Always show odd number
+ return list;
+ }
+ onValuesChanged: root.currentIndex = search ? 0 : values.findIndex(w => w.path === Wallpapers.actualCurrent)
+ }
+
+ Component.onCompleted: currentIndex = Wallpapers.list.findIndex(w => w.path === Wallpapers.actualCurrent)
+ Component.onDestruction: Wallpapers.stopPreview()
+
+ onCurrentItemChanged: {
+ if (currentItem)
+ Wallpapers.preview(currentItem.modelData.path);
+ }
+
+ implicitWidth: Math.min(numItems, count) * (LauncherConfig.sizes.wallpaperWidth * 0.8 + Appearance.padding.larger * 2)
+ pathItemCount: numItems
+ cacheItemCount: 4
+
+ snapMode: PathView.SnapToItem
+ preferredHighlightBegin: 0.5
+ preferredHighlightEnd: 0.5
+ highlightRangeMode: PathView.StrictlyEnforceRange
+
+ delegate: WallpaperItem {
+ visibilities: root.visibilities
+ }
+
+ path: Path {
+ startY: root.height / 2
+
+ PathAttribute {
+ name: "z"
+ value: 0
+ }
+ PathLine {
+ x: root.width / 2
+ relativeY: 0
+ }
+ PathAttribute {
+ name: "z"
+ value: 1
+ }
+ PathLine {
+ x: root.width
+ relativeY: 0
+ }
+ }
+}
diff --git a/modules/launcher/Wrapper.qml b/modules/launcher/Wrapper.qml
new file mode 100644
index 0000000..86c8b95
--- /dev/null
+++ b/modules/launcher/Wrapper.qml
@@ -0,0 +1,55 @@
+import "root:/config"
+import Quickshell
+import QtQuick
+
+Item {
+ id: root
+
+ required property PersistentProperties visibilities
+
+ visible: height > 0
+ implicitHeight: 0
+ implicitWidth: content.implicitWidth
+
+ states: State {
+ name: "visible"
+ when: root.visibilities.launcher
+
+ PropertyChanges {
+ root.implicitHeight: content.implicitHeight
+ }
+ }
+
+ transitions: [
+ Transition {
+ from: ""
+ to: "visible"
+
+ NumberAnimation {
+ target: root
+ property: "implicitHeight"
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ },
+ Transition {
+ from: "visible"
+ to: ""
+
+ NumberAnimation {
+ target: root
+ property: "implicitHeight"
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+ ]
+
+ Content {
+ id: content
+
+ visibilities: root.visibilities
+ }
+}
diff --git a/modules/notifications/Background.qml b/modules/notifications/Background.qml
new file mode 100644
index 0000000..2fd05f8
--- /dev/null
+++ b/modules/notifications/Background.qml
@@ -0,0 +1,66 @@
+import "root:/services"
+import "root:/config"
+import Quickshell
+import QtQuick
+import QtQuick.Shapes
+
+ShapePath {
+ id: root
+
+ required property Wrapper wrapper
+ readonly property real rounding: BorderConfig.rounding
+ readonly property bool flatten: wrapper.height < rounding * 2
+ readonly property real roundingY: flatten ? wrapper.height / 2 : rounding
+ property real fullHeightRounding: wrapper.height >= QsWindow.window?.height - BorderConfig.thickness * 2 ? -rounding : rounding
+
+ strokeWidth: -1
+ fillColor: BorderConfig.colour
+
+ PathLine {
+ relativeX: -(root.wrapper.width + root.rounding)
+ relativeY: 0
+ }
+ PathArc {
+ relativeX: root.rounding
+ relativeY: root.roundingY
+ radiusX: root.rounding
+ radiusY: Math.min(root.rounding, root.wrapper.height)
+ }
+ PathLine {
+ relativeX: 0
+ relativeY: root.wrapper.height - root.roundingY * 2
+ }
+ PathArc {
+ relativeX: root.fullHeightRounding
+ relativeY: root.roundingY
+ radiusX: Math.abs(root.fullHeightRounding)
+ radiusY: Math.min(root.rounding, root.wrapper.height)
+ direction: root.fullHeightRounding < 0 ? PathArc.Clockwise : PathArc.Counterclockwise
+ }
+ PathLine {
+ relativeX: root.wrapper.height > 0 ? root.wrapper.width - root.rounding - root.fullHeightRounding : root.wrapper.width
+ relativeY: 0
+ }
+ PathArc {
+ relativeX: root.rounding
+ relativeY: root.rounding
+ radiusX: root.rounding
+ radiusY: root.rounding
+ }
+
+ Behavior on fillColor {
+ ColorAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+
+ Behavior on fullHeightRounding {
+ NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+}
diff --git a/modules/notifications/Content.qml b/modules/notifications/Content.qml
new file mode 100644
index 0000000..a64ccb9
--- /dev/null
+++ b/modules/notifications/Content.qml
@@ -0,0 +1,162 @@
+import "root:/widgets"
+import "root:/services"
+import "root:/config"
+import Quickshell
+import Quickshell.Widgets
+import QtQuick
+
+Item {
+ id: root
+
+ readonly property int padding: Appearance.padding.large
+
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ anchors.right: parent.right
+
+ implicitWidth: NotifsConfig.sizes.width + padding * 2
+ implicitHeight: {
+ const count = list.count;
+ if (count === 0)
+ return 0;
+
+ let height = (count - 1) * Appearance.spacing.smaller;
+ for (let i = 0; i < count; i++)
+ height += list.itemAtIndex(i)?.nonAnimHeight ?? 0;
+
+ const screen = QsWindow.window?.screen;
+ const visibilities = Visibilities.screens[screen];
+ const panel = Visibilities.panels[screen];
+ if (visibilities && panel) {
+ if (visibilities.osd) {
+ const h = panel.osd.y - BorderConfig.rounding * 2;
+ if (height > h)
+ height = h;
+ }
+
+ if (visibilities.session) {
+ const h = panel.session.y - BorderConfig.rounding * 2;
+ if (height > h)
+ height = h;
+ }
+ }
+
+ return Math.min((screen?.height ?? 0) - BorderConfig.thickness * 2, height + padding * 2);
+ }
+
+ ClippingWrapperRectangle {
+ anchors.fill: parent
+ anchors.margins: root.padding
+
+ color: "transparent"
+ radius: Appearance.rounding.normal
+
+ ListView {
+ id: list
+
+ model: ScriptModel {
+ values: [...Notifs.popups].reverse()
+ }
+
+ anchors.fill: parent
+
+ orientation: Qt.Vertical
+ spacing: 0
+ cacheBuffer: QsWindow.window?.screen.height ?? 0
+
+ delegate: Item {
+ id: wrapper
+
+ required property Notifs.Notif modelData
+ required property int index
+ readonly property alias nonAnimHeight: notif.nonAnimHeight
+ property int idx
+
+ onIndexChanged: {
+ if (index !== -1)
+ idx = index;
+ }
+
+ implicitWidth: notif.implicitWidth
+ implicitHeight: notif.implicitHeight + (idx === 0 ? 0 : Appearance.spacing.smaller)
+
+ ListView.onRemove: removeAnim.start()
+
+ SequentialAnimation {
+ id: removeAnim
+
+ PropertyAction {
+ target: wrapper
+ property: "ListView.delayRemove"
+ value: true
+ }
+ PropertyAction {
+ target: wrapper
+ property: "enabled"
+ value: false
+ }
+ PropertyAction {
+ target: wrapper
+ property: "implicitHeight"
+ value: 0
+ }
+ PropertyAction {
+ target: wrapper
+ property: "z"
+ value: 1
+ }
+ Anim {
+ target: notif
+ property: "x"
+ to: (notif.x >= 0 ? NotifsConfig.sizes.width : -NotifsConfig.sizes.width) * 2
+ duration: Appearance.anim.durations.normal
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ PropertyAction {
+ target: wrapper
+ property: "ListView.delayRemove"
+ value: false
+ }
+ }
+
+ ClippingRectangle {
+ anchors.top: parent.top
+ anchors.topMargin: wrapper.idx === 0 ? 0 : Appearance.spacing.smaller
+
+ color: "transparent"
+ radius: notif.radius
+ implicitWidth: notif.implicitWidth
+ implicitHeight: notif.implicitHeight
+
+ Notification {
+ id: notif
+
+ modelData: wrapper.modelData
+ }
+ }
+ }
+
+ move: Transition {
+ Anim {
+ property: "y"
+ }
+ }
+
+ displaced: Transition {
+ Anim {
+ property: "y"
+ }
+ }
+ }
+ }
+
+ Behavior on implicitHeight {
+ Anim {}
+ }
+
+ component Anim: NumberAnimation {
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+}
diff --git a/modules/notifications/Notification.qml b/modules/notifications/Notification.qml
new file mode 100644
index 0000000..51ed5f5
--- /dev/null
+++ b/modules/notifications/Notification.qml
@@ -0,0 +1,489 @@
+pragma ComponentBehavior: Bound
+
+import "root:/widgets"
+import "root:/services"
+import "root:/config"
+import Quickshell
+import Quickshell.Widgets
+import Quickshell.Services.Notifications
+import QtQuick
+import QtQuick.Layouts
+
+StyledRect {
+ id: root
+
+ required property Notifs.Notif modelData
+ readonly property bool hasImage: modelData.image.length > 0
+ readonly property bool hasAppIcon: modelData.appIcon.length > 0
+ readonly property int nonAnimHeight: summary.implicitHeight + (root.expanded ? appName.height + body.height + actions.height + actions.anchors.topMargin : bodyPreview.height) + inner.anchors.margins * 2
+ property bool expanded
+
+ color: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3secondaryContainer : Colours.palette.m3surfaceContainer
+ radius: Appearance.rounding.normal
+ implicitWidth: NotifsConfig.sizes.width
+ implicitHeight: inner.implicitHeight
+
+ x: NotifsConfig.sizes.width
+ Component.onCompleted: x = 0
+
+ RetainableLock {
+ object: root.modelData.notification
+ locked: true
+ }
+
+ MouseArea {
+ property int startY
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: pressed ? Qt.ClosedHandCursor : undefined
+ acceptedButtons: Qt.LeftButton | Qt.MiddleButton
+ preventStealing: true
+
+ onEntered: root.modelData.timer.stop()
+ onExited: root.modelData.timer.start()
+
+ drag.target: parent
+ drag.axis: Drag.XAxis
+
+ onPressed: event => {
+ startY = event.y;
+ if (event.button === Qt.MiddleButton)
+ root.modelData.notification.dismiss();
+ }
+ onReleased: event => {
+ if (Math.abs(root.x) < NotifsConfig.sizes.width * NotifsConfig.clearThreshold)
+ root.x = 0;
+ else
+ root.modelData.popup = false;
+ }
+ onPositionChanged: event => {
+ if (pressed) {
+ const diffY = event.y - startY;
+ if (Math.abs(diffY) > NotifsConfig.expandThreshold)
+ root.expanded = diffY > 0;
+ }
+ }
+ onClicked: event => {
+ if (!NotifsConfig.actionOnClick || event.button !== Qt.LeftButton)
+ return;
+
+ const actions = root.modelData.actions;
+ if (actions?.length === 1)
+ actions[0].invoke();
+ }
+ }
+
+ Behavior on x {
+ NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.emphasizedDecel
+ }
+ }
+
+ Item {
+ id: inner
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.margins: Appearance.padding.normal
+
+ implicitHeight: root.nonAnimHeight
+
+ Behavior on implicitHeight {
+ Anim {}
+ }
+
+ Loader {
+ id: image
+
+ active: root.hasImage
+ asynchronous: true
+
+ anchors.left: parent.left
+ anchors.top: parent.top
+ width: NotifsConfig.sizes.image
+ height: NotifsConfig.sizes.image
+ visible: root.hasImage || root.hasAppIcon
+
+ sourceComponent: ClippingRectangle {
+ radius: Appearance.rounding.full
+ implicitWidth: NotifsConfig.sizes.image
+ implicitHeight: NotifsConfig.sizes.image
+
+ Image {
+ anchors.fill: parent
+ source: Qt.resolvedUrl(root.modelData.image)
+ fillMode: Image.PreserveAspectCrop
+ cache: false
+ asynchronous: true
+ }
+ }
+ }
+
+ Loader {
+ id: appIcon
+
+ active: root.hasAppIcon || !root.hasImage
+ asynchronous: true
+
+ anchors.horizontalCenter: root.hasImage ? undefined : image.horizontalCenter
+ anchors.verticalCenter: root.hasImage ? undefined : image.verticalCenter
+ anchors.right: root.hasImage ? image.right : undefined
+ anchors.bottom: root.hasImage ? image.bottom : undefined
+
+ sourceComponent: StyledRect {
+ radius: Appearance.rounding.full
+ color: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3error : root.modelData.urgency === NotificationUrgency.Low ? Colours.palette.m3surfaceContainerHighest : Colours.palette.m3tertiaryContainer
+ implicitWidth: root.hasImage ? NotifsConfig.sizes.badge : NotifsConfig.sizes.image
+ implicitHeight: root.hasImage ? NotifsConfig.sizes.badge : NotifsConfig.sizes.image
+
+ Loader {
+ id: icon
+
+ active: root.hasAppIcon
+ asynchronous: true
+
+ anchors.centerIn: parent
+ visible: !root.modelData.appIcon.endsWith("symbolic")
+
+ width: Math.round(parent.width * 0.6)
+ height: Math.round(parent.width * 0.6)
+
+ sourceComponent: IconImage {
+ implicitSize: Math.round(parent.width * 0.6)
+ source: Quickshell.iconPath(root.modelData.appIcon)
+ asynchronous: true
+ }
+ }
+
+ Loader {
+ active: root.modelData.appIcon.endsWith("symbolic")
+ asynchronous: true
+ anchors.fill: icon
+
+ sourceComponent: Colouriser {
+ source: icon
+ colorizationColor: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3onError : root.modelData.urgency === NotificationUrgency.Low ? Colours.palette.m3onSurface : Colours.palette.m3onTertiaryContainer
+ }
+ }
+
+ Loader {
+ active: !root.hasAppIcon
+ asynchronous: true
+ anchors.centerIn: parent
+
+ sourceComponent: MaterialIcon {
+ text: {
+ const summary = root.modelData.summary.toLowerCase();
+ if (summary.includes("reboot"))
+ return "restart_alt";
+ if (summary.includes("recording"))
+ return "screen_record";
+ if (summary.includes("battery"))
+ return "power";
+ if (summary.includes("screenshot"))
+ return "screenshot_monitor";
+ if (summary.includes("welcome"))
+ return "waving_hand";
+ if (summary.includes("time") || summary.includes("a break"))
+ return "schedule";
+ if (summary.includes("installed"))
+ return "download";
+ if (summary.includes("update"))
+ return "update";
+ if (summary.startsWith("file"))
+ return "folder_copy";
+ if (root.modelData.urgency === NotificationUrgency.Critical)
+ return "release_alert";
+ return "chat";
+ }
+
+ color: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3onError : root.modelData.urgency === NotificationUrgency.Low ? Colours.palette.m3onSurface : Colours.palette.m3onTertiaryContainer
+ font.pointSize: Appearance.font.size.large
+ }
+ }
+ }
+ }
+
+ StyledText {
+ id: appName
+
+ anchors.top: parent.top
+ anchors.left: image.right
+ anchors.leftMargin: Appearance.spacing.smaller
+
+ animate: true
+ text: appNameMetrics.elidedText
+ maximumLineCount: 1
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.small
+
+ opacity: root.expanded ? 1 : 0
+
+ Behavior on opacity {
+ Anim {}
+ }
+ }
+
+ TextMetrics {
+ id: appNameMetrics
+
+ text: root.modelData.appName
+ font.family: appName.font.family
+ font.pointSize: appName.font.pointSize
+ elide: Text.ElideRight
+ elideWidth: expandBtn.x - time.width - timeSep.width - summary.x - Appearance.spacing.small * 3
+ }
+
+ StyledText {
+ id: summary
+
+ anchors.top: parent.top
+ anchors.left: image.right
+ anchors.leftMargin: Appearance.spacing.smaller
+
+ animate: true
+ text: summaryMetrics.elidedText
+ maximumLineCount: 1
+ height: implicitHeight
+
+ states: State {
+ name: "expanded"
+ when: root.expanded
+
+ PropertyChanges {
+ summary.maximumLineCount: undefined
+ }
+
+ AnchorChanges {
+ target: summary
+ anchors.top: appName.bottom
+ }
+ }
+
+ transitions: Transition {
+ PropertyAction {
+ target: summary
+ property: "maximumLineCount"
+ }
+ AnchorAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+
+ Behavior on height {
+ Anim {}
+ }
+ }
+
+ TextMetrics {
+ id: summaryMetrics
+
+ text: root.modelData.summary
+ font.family: summary.font.family
+ font.pointSize: summary.font.pointSize
+ elide: Text.ElideRight
+ elideWidth: expandBtn.x - time.width - timeSep.width - summary.x - Appearance.spacing.small * 3
+ }
+
+ StyledText {
+ id: timeSep
+
+ anchors.top: parent.top
+ anchors.left: summary.right
+ anchors.leftMargin: Appearance.spacing.small
+
+ text: "•"
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.small
+
+ states: State {
+ name: "expanded"
+ when: root.expanded
+
+ AnchorChanges {
+ target: timeSep
+ anchors.left: appName.right
+ }
+ }
+
+ transitions: Transition {
+ AnchorAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+ }
+
+ StyledText {
+ id: time
+
+ anchors.top: parent.top
+ anchors.left: timeSep.right
+ anchors.leftMargin: Appearance.spacing.small
+
+ animate: true
+ horizontalAlignment: Text.AlignLeft
+ text: root.modelData.timeStr
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.small
+ }
+
+ Item {
+ id: expandBtn
+
+ anchors.right: parent.right
+ anchors.top: parent.top
+
+ implicitWidth: expandIcon.height
+ implicitHeight: expandIcon.height
+
+ StateLayer {
+ radius: Appearance.rounding.full
+
+ function onClicked() {
+ root.expanded = !root.expanded;
+ }
+ }
+
+ MaterialIcon {
+ id: expandIcon
+
+ anchors.centerIn: parent
+
+ animate: true
+ text: root.expanded ? "expand_less" : "expand_more"
+ font.pointSize: Appearance.font.size.normal
+ }
+ }
+
+ StyledText {
+ id: bodyPreview
+
+ anchors.left: summary.left
+ anchors.right: expandBtn.left
+ anchors.top: summary.bottom
+ anchors.rightMargin: Appearance.spacing.small
+
+ animate: true
+ textFormat: Text.MarkdownText
+ text: bodyPreviewMetrics.elidedText
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.small
+
+ opacity: root.expanded ? 0 : 1
+
+ Behavior on opacity {
+ Anim {}
+ }
+ }
+
+ TextMetrics {
+ id: bodyPreviewMetrics
+
+ text: root.modelData.body
+ font.family: bodyPreview.font.family
+ font.pointSize: bodyPreview.font.pointSize
+ elide: Text.ElideRight
+ elideWidth: bodyPreview.width
+ }
+
+ StyledText {
+ id: body
+
+ anchors.left: summary.left
+ anchors.right: expandBtn.left
+ anchors.top: summary.bottom
+ anchors.rightMargin: Appearance.spacing.small
+
+ animate: true
+ textFormat: Text.MarkdownText
+ text: root.modelData.body
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.small
+ wrapMode: Text.WrapAtWordBoundaryOrAnywhere
+
+ opacity: root.expanded ? 1 : 0
+
+ Behavior on opacity {
+ Anim {}
+ }
+ }
+
+ RowLayout {
+ id: actions
+
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.top: body.bottom
+ anchors.topMargin: Appearance.spacing.small
+
+ spacing: Appearance.spacing.smaller
+
+ opacity: root.expanded ? 1 : 0
+
+ Behavior on opacity {
+ Anim {}
+ }
+
+ Repeater {
+ model: root.modelData.actions
+
+ delegate: StyledRect {
+ id: action
+
+ required property NotificationAction modelData
+
+ radius: Appearance.rounding.full
+ color: Colours.palette.m3surfaceContainerHigh
+
+ Layout.preferredWidth: actionText.width + Appearance.padding.normal * 2
+ Layout.preferredHeight: actionText.height + Appearance.padding.small * 2
+ implicitWidth: actionText.width + Appearance.padding.normal * 2
+ implicitHeight: actionText.height + Appearance.padding.small * 2
+
+ StateLayer {
+ radius: Appearance.rounding.full
+
+ function onClicked(): void {
+ action.modelData.invoke();
+ }
+ }
+
+ StyledText {
+ id: actionText
+
+ anchors.centerIn: parent
+ text: actionTextMetrics.elidedText
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.small
+ }
+
+ TextMetrics {
+ id: actionTextMetrics
+
+ text: modelData.text
+ font.family: actionText.font.family
+ font.pointSize: actionText.font.pointSize
+ elide: Text.ElideRight
+ elideWidth: {
+ const numActions = root.modelData.actions.length;
+ return (inner.width - actions.spacing * (numActions - 1)) / numActions - Appearance.padding.normal * 2;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ component Anim: NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+}
diff --git a/modules/notifications/Wrapper.qml b/modules/notifications/Wrapper.qml
new file mode 100644
index 0000000..27b9219
--- /dev/null
+++ b/modules/notifications/Wrapper.qml
@@ -0,0 +1,14 @@
+import "root:/config"
+import QtQuick
+
+Item {
+ id: root
+
+ visible: height > 0
+ implicitHeight: content.implicitHeight
+ implicitWidth: content.implicitWidth
+
+ Content {
+ id: content
+ }
+}
diff --git a/modules/osd/Background.qml b/modules/osd/Background.qml
new file mode 100644
index 0000000..551637b
--- /dev/null
+++ b/modules/osd/Background.qml
@@ -0,0 +1,63 @@
+import "root:/services"
+import "root:/config"
+import QtQuick
+import QtQuick.Shapes
+
+ShapePath {
+ id: root
+
+ required property Wrapper wrapper
+ readonly property real rounding: BorderConfig.rounding
+ readonly property bool flatten: wrapper.width < rounding * 2
+ readonly property real roundingX: flatten ? wrapper.width / 2 : rounding
+
+ strokeWidth: -1
+ fillColor: BorderConfig.colour
+
+ PathArc {
+ relativeX: -root.roundingX
+ relativeY: root.rounding
+ radiusX: Math.min(root.rounding, root.wrapper.width)
+ radiusY: root.rounding
+ }
+ PathLine {
+ relativeX: -(root.wrapper.width - root.roundingX * 2)
+ relativeY: 0
+ }
+ PathArc {
+ relativeX: -root.roundingX
+ relativeY: root.rounding
+ radiusX: Math.min(root.rounding, root.wrapper.width)
+ radiusY: root.rounding
+ direction: PathArc.Counterclockwise
+ }
+ PathLine {
+ relativeX: 0
+ relativeY: root.wrapper.height - root.rounding * 2
+ }
+ PathArc {
+ relativeX: root.roundingX
+ relativeY: root.rounding
+ radiusX: Math.min(root.rounding, root.wrapper.width)
+ radiusY: root.rounding
+ direction: PathArc.Counterclockwise
+ }
+ PathLine {
+ relativeX: root.wrapper.width - root.roundingX * 2
+ relativeY: 0
+ }
+ PathArc {
+ relativeX: root.roundingX
+ relativeY: root.rounding
+ radiusX: Math.min(root.rounding, root.wrapper.width)
+ radiusY: root.rounding
+ }
+
+ Behavior on fillColor {
+ ColorAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+}
diff --git a/modules/osd/Content.qml b/modules/osd/Content.qml
new file mode 100644
index 0000000..6814966
--- /dev/null
+++ b/modules/osd/Content.qml
@@ -0,0 +1,43 @@
+import "root:/widgets"
+import "root:/services"
+import "root:/config"
+import QtQuick
+
+Column {
+ id: root
+
+ required property Brightness.Monitor monitor
+
+ padding: Appearance.padding.large
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: parent.left
+
+ spacing: Appearance.spacing.normal
+
+ VerticalSlider {
+ icon: {
+ if (Audio.muted)
+ return "no_sound";
+ if (value >= 0.5)
+ return "volume_up";
+ if (value > 0)
+ return "volume_down";
+ return "volume_mute";
+ }
+ value: Audio.volume
+ onMoved: Audio.setVolume(value)
+
+ implicitWidth: OsdConfig.sizes.sliderWidth
+ implicitHeight: OsdConfig.sizes.sliderHeight
+ }
+
+ VerticalSlider {
+ icon: `brightness_${(Math.round(value * 6) + 1)}`
+ value: root.monitor?.brightness ?? 0
+ onMoved: root.monitor?.setBrightness(value)
+
+ implicitWidth: OsdConfig.sizes.sliderWidth
+ implicitHeight: OsdConfig.sizes.sliderHeight
+ }
+}
diff --git a/modules/osd/Interactions.qml b/modules/osd/Interactions.qml
new file mode 100644
index 0000000..eecf0b6
--- /dev/null
+++ b/modules/osd/Interactions.qml
@@ -0,0 +1,48 @@
+import "root:/services"
+import "root:/config"
+import Quickshell
+import QtQuick
+
+Scope {
+ id: root
+
+ required property ShellScreen screen
+ required property PersistentProperties visibilities
+ required property bool hovered
+ readonly property Brightness.Monitor monitor: Brightness.getMonitorForScreen(screen)
+
+ function show(): void {
+ root.visibilities.osd = true;
+ timer.restart();
+ }
+
+ Connections {
+ target: Audio
+
+ function onMutedChanged(): void {
+ root.show();
+ }
+
+ function onVolumeChanged(): void {
+ root.show();
+ }
+ }
+
+ Connections {
+ target: root.monitor
+
+ function onBrightnessChanged(): void {
+ root.show();
+ }
+ }
+
+ Timer {
+ id: timer
+
+ interval: OsdConfig.hideDelay
+ onTriggered: {
+ if (!root.hovered)
+ root.visibilities.osd = false;
+ }
+ }
+}
diff --git a/modules/osd/Wrapper.qml b/modules/osd/Wrapper.qml
new file mode 100644
index 0000000..2969002
--- /dev/null
+++ b/modules/osd/Wrapper.qml
@@ -0,0 +1,57 @@
+import "root:/services"
+import "root:/config"
+import Quickshell
+import QtQuick
+
+Item {
+ id: root
+
+ required property ShellScreen screen
+ required property bool visibility
+
+ visible: width > 0
+ implicitWidth: 0
+ implicitHeight: content.implicitHeight
+
+ states: State {
+ name: "visible"
+ when: root.visibility
+
+ PropertyChanges {
+ root.implicitWidth: content.implicitWidth
+ }
+ }
+
+ transitions: [
+ Transition {
+ from: ""
+ to: "visible"
+
+ NumberAnimation {
+ target: root
+ property: "implicitWidth"
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
+ }
+ },
+ Transition {
+ from: "visible"
+ to: ""
+
+ NumberAnimation {
+ target: root
+ property: "implicitWidth"
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+ ]
+
+ Content {
+ id: content
+
+ monitor: Brightness.getMonitorForScreen(root.screen)
+ }
+}
diff --git a/modules/session/Background.qml b/modules/session/Background.qml
new file mode 100644
index 0000000..551637b
--- /dev/null
+++ b/modules/session/Background.qml
@@ -0,0 +1,63 @@
+import "root:/services"
+import "root:/config"
+import QtQuick
+import QtQuick.Shapes
+
+ShapePath {
+ id: root
+
+ required property Wrapper wrapper
+ readonly property real rounding: BorderConfig.rounding
+ readonly property bool flatten: wrapper.width < rounding * 2
+ readonly property real roundingX: flatten ? wrapper.width / 2 : rounding
+
+ strokeWidth: -1
+ fillColor: BorderConfig.colour
+
+ PathArc {
+ relativeX: -root.roundingX
+ relativeY: root.rounding
+ radiusX: Math.min(root.rounding, root.wrapper.width)
+ radiusY: root.rounding
+ }
+ PathLine {
+ relativeX: -(root.wrapper.width - root.roundingX * 2)
+ relativeY: 0
+ }
+ PathArc {
+ relativeX: -root.roundingX
+ relativeY: root.rounding
+ radiusX: Math.min(root.rounding, root.wrapper.width)
+ radiusY: root.rounding
+ direction: PathArc.Counterclockwise
+ }
+ PathLine {
+ relativeX: 0
+ relativeY: root.wrapper.height - root.rounding * 2
+ }
+ PathArc {
+ relativeX: root.roundingX
+ relativeY: root.rounding
+ radiusX: Math.min(root.rounding, root.wrapper.width)
+ radiusY: root.rounding
+ direction: PathArc.Counterclockwise
+ }
+ PathLine {
+ relativeX: root.wrapper.width - root.roundingX * 2
+ relativeY: 0
+ }
+ PathArc {
+ relativeX: root.roundingX
+ relativeY: root.rounding
+ radiusX: Math.min(root.rounding, root.wrapper.width)
+ radiusY: root.rounding
+ }
+
+ Behavior on fillColor {
+ ColorAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+}
diff --git a/modules/session/Content.qml b/modules/session/Content.qml
new file mode 100644
index 0000000..4f4747c
--- /dev/null
+++ b/modules/session/Content.qml
@@ -0,0 +1,119 @@
+pragma ComponentBehavior: Bound
+
+import "root:/widgets"
+import "root:/services"
+import "root:/config"
+import Quickshell
+import Quickshell.Io
+import QtQuick
+
+Column {
+ id: root
+
+ required property PersistentProperties visibilities
+
+ padding: Appearance.padding.large
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: parent.left
+
+ spacing: Appearance.spacing.large
+
+ SessionButton {
+ id: logout
+
+ icon: "logout"
+ command: ["uwsm", "stop"]
+
+ KeyNavigation.down: shutdown
+
+ Connections {
+ target: root.visibilities
+
+ function onSessionChanged(): void {
+ if (root.visibilities.session)
+ logout.focus = true;
+ }
+ }
+ }
+
+ SessionButton {
+ id: shutdown
+
+ icon: "power_settings_new"
+ command: ["systemctl", "poweroff"]
+
+ KeyNavigation.up: logout
+ KeyNavigation.down: hibernate
+ }
+
+ AnimatedImage {
+ width: SessionConfig.sizes.button
+ height: SessionConfig.sizes.button
+ sourceSize.width: width
+ sourceSize.height: height
+
+ playing: visible
+ asynchronous: true
+ speed: 0.7
+ source: "root:/assets/kurukuru.gif"
+ }
+
+ SessionButton {
+ id: hibernate
+
+ icon: "downloading"
+ command: ["systemctl", "hibernate"]
+
+ KeyNavigation.up: shutdown
+ KeyNavigation.down: reboot
+ }
+
+ SessionButton {
+ id: reboot
+
+ icon: "cached"
+ command: ["systemctl", "reboot"]
+
+ KeyNavigation.up: hibernate
+ }
+
+ component SessionButton: StyledRect {
+ id: button
+
+ required property string icon
+ required property list command
+
+ implicitWidth: SessionConfig.sizes.button
+ implicitHeight: SessionConfig.sizes.button
+
+ radius: Appearance.rounding.large
+ color: button.activeFocus ? Colours.palette.m3secondaryContainer : Colours.palette.m3surfaceContainer
+
+ Keys.onEnterPressed: proc.startDetached()
+ Keys.onReturnPressed: proc.startDetached()
+ Keys.onEscapePressed: root.visibilities.session = false
+
+ Process {
+ id: proc
+
+ command: button.command
+ }
+
+ StateLayer {
+ radius: parent.radius
+
+ function onClicked(): void {
+ proc.startDetached();
+ }
+ }
+
+ MaterialIcon {
+ anchors.centerIn: parent
+
+ text: button.icon
+ color: button.activeFocus ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface
+ font.pointSize: Appearance.font.size.extraLarge
+ }
+ }
+}
diff --git a/modules/session/Wrapper.qml b/modules/session/Wrapper.qml
new file mode 100644
index 0000000..db7105e
--- /dev/null
+++ b/modules/session/Wrapper.qml
@@ -0,0 +1,56 @@
+import "root:/services"
+import "root:/config"
+import Quickshell
+import QtQuick
+
+Item {
+ id: root
+
+ required property PersistentProperties visibilities
+
+ visible: width > 0
+ implicitWidth: 0
+ implicitHeight: content.implicitHeight
+
+ states: State {
+ name: "visible"
+ when: root.visibilities.session
+
+ PropertyChanges {
+ root.implicitWidth: content.implicitWidth
+ }
+ }
+
+ transitions: [
+ Transition {
+ from: ""
+ to: "visible"
+
+ NumberAnimation {
+ target: root
+ property: "implicitWidth"
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
+ }
+ },
+ Transition {
+ from: "visible"
+ to: ""
+
+ NumberAnimation {
+ target: root
+ property: "implicitWidth"
+ duration: root.visibilities.osd ? Appearance.anim.durations.expressiveFastSpatial : Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: root.visibilities.osd ? Appearance.anim.curves.expressiveFastSpatial : Appearance.anim.curves.emphasized
+ }
+ }
+ ]
+
+ Content {
+ id: content
+
+ visibilities: root.visibilities
+ }
+}
diff --git a/run.fish b/run.fish
new file mode 100755
index 0000000..0fddaee
--- /dev/null
+++ b/run.fish
@@ -0,0 +1,8 @@
+#!/bin/fish
+
+set -l dbus 'quickshell.dbus.properties.warning = false;quickshell.dbus.dbusmenu.warning = false' # System tray dbus property errors
+set -l notifs 'quickshell.service.notifications.warning = false' # Notification server warnings on reload
+set -l sni 'quickshell.service.sni.host.warning = false' # StatusNotifierItem warnings on reload
+set -l process 'QProcess: Destroyed while process' # Long running processes on reload
+
+qs -p (dirname (status filename)) --log-rules "$dbus;$notifs;$sni" | grep -vE -e $process
diff --git a/services/Apps.qml b/services/Apps.qml
new file mode 100644
index 0000000..e3ab276
--- /dev/null
+++ b/services/Apps.qml
@@ -0,0 +1,37 @@
+pragma Singleton
+
+import "root:/utils/scripts/fuzzysort.js" as Fuzzy
+import Quickshell
+import Quickshell.Io
+
+Singleton {
+ id: root
+
+ readonly property list list: DesktopEntries.applications.values.filter(a => !a.noDisplay).sort((a, b) => a.name.localeCompare(b.name))
+ readonly property list preppedApps: list.map(a => ({
+ name: Fuzzy.prepare(a.name),
+ comment: Fuzzy.prepare(a.comment),
+ entry: a
+ }))
+
+ function fuzzyQuery(search: string): var { // Idk why list doesn't work
+ return Fuzzy.go(search, preppedApps, {
+ all: true,
+ keys: ["name", "comment"],
+ scoreFn: r => r[0].score > 0 ? r[0].score * 0.9 + r[1].score * 0.1 : 0
+ }).map(r => r.obj.entry);
+ }
+
+ function launch(entry: DesktopEntry): void {
+ launchProc.entry = entry;
+ launchProc.startDetached();
+ }
+
+ Process {
+ id: launchProc
+
+ property DesktopEntry entry
+
+ command: ["app2unit", "--", `${entry?.id}.desktop`]
+ }
+}
diff --git a/services/Audio.qml b/services/Audio.qml
new file mode 100644
index 0000000..c91adbc
--- /dev/null
+++ b/services/Audio.qml
@@ -0,0 +1,25 @@
+pragma Singleton
+
+import Quickshell
+import Quickshell.Services.Pipewire
+
+Singleton {
+ id: root
+
+ readonly property PwNode sink: Pipewire.defaultAudioSink
+ readonly property PwNode source: Pipewire.defaultAudioSource
+
+ readonly property bool muted: sink?.audio?.muted ?? false
+ readonly property real volume: sink?.audio?.volume ?? 0
+
+ function setVolume(volume: real): void {
+ if (sink?.ready && sink?.audio) {
+ sink.audio.muted = false;
+ sink.audio.volume = volume;
+ }
+ }
+
+ PwObjectTracker {
+ objects: [Pipewire.defaultAudioSink, Pipewire.defaultAudioSource]
+ }
+}
diff --git a/services/BeatDetector.qml b/services/BeatDetector.qml
new file mode 100644
index 0000000..66bf2d4
--- /dev/null
+++ b/services/BeatDetector.qml
@@ -0,0 +1,18 @@
+pragma Singleton
+
+import Quickshell
+import Quickshell.Io
+
+Singleton {
+ id: root
+
+ property real bpm
+
+ Process {
+ running: true
+ command: [`${Quickshell.shellRoot}/assets/realtime-beat-detector.py`]
+ stdout: SplitParser {
+ onRead: data => root.bpm = parseFloat(data)
+ }
+ }
+}
diff --git a/services/Bluetooth.qml b/services/Bluetooth.qml
new file mode 100644
index 0000000..43f3636
--- /dev/null
+++ b/services/Bluetooth.qml
@@ -0,0 +1,80 @@
+pragma Singleton
+
+import Quickshell
+import Quickshell.Io
+import QtQuick
+
+Singleton {
+ id: root
+
+ property bool powered
+ property bool discovering
+ readonly property list devices: []
+
+ Process {
+ running: true
+ command: ["bluetoothctl"]
+ stdout: SplitParser {
+ onRead: {
+ getInfo.running = true;
+ getDevices.running = true;
+ }
+ }
+ }
+
+ Process {
+ id: getInfo
+ running: true
+ command: ["sh", "-c", "bluetoothctl show | paste -s"]
+ stdout: SplitParser {
+ onRead: data => {
+ root.powered = data.includes("Powered: yes");
+ root.discovering = data.includes("Discovering: yes");
+ }
+ }
+ }
+
+ Process {
+ id: getDevices
+ running: true
+ command: ["fish", "-c", `for a in (bluetoothctl devices | cut -d ' ' -f 2); bluetoothctl info $a | jq -R 'reduce (inputs / ":") as [$key, $value] ({}; .[$key | ltrimstr("\t")] = ($value | ltrimstr(" ")))' | jq -c --arg addr $a '.Address = $addr'; end | jq -sc`]
+ stdout: SplitParser {
+ onRead: data => {
+ const devices = JSON.parse(data).filter(d => d.Name);
+ const rDevices = root.devices;
+
+ const destroyed = rDevices.filter(rd => !devices.find(d => d.Address === rd.address));
+ for (const device of destroyed)
+ rDevices.splice(rDevices.indexOf(device), 1).forEach(d => d.destroy());
+
+ for (const device of devices) {
+ const match = rDevices.find(d => d.address === device.Address);
+ if (match) {
+ match.lastIpcObject = device;
+ } else {
+ rDevices.push(deviceComp.createObject(root, {
+ lastIpcObject: device
+ }));
+ }
+ }
+ }
+ }
+ }
+
+ component Device: QtObject {
+ required property var lastIpcObject
+ readonly property string name: lastIpcObject.Name
+ readonly property string alias: lastIpcObject.Alias
+ readonly property string address: lastIpcObject.Address
+ readonly property string icon: lastIpcObject.Icon
+ readonly property bool connected: lastIpcObject.Connected === "yes"
+ readonly property bool paired: lastIpcObject.Paired === "yes"
+ readonly property bool trusted: lastIpcObject.Trusted === "yes"
+ }
+
+ Component {
+ id: deviceComp
+
+ Device {}
+ }
+}
diff --git a/services/Brightness.qml b/services/Brightness.qml
new file mode 100644
index 0000000..1687878
--- /dev/null
+++ b/services/Brightness.qml
@@ -0,0 +1,118 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import "root:/widgets"
+import Quickshell
+import Quickshell.Io
+import QtQuick
+
+Singleton {
+ id: root
+
+ property var ddcMonitors: []
+ readonly property list monitors: variants.instances
+
+ function getMonitorForScreen(screen: ShellScreen): var {
+ return monitors.find(m => m.modelData === screen);
+ }
+
+ function increaseBrightness(): void {
+ const focusedName = Hyprland.focusedMonitor.name;
+ const monitor = monitors.find(m => focusedName === m.modelData.name);
+ if (monitor)
+ monitor.setBrightness(monitor.brightness + 0.1);
+ }
+
+ function decreaseBrightness(): void {
+ const focusedName = Hyprland.focusedMonitor.name;
+ const monitor = monitors.find(m => focusedName === m.modelData.name);
+ if (monitor)
+ monitor.setBrightness(monitor.brightness - 0.1);
+ }
+
+ reloadableId: "brightness"
+
+ onMonitorsChanged: {
+ ddcMonitors = [];
+ ddcProc.running = true;
+ }
+
+ Variants {
+ id: variants
+
+ model: Quickshell.screens
+
+ Monitor {}
+ }
+
+ Process {
+ id: ddcProc
+
+ command: ["ddcutil", "detect", "--brief"]
+ stdout: SplitParser {
+ splitMarker: "\n\n"
+ onRead: data => {
+ if (data.startsWith("Display ")) {
+ const lines = data.split("\n").map(l => l.trim());
+ root.ddcMonitors.push({
+ model: lines.find(l => l.startsWith("Monitor:")).split(":")[2],
+ busNum: lines.find(l => l.startsWith("I2C bus:")).split("/dev/i2c-")[1]
+ });
+ }
+ }
+ }
+ onExited: root.ddcMonitorsChanged()
+ }
+
+ Process {
+ id: setProc
+ }
+
+ CustomShortcut {
+ name: "brightnessUp"
+ onPressed: root.increaseBrightness()
+ }
+
+ CustomShortcut {
+ name: "brightnessDown"
+ onPressed: root.decreaseBrightness()
+ }
+
+ component Monitor: QtObject {
+ id: monitor
+
+ required property ShellScreen modelData
+ readonly property bool isDdc: root.ddcMonitors.some(m => m.model === modelData.model)
+ readonly property string busNum: root.ddcMonitors.find(m => m.model === modelData.model)?.busNum ?? ""
+ property real brightness
+
+ readonly property Process initProc: Process {
+ stdout: SplitParser {
+ onRead: data => {
+ const [, , , current, max] = data.split(" ");
+ monitor.brightness = parseInt(current) / parseInt(max);
+ }
+ }
+ }
+
+ function setBrightness(value: real): void {
+ value = Math.max(0, Math.min(1, value));
+ const rounded = Math.round(value * 100);
+ if (Math.round(brightness * 100) === rounded)
+ return;
+ brightness = value;
+ setProc.command = isDdc ? ["ddcutil", "-b", busNum, "setvcp", "10", rounded] : ["brightnessctl", "s", `${rounded}%`];
+ setProc.startDetached();
+ }
+
+ onBusNumChanged: {
+ initProc.command = isDdc ? ["ddcutil", "-b", busNum, "getvcp", "10", "--brief"] : ["sh", "-c", `echo "a b c $(brightnessctl g) $(brightnessctl m)"`];
+ initProc.running = true;
+ }
+
+ Component.onCompleted: {
+ initProc.command = isDdc ? ["ddcutil", "-b", busNum, "getvcp", "10", "--brief"] : ["sh", "-c", `echo "a b c $(brightnessctl g) $(brightnessctl m)"`];
+ initProc.running = true;
+ }
+ }
+}
diff --git a/services/Cava.qml b/services/Cava.qml
new file mode 100644
index 0000000..eaa6e20
--- /dev/null
+++ b/services/Cava.qml
@@ -0,0 +1,19 @@
+pragma Singleton
+
+import "root:/config"
+import Quickshell
+import Quickshell.Io
+
+Singleton {
+ id: root
+
+ property list values
+
+ Process {
+ running: true
+ command: ["sh", "-c", `printf '[general]\nframerate=60\nbars=${DashboardConfig.visualiserBars}\n[output]\nchannels=mono\nmethod=raw\nraw_target=/dev/stdout\ndata_format=ascii\nascii_max_range=100' | cava -p /dev/stdin`]
+ stdout: SplitParser {
+ onRead: data => root.values = data.slice(0, -1).split(";").map(v => parseInt(v, 10))
+ }
+ }
+}
diff --git a/services/Colours.qml b/services/Colours.qml
new file mode 100644
index 0000000..4383972
--- /dev/null
+++ b/services/Colours.qml
@@ -0,0 +1,153 @@
+pragma Singleton
+
+import "root:/config"
+import "root:/utils"
+import Quickshell
+import Quickshell.Io
+import QtQuick
+
+Singleton {
+ id: root
+
+ readonly property list colourNames: ["rosewater", "flamingo", "pink", "mauve", "red", "maroon", "peach", "yellow", "green", "teal", "sky", "sapphire", "blue", "lavender"]
+
+ property bool showPreview
+ property bool endPreviewOnNextChange
+ property bool light
+ readonly property Colours palette: showPreview ? preview : current
+ readonly property Colours current: Colours {}
+ readonly property Colours preview: Colours {}
+ readonly property Transparency transparency: Transparency {}
+
+ function alpha(c: color, layer: bool): color {
+ if (!transparency.enabled)
+ return c;
+ c = Qt.rgba(c.r, c.g, c.b, layer ? transparency.layers : transparency.base);
+ if (layer)
+ c.hsvValue = Math.max(0, Math.min(1, c.hslLightness + (light ? -0.2 : 0.2))); // TODO: edit based on colours (hue or smth)
+ return c;
+ }
+
+ function on(c: color): color {
+ if (c.hslLightness < 0.5)
+ return Qt.hsla(c.hslHue, c.hslSaturation, 0.9, 1);
+ return Qt.hsla(c.hslHue, c.hslSaturation, 0.1, 1);
+ }
+
+ function load(data: string, isPreview: bool): void {
+ const colours = isPreview ? preview : current;
+ for (const line of data.trim().split("\n")) {
+ let [name, colour] = line.split(" ");
+ name = name.trim();
+ name = colourNames.includes(name) ? name : `m3${name}`;
+ if (colours.hasOwnProperty(name))
+ colours[name] = `#${colour.trim()}`;
+ }
+
+ if (!isPreview || (isPreview && endPreviewOnNextChange)) {
+ showPreview = false;
+ endPreviewOnNextChange = false;
+ }
+ }
+
+ function setMode(mode: string): void {
+ setModeProc.command = ["caelestia", "scheme", "dynamic", "default", mode];
+ setModeProc.startDetached();
+ }
+
+ Process {
+ id: setModeProc
+ }
+
+ FileView {
+ path: `${Paths.state}/scheme/current-mode.txt`
+ watchChanges: true
+ onFileChanged: reload()
+ onLoaded: root.light = text() === "light"
+ }
+
+ FileView {
+ path: `${Paths.state}/scheme/current.txt`
+ watchChanges: true
+ onFileChanged: reload()
+ onLoaded: root.load(text(), false)
+ }
+
+ component Transparency: QtObject {
+ readonly property bool enabled: false
+ readonly property real base: 0.78
+ readonly property real layers: 0.58
+ }
+
+ component Colours: QtObject {
+ property color m3primary_paletteKeyColor: "#7870AB"
+ property color m3secondary_paletteKeyColor: "#78748A"
+ property color m3tertiary_paletteKeyColor: "#976A7D"
+ property color m3neutral_paletteKeyColor: "#79767D"
+ property color m3neutral_variant_paletteKeyColor: "#797680"
+ property color m3background: "#141318"
+ property color m3onBackground: "#E5E1E9"
+ property color m3surface: "#141318"
+ property color m3surfaceDim: "#141318"
+ property color m3surfaceBright: "#3A383E"
+ property color m3surfaceContainerLowest: "#0E0D13"
+ property color m3surfaceContainerLow: "#1C1B20"
+ property color m3surfaceContainer: "#201F25"
+ property color m3surfaceContainerHigh: "#2B292F"
+ property color m3surfaceContainerHighest: "#35343A"
+ property color m3onSurface: "#E5E1E9"
+ property color m3surfaceVariant: "#48454E"
+ property color m3onSurfaceVariant: "#C9C5D0"
+ property color m3inverseSurface: "#E5E1E9"
+ property color m3inverseOnSurface: "#312F36"
+ property color m3outline: "#938F99"
+ property color m3outlineVariant: "#48454E"
+ property color m3shadow: "#000000"
+ property color m3scrim: "#000000"
+ property color m3surfaceTint: "#C8BFFF"
+ property color m3primary: "#C8BFFF"
+ property color m3onPrimary: "#30285F"
+ property color m3primaryContainer: "#473F77"
+ property color m3onPrimaryContainer: "#E5DEFF"
+ property color m3inversePrimary: "#5F5791"
+ property color m3secondary: "#C9C3DC"
+ property color m3onSecondary: "#312E41"
+ property color m3secondaryContainer: "#484459"
+ property color m3onSecondaryContainer: "#E5DFF9"
+ property color m3tertiary: "#ECB8CD"
+ property color m3onTertiary: "#482536"
+ property color m3tertiaryContainer: "#B38397"
+ property color m3onTertiaryContainer: "#000000"
+ property color m3error: "#EA8DC1"
+ property color m3onError: "#690005"
+ property color m3errorContainer: "#93000A"
+ property color m3onErrorContainer: "#FFDAD6"
+ property color m3primaryFixed: "#E5DEFF"
+ property color m3primaryFixedDim: "#C8BFFF"
+ property color m3onPrimaryFixed: "#1B1149"
+ property color m3onPrimaryFixedVariant: "#473F77"
+ property color m3secondaryFixed: "#E5DFF9"
+ property color m3secondaryFixedDim: "#C9C3DC"
+ property color m3onSecondaryFixed: "#1C192B"
+ property color m3onSecondaryFixedVariant: "#484459"
+ property color m3tertiaryFixed: "#FFD8E7"
+ property color m3tertiaryFixedDim: "#ECB8CD"
+ property color m3onTertiaryFixed: "#301121"
+ property color m3onTertiaryFixedVariant: "#613B4C"
+
+ property color rosewater: "#B8C4FF"
+ property color flamingo: "#DBB9F8"
+ property color pink: "#F3B3E3"
+ property color mauve: "#D0BDFE"
+ property color red: "#F8B3D1"
+ property color maroon: "#F6B2DA"
+ property color peach: "#E4B7F4"
+ property color yellow: "#C3C0FF"
+ property color green: "#ADC6FF"
+ property color teal: "#D4BBFC"
+ property color sky: "#CBBEFF"
+ property color sapphire: "#BDC2FF"
+ property color blue: "#C7BFFF"
+ property color lavender: "#EAB5ED"
+ }
+}
diff --git a/services/Hyprland.qml b/services/Hyprland.qml
new file mode 100644
index 0000000..610fba2
--- /dev/null
+++ b/services/Hyprland.qml
@@ -0,0 +1,114 @@
+pragma Singleton
+
+import Quickshell
+import Quickshell.Io
+import Quickshell.Hyprland
+import QtQuick
+
+Singleton {
+ id: root
+
+ readonly property list clients: []
+ readonly property var workspaces: Hyprland.workspaces
+ readonly property var monitors: Hyprland.monitors
+ property Client activeClient: null
+ readonly property HyprlandWorkspace activeWorkspace: focusedMonitor?.activeWorkspace ?? null
+ readonly property HyprlandMonitor focusedMonitor: Hyprland.focusedMonitor
+ readonly property int activeWsId: activeWorkspace?.id ?? 1
+ property point cursorPos
+
+ function reload() {
+ Hyprland.refreshWorkspaces();
+ Hyprland.refreshMonitors();
+ getClients.running = true;
+ getActiveClient.running = true;
+ }
+
+ function dispatch(request: string): void {
+ Hyprland.dispatch(request);
+ }
+
+ Component.onCompleted: reload()
+
+ Connections {
+ target: Hyprland
+
+ function onRawEvent(event: HyprlandEvent): void {
+ if (!event.name.endsWith("v2"))
+ root.reload();
+ }
+ }
+
+ Process {
+ id: getClients
+ command: ["sh", "-c", "hyprctl -j clients | jq -c"]
+ stdout: SplitParser {
+ onRead: data => {
+ const clients = JSON.parse(data);
+ const rClients = root.clients;
+
+ const destroyed = rClients.filter(rc => !clients.find(c => c.address === rc.address));
+ for (const client of destroyed)
+ rClients.splice(rClients.indexOf(client), 1).forEach(c => c.destroy());
+
+ for (const client of clients) {
+ const match = rClients.find(c => c.address === client.address);
+ if (match) {
+ match.lastIpcObject = client;
+ } else {
+ rClients.push(clientComp.createObject(root, {
+ lastIpcObject: client
+ }));
+ }
+ }
+ }
+ }
+ }
+
+ Process {
+ id: getActiveClient
+ command: ["hyprctl", "-j", "activewindow"]
+ stdout: SplitParser {
+ splitMarker: ""
+ onRead: data => {
+ const client = JSON.parse(data);
+ const rClient = root.activeClient;
+ if (client.address) {
+ if (rClient)
+ rClient.lastIpcObject = client;
+ else
+ root.activeClient = clientComp.createObject(root, {
+ lastIpcObject: client
+ });
+ } else if (rClient) {
+ rClient.destroy();
+ root.activeClient = null;
+ }
+ }
+ }
+ }
+
+ component Client: QtObject {
+ required property var lastIpcObject
+ readonly property string address: lastIpcObject.address
+ readonly property string wmClass: lastIpcObject.class
+ readonly property string title: lastIpcObject.title
+ readonly property string initialClass: lastIpcObject.initialClass
+ readonly property string initialTitle: lastIpcObject.initialTitle
+ readonly property int x: lastIpcObject.at[0]
+ readonly property int y: lastIpcObject.at[1]
+ readonly property int width: lastIpcObject.size[0]
+ readonly property int height: lastIpcObject.size[1]
+ readonly property HyprlandWorkspace workspace: Hyprland.workspaces.values.find(w => w.id === lastIpcObject.workspace.id) ?? null
+ readonly property bool floating: lastIpcObject.floating
+ readonly property bool fullscreen: lastIpcObject.fullscreen
+ readonly property int pid: lastIpcObject.pid
+ readonly property int focusHistoryId: lastIpcObject.focusHistoryID
+ }
+
+ Component {
+ id: clientComp
+
+ Client {}
+ }
+}
diff --git a/services/Network.qml b/services/Network.qml
new file mode 100644
index 0000000..49d61f8
--- /dev/null
+++ b/services/Network.qml
@@ -0,0 +1,68 @@
+pragma Singleton
+
+import Quickshell
+import Quickshell.Io
+import QtQuick
+
+Singleton {
+ id: root
+
+ readonly property list networks: []
+ readonly property AccessPoint active: networks.find(n => n.active) ?? null
+
+ reloadableId: "network"
+
+ Process {
+ running: true
+ command: ["nmcli", "m"]
+ stdout: SplitParser {
+ onRead: getNetworks.running = true
+ }
+ }
+
+ Process {
+ id: getNetworks
+ running: true
+ command: ["sh", "-c", `nmcli -g ACTIVE,SIGNAL,FREQ,SSID d w | jq -cR '[(inputs / ":") | select(.[3] | length >= 4)]'`]
+ stdout: SplitParser {
+ onRead: data => {
+ const networks = JSON.parse(data).map(n => [n[0] === "yes", parseInt(n[1]), parseInt(n[2]), n[3]]);
+ const rNetworks = root.networks;
+
+ const destroyed = rNetworks.filter(rn => !networks.find(n => n[2] === rn.frequency && n[3] === rn.ssid));
+ for (const network of destroyed)
+ rNetworks.splice(rNetworks.indexOf(network), 1).forEach(n => n.destroy());
+
+ for (const network of networks) {
+ const match = rNetworks.find(n => n.frequency === network[2] && n.ssid === network[3]);
+ if (match) {
+ match.active = network[0];
+ match.strength = network[1];
+ match.frequency = network[2];
+ match.ssid = network[3];
+ } else {
+ rNetworks.push(apComp.createObject(root, {
+ active: network[0],
+ strength: network[1],
+ frequency: network[2],
+ ssid: network[3]
+ }));
+ }
+ }
+ }
+ }
+ }
+
+ component AccessPoint: QtObject {
+ required property string ssid
+ required property int strength
+ required property int frequency
+ required property bool active
+ }
+
+ Component {
+ id: apComp
+
+ AccessPoint {}
+ }
+}
diff --git a/services/Notifs.qml b/services/Notifs.qml
new file mode 100644
index 0000000..de53d89
--- /dev/null
+++ b/services/Notifs.qml
@@ -0,0 +1,98 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import "root:/widgets"
+import "root:/config"
+import Quickshell
+import Quickshell.Services.Notifications
+import QtQuick
+
+Singleton {
+ id: root
+
+ readonly property list list: []
+ readonly property list popups: list.filter(n => n.popup)
+
+ NotificationServer {
+ id: server
+
+ keepOnReload: false
+ actionsSupported: true
+ bodyHyperlinksSupported: true
+ bodyImagesSupported: true
+ bodyMarkupSupported: true
+ imageSupported: true
+
+ onNotification: notif => {
+ notif.tracked = true;
+
+ root.list.push(notifComp.createObject(root, {
+ popup: true,
+ notification: notif
+ }));
+ }
+ }
+
+ CustomShortcut {
+ name: "clearNotifs"
+ description: "Clear all notifications"
+ onPressed: {
+ for (const notif of root.list)
+ notif.popup = false;
+ }
+ }
+
+ component Notif: QtObject {
+ id: notif
+
+ property bool popup
+ readonly property date time: new Date()
+ readonly property string timeStr: {
+ const diff = Time.date.getTime() - time.getTime();
+ const m = Math.floor(diff / 60000);
+ const h = Math.floor(m / 60);
+
+ if (h < 1 && m < 1)
+ return "now";
+ if (h < 1)
+ return `${m}m`;
+ return `${h}h`;
+ }
+
+ required property Notification notification
+ readonly property string summary: notification.summary
+ readonly property string body: notification.body
+ readonly property string appIcon: notification.appIcon
+ readonly property string appName: notification.appName
+ readonly property string image: notification.image
+ readonly property var urgency: notification.urgency // Idk why NotificationUrgency doesn't work
+ readonly property list actions: notification.actions
+
+ readonly property Timer timer: Timer {
+ running: true
+ interval: notif.notification.expireTimeout > 0 ? notif.notification.expireTimeout : NotifsConfig.defaultExpireTimeout
+ onTriggered: {
+ if (NotifsConfig.expire)
+ notif.popup = false;
+ }
+ }
+
+ readonly property Connections conn: Connections {
+ target: notif.notification.Retainable
+
+ function onDropped(): void {
+ root.list.splice(root.list.indexOf(notif), 1);
+ }
+
+ function onAboutToDestroy(): void {
+ notif.destroy();
+ }
+ }
+ }
+
+ Component {
+ id: notifComp
+
+ Notif {}
+ }
+}
diff --git a/services/Players.qml b/services/Players.qml
new file mode 100644
index 0000000..cbaf550
--- /dev/null
+++ b/services/Players.qml
@@ -0,0 +1,59 @@
+pragma Singleton
+
+import "root:/widgets"
+import Quickshell
+import Quickshell.Io
+import Quickshell.Services.Mpris
+
+Singleton {
+ id: root
+
+ readonly property list list: Mpris.players.values
+ readonly property MprisPlayer active: manualActive ?? list.find(p => p.identity === "Spotify") ?? list[0] ?? null
+ property MprisPlayer manualActive
+
+ CustomShortcut {
+ name: "mediaToggle"
+ description: "Toggle media playback"
+ onPressed: {
+ const active = root.active;
+ if (active && active.canTogglePlaying)
+ active.togglePlaying();
+ }
+ }
+
+ CustomShortcut {
+ name: "mediaPrev"
+ description: "Previous track"
+ onPressed: {
+ const active = root.active;
+ if (active && active.canGoPrevious)
+ active.previous();
+ }
+ }
+
+ CustomShortcut {
+ name: "mediaNext"
+ description: "Next track"
+ onPressed: {
+ const active = root.active;
+ if (active && active.canGoNext)
+ active.next();
+ }
+ }
+
+ CustomShortcut {
+ name: "mediaStop"
+ description: "Stop media playback"
+ onPressed: root.active?.stop()
+ }
+
+ IpcHandler {
+ target: "mpris"
+
+ function getActive(prop: string): string {
+ const active = root.active;
+ return active ? active[prop] ?? "Invalid property" : "No active player";
+ }
+ }
+}
diff --git a/services/SystemUsage.qml b/services/SystemUsage.qml
new file mode 100644
index 0000000..1de0c57
--- /dev/null
+++ b/services/SystemUsage.qml
@@ -0,0 +1,173 @@
+pragma Singleton
+
+import Quickshell
+import Quickshell.Io
+import QtQuick
+
+Singleton {
+ id: root
+
+ property real cpuPerc
+ property real cpuTemp
+ property real gpuPerc
+ property real gpuTemp
+ property int memUsed
+ property int memTotal
+ readonly property real memPerc: memTotal > 0 ? memUsed / memTotal : 0
+ property int storageUsed
+ property int storageTotal
+ property real storagePerc: storageTotal > 0 ? storageUsed / storageTotal : 0
+
+ property int lastCpuIdle
+ property int lastCpuTotal
+
+ function formatKib(kib: int): var {
+ const mib = 1024;
+ const gib = 1024 ** 2;
+ const tib = 1024 ** 3;
+
+ if (kib >= tib)
+ return {
+ value: kib / tib,
+ unit: "TiB"
+ };
+ if (kib >= gib)
+ return {
+ value: kib / gib,
+ unit: "GiB"
+ };
+ if (kib >= mib)
+ return {
+ value: kib / mib,
+ unit: "MiB"
+ };
+ return {
+ value: kib,
+ unit: "KiB"
+ };
+ }
+
+ Timer {
+ running: true
+ interval: 3000
+ repeat: true
+ onTriggered: {
+ stat.reload();
+ meminfo.reload();
+ storage.running = true;
+ cpuTemp.running = true;
+ gpuUsage.running = true;
+ gpuTemp.running = true;
+ }
+ }
+
+ FileView {
+ id: stat
+
+ path: "/proc/stat"
+ onLoaded: {
+ const data = text().match(/^cpu\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/);
+ if (data) {
+ const stats = data.slice(1).map(n => parseInt(n, 10));
+ const total = stats.reduce((a, b) => a + b, 0);
+ const idle = stats[3];
+
+ const totalDiff = total - root.lastCpuTotal;
+ const idleDiff = idle - root.lastCpuIdle;
+ root.cpuPerc = totalDiff > 0 ? (1 - idleDiff / totalDiff) : 0;
+
+ root.lastCpuTotal = total;
+ root.lastCpuIdle = idle;
+ }
+ }
+ }
+
+ FileView {
+ id: meminfo
+
+ path: "/proc/meminfo"
+ onLoaded: {
+ const data = text();
+ root.memTotal = parseInt(data.match(/MemTotal: *(\d+)/)[1], 10) || 1;
+ root.memUsed = (root.memTotal - parseInt(data.match(/MemAvailable: *(\d+)/)[1], 10)) || 0;
+ }
+ }
+
+ Process {
+ id: storage
+
+ running: true
+ command: ["sh", "-c", "df | grep '^/dev/' | awk '{print $3, $4}'"]
+ stdout: SplitParser {
+ splitMarker: ""
+ onRead: data => {
+ let used = 0;
+ let avail = 0;
+ for (const line of data.trim().split("\n")) {
+ const [u, a] = line.split(" ");
+ used += parseInt(u, 10);
+ avail += parseInt(a, 10);
+ }
+ root.storageUsed = used;
+ root.storageTotal = used + avail;
+ }
+ }
+ }
+
+ Process {
+ id: cpuTemp
+
+ running: true
+ command: ["fish", "-c", "cat /sys/class/thermal/thermal_zone*/temp | string join ' '"]
+ stdout: SplitParser {
+ onRead: data => {
+ const temps = data.trim().split(" ");
+ const sum = temps.reduce((acc, d) => acc + parseInt(d, 10), 0);
+ root.cpuTemp = sum / temps.length / 1000;
+ }
+ }
+ }
+
+ Process {
+ id: gpuUsage
+
+ running: true
+ command: ["sh", "-c", "cat /sys/class/drm/card*/device/gpu_busy_percent"]
+ stdout: SplitParser {
+ splitMarker: ""
+ onRead: data => {
+ const percs = data.trim().split("\n");
+ const sum = percs.reduce((acc, d) => acc + parseInt(d, 10), 0);
+ root.gpuPerc = sum / percs.length / 100;
+ }
+ }
+ }
+
+ Process {
+ id: gpuTemp
+
+ running: true
+ command: ["sh", "-c", "sensors | jq -nRc '[inputs]'"]
+ stdout: SplitParser {
+ onRead: data => {
+ let eligible = false;
+ let sum = 0;
+ let count = 0;
+ for (const line of JSON.parse(data)) {
+ if (line === "Adapter: PCI adapter")
+ eligible = true;
+ else if (line === "")
+ eligible = false;
+ else if (eligible) {
+ const match = line.match(/^(temp[0-9]+|GPU core|edge)+:\s+\+([0-9]+\.[0-9]+)°C/);
+ if (match) {
+ sum += parseFloat(match[2]);
+ count++;
+ }
+ }
+ }
+ root.gpuTemp = count > 0 ? sum / count : 0;
+ }
+ }
+ }
+}
diff --git a/services/Thumbnailer.qml b/services/Thumbnailer.qml
new file mode 100644
index 0000000..6a6066c
--- /dev/null
+++ b/services/Thumbnailer.qml
@@ -0,0 +1,72 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import "root:/utils"
+import Quickshell
+import Quickshell.Io
+import QtQuick
+
+Singleton {
+ id: root
+
+ readonly property string thumbDir: `${Paths.cache}/thumbnails`.slice(7)
+
+ function go(obj: var): var {
+ return thumbComp.createObject(obj, {
+ originalPath: obj.path,
+ width: obj.width,
+ height: obj.height,
+ loadOriginal: obj.loadOriginal
+ });
+ }
+
+ component Thumbnail: QtObject {
+ id: obj
+
+ required property string originalPath
+ required property int width
+ required property int height
+ required property bool loadOriginal
+
+ property string path
+
+ readonly property Process proc: Process {
+ running: true
+ command: ["fish", "-c", `
+set -l path "${root.thumbDir}/$(sha1sum ${obj.originalPath} | cut -d ' ' -f 1)@${obj.width}x${obj.height}-exact.png"
+if test -f $path
+ echo $path
+else
+ echo 'start'
+ set -l size (identify -ping -format '%w\n%h' ${obj.originalPath})
+ if test $size[1] -gt ${obj.width} -o $size[2] -gt ${obj.height}
+ magick ${obj.originalPath} -${obj.width > 1024 || obj.height > 1024 ? "resize" : "thumbnail"} ${obj.width}x${obj.height}^ -background none -gravity center -extent ${obj.width}x${obj.height} -unsharp 0x.5 $path
+ else
+ cp ${obj.originalPath} $path
+ end
+ echo $path
+end`]
+ stdout: SplitParser {
+ onRead: data => {
+ if (data === "start") {
+ if (obj.loadOriginal)
+ obj.path = obj.originalPath;
+ } else {
+ obj.path = data;
+ }
+ }
+ }
+ }
+
+ function reload(): void {
+ proc.signal(9);
+ proc.running = true;
+ }
+ }
+
+ Component {
+ id: thumbComp
+
+ Thumbnail {}
+ }
+}
diff --git a/services/Time.qml b/services/Time.qml
new file mode 100644
index 0000000..c4b3913
--- /dev/null
+++ b/services/Time.qml
@@ -0,0 +1,20 @@
+pragma Singleton
+
+import Quickshell
+
+Singleton {
+ property alias enabled: clock.enabled
+ readonly property date date: clock.date
+ readonly property int hours: clock.hours
+ readonly property int minutes: clock.minutes
+ readonly property int seconds: clock.seconds
+
+ function format(fmt: string): string {
+ return Qt.formatDateTime(clock.date, fmt);
+ }
+
+ SystemClock {
+ id: clock
+ precision: SystemClock.Seconds
+ }
+}
diff --git a/services/Visibilities.qml b/services/Visibilities.qml
new file mode 100644
index 0000000..eb6ddfb
--- /dev/null
+++ b/services/Visibilities.qml
@@ -0,0 +1,12 @@
+pragma Singleton
+
+import Quickshell
+
+Singleton {
+ property var screens: ({})
+ property var panels: ({})
+
+ function getForActive(): PersistentProperties {
+ return Object.entries(screens).find(s => s[0].slice(s[0].indexOf('"') + 1, s[0].lastIndexOf('"')) === Hyprland.focusedMonitor.name)[1];
+ }
+}
diff --git a/services/Wallpapers.qml b/services/Wallpapers.qml
new file mode 100644
index 0000000..0b2efa0
--- /dev/null
+++ b/services/Wallpapers.qml
@@ -0,0 +1,102 @@
+pragma Singleton
+
+import "root:/utils/scripts/fuzzysort.js" as Fuzzy
+import "root:/utils"
+import Quickshell
+import Quickshell.Io
+import QtQuick
+
+Singleton {
+ id: root
+
+ readonly property string currentNamePath: `${Paths.state}/wallpaper/last.txt`.slice(7)
+ readonly property string path: `${Paths.pictures}/Wallpapers`.slice(7)
+
+ readonly property list list: wallpapers.instances
+ property bool showPreview: false
+ readonly property string current: showPreview ? previewPath : actualCurrent
+ property string previewPath
+ property string actualCurrent
+
+ readonly property list preppedWalls: list.map(w => ({
+ name: Fuzzy.prepare(w.name),
+ path: Fuzzy.prepare(w.path),
+ wall: w
+ }))
+
+ function fuzzyQuery(search: string): var {
+ return Fuzzy.go(search, preppedWalls, {
+ all: true,
+ keys: ["name", "path"],
+ scoreFn: r => r[0].score * 0.9 + r[1].score * 0.1
+ }).map(r => r.obj.wall);
+ }
+
+ function setWallpaper(path: string): void {
+ actualCurrent = path;
+ setWall.path = path;
+ setWall.startDetached();
+ }
+
+ function preview(path: string): void {
+ previewPath = path;
+ showPreview = true;
+ getPreviewColoursProc.running = true;
+ }
+
+ function stopPreview(): void {
+ showPreview = false;
+ Colours.endPreviewOnNextChange = true;
+ }
+
+ reloadableId: "wallpapers"
+
+ FileView {
+ path: root.currentNamePath
+ watchChanges: true
+ onFileChanged: reload()
+ onLoaded: root.actualCurrent = text().trim()
+ }
+
+ Process {
+ id: getPreviewColoursProc
+
+ command: ["caelestia", "scheme", "print", root.previewPath]
+ stdout: SplitParser {
+ splitMarker: ""
+ onRead: data => {
+ Colours.load(data, true);
+ Colours.showPreview = true;
+ }
+ }
+ }
+
+ Process {
+ id: setWall
+
+ property string path
+
+ command: ["caelestia", "wallpaper", "-f", path]
+ }
+
+ Process {
+ running: true
+ command: ["fd", ".", root.path, "-t", "f", "-e", "jpg", "-e", "jpeg", "-e", "png", "-e", "svg"]
+ stdout: SplitParser {
+ splitMarker: ""
+ onRead: data => wallpapers.model = data.trim().split("\n")
+ }
+ }
+
+ Variants {
+ id: wallpapers
+
+ Wallpaper {}
+ }
+
+ component Wallpaper: QtObject {
+ required property string modelData
+ readonly property string path: modelData
+ readonly property string name: path.slice(path.lastIndexOf("/") + 1, path.lastIndexOf("."))
+ }
+}
diff --git a/services/Weather.qml b/services/Weather.qml
new file mode 100644
index 0000000..13503f9
--- /dev/null
+++ b/services/Weather.qml
@@ -0,0 +1,32 @@
+pragma Singleton
+
+import "root:/utils"
+import Quickshell
+import Quickshell.Io
+
+Singleton {
+ id: root
+
+ property string icon
+ property string description
+ property real temperature
+
+ function reload(): void {
+ wttrProc.running = true;
+ }
+
+ Process {
+ id: wttrProc
+
+ running: true
+ command: ["fish", "-c", `curl "https://wttr.in/$(curl ipinfo.io | jq -r '.city' | string replace -a ' ' '%20')?format=j1" | jq -c '.current_condition[0] | {code: .weatherCode, desc: .weatherDesc[0].value, temp: .temp_C}'`]
+ stdout: SplitParser {
+ onRead: data => {
+ const json = JSON.parse(data);
+ root.icon = Icons.getWeatherIcon(json.code);
+ root.description = json.desc;
+ root.temperature = parseFloat(json.temp);
+ }
+ }
+ }
+}
diff --git a/shell.qml b/shell.qml
new file mode 100644
index 0000000..8369d28
--- /dev/null
+++ b/shell.qml
@@ -0,0 +1,10 @@
+import "modules"
+import "modules/drawers"
+import "modules/background"
+import Quickshell
+
+ShellRoot {
+ Drawers {}
+
+ Shortcuts {}
+}
diff --git a/utils/Icons.qml b/utils/Icons.qml
new file mode 100644
index 0000000..41ecbfa
--- /dev/null
+++ b/utils/Icons.qml
@@ -0,0 +1,222 @@
+pragma Singleton
+
+import Quickshell
+import Quickshell.Io
+
+Singleton {
+ id: root
+
+ readonly property var osIcons: ({
+ almalinux: "",
+ alpine: "",
+ arch: "",
+ archcraft: "",
+ arcolinux: "",
+ artix: "",
+ centos: "",
+ debian: "",
+ devuan: "",
+ elementary: "",
+ endeavouros: "",
+ fedora: "",
+ freebsd: "",
+ garuda: "",
+ gentoo: "",
+ hyperbola: "",
+ kali: "",
+ linuxmint: "",
+ mageia: "",
+ openmandriva: "",
+ manjaro: "",
+ neon: "",
+ nixos: "",
+ opensuse: "",
+ suse: "",
+ sles: "",
+ sles_sap: "",
+ "opensuse-tumbleweed": "",
+ parrot: "",
+ pop: "",
+ raspbian: "",
+ rhel: "",
+ rocky: "",
+ slackware: "",
+ solus: "",
+ steamos: "",
+ tails: "",
+ trisquel: "",
+ ubuntu: "",
+ vanilla: "",
+ void: "",
+ zorin: ""
+ })
+
+ readonly property var weatherIcons: ({
+ "113": "clear_day",
+ "116": "partly_cloudy_day",
+ "119": "cloud",
+ "122": "cloud",
+ "143": "foggy",
+ "176": "rainy",
+ "179": "rainy",
+ "182": "rainy",
+ "185": "rainy",
+ "200": "thunderstorm",
+ "227": "cloudy_snowing",
+ "230": "snowing_heavy",
+ "248": "foggy",
+ "260": "foggy",
+ "263": "rainy",
+ "266": "rainy",
+ "281": "rainy",
+ "284": "rainy",
+ "293": "rainy",
+ "296": "rainy",
+ "299": "rainy",
+ "302": "weather_hail",
+ "305": "rainy",
+ "308": "weather_hail",
+ "311": "rainy",
+ "314": "rainy",
+ "317": "rainy",
+ "320": "cloudy_snowing",
+ "323": "cloudy_snowing",
+ "326": "cloudy_snowing",
+ "329": "snowing_heavy",
+ "332": "snowing_heavy",
+ "335": "snowing",
+ "338": "snowing_heavy",
+ "350": "rainy",
+ "353": "rainy",
+ "356": "rainy",
+ "359": "weather_hail",
+ "362": "rainy",
+ "365": "rainy",
+ "368": "cloudy_snowing",
+ "371": "snowing",
+ "374": "rainy",
+ "377": "rainy",
+ "386": "thunderstorm",
+ "389": "thunderstorm",
+ "392": "thunderstorm",
+ "395": "snowing"
+ })
+
+ readonly property var desktopEntrySubs: ({})
+
+ readonly property var categoryIcons: ({
+ WebBrowser: "web",
+ Printing: "print",
+ Security: "security",
+ Network: "chat",
+ Archiving: "archive",
+ Compression: "archive",
+ Development: "code",
+ IDE: "code",
+ TextEditor: "edit_note",
+ Audio: "music_note",
+ Music: "music_note",
+ Player: "music_note",
+ Recorder: "mic",
+ Game: "sports_esports",
+ FileTools: "files",
+ FileManager: "files",
+ Filesystem: "files",
+ FileTransfer: "files",
+ Settings: "settings",
+ DesktopSettings: "settings",
+ HardwareSettings: "settings",
+ TerminalEmulator: "terminal",
+ ConsoleOnly: "terminal",
+ Utility: "build",
+ Monitor: "monitor_heart",
+ Midi: "graphic_eq",
+ Mixer: "graphic_eq",
+ AudioVideoEditing: "video_settings",
+ AudioVideo: "music_video",
+ Video: "videocam",
+ Building: "construction",
+ Graphics: "photo_library",
+ "2DGraphics": "photo_library",
+ RasterGraphics: "photo_library",
+ TV: "tv",
+ System: "host",
+ Office: "content_paste"
+ })
+
+ property string osIcon: ""
+ property string osName
+
+ function getDesktopEntry(name: string): DesktopEntry {
+ name = name.toLowerCase().replace(/ /g, "-");
+
+ if (desktopEntrySubs.hasOwnProperty(name))
+ name = desktopEntrySubs[name];
+
+ return DesktopEntries.applications.values.find(a => a.id.toLowerCase() === name) ?? null;
+ }
+
+ function getAppIcon(name: string, fallback: string): string {
+ return Quickshell.iconPath(getDesktopEntry(name)?.icon, fallback);
+ }
+
+ function getAppCategoryIcon(name: string, fallback: string): string {
+ const categories = getDesktopEntry(name)?.categories;
+
+ if (categories)
+ for (const [key, value] of Object.entries(categoryIcons))
+ if (categories.includes(key))
+ return value;
+ return fallback;
+ }
+
+ function getNetworkIcon(strength: int): string {
+ if (strength >= 80)
+ return "signal_wifi_4_bar";
+ if (strength >= 60)
+ return "network_wifi_3_bar";
+ if (strength >= 40)
+ return "network_wifi_2_bar";
+ if (strength >= 20)
+ return "network_wifi_1_bar";
+ return "signal_wifi_0_bar";
+ }
+
+ function getBluetoothIcon(icon: string): string {
+ if (icon.includes("headset") || icon.includes("headphones"))
+ return "headphones";
+ if (icon.includes("audio"))
+ return "speaker";
+ if (icon.includes("phone"))
+ return "smartphone";
+ return "bluetooth";
+ }
+
+ function getWeatherIcon(code: string): string {
+ if (weatherIcons.hasOwnProperty(code))
+ return weatherIcons[code];
+ return "air";
+ }
+
+ FileView {
+ path: "/etc/os-release"
+ onLoaded: {
+ const lines = text().split("\n");
+ let osId = lines.find(l => l.startsWith("ID="))?.split("=")[1];
+ if (root.osIcons.hasOwnProperty(osId))
+ root.osIcon = root.osIcons[osId];
+ else {
+ const osIdLike = lines.find(l => l.startsWith("ID_LIKE="))?.split("=")[1];
+ if (osIdLike)
+ for (const id of osIdLike.split(" "))
+ if (root.osIcons.hasOwnProperty(id))
+ return root.osIcon = root.osIcons[id];
+ }
+
+ let nameLine = lines.find(l => l.startsWith("PRETTY_NAME="));
+ if (!nameLine)
+ nameLine = lines.find(l => l.startsWith("NAME="));
+ root.osName = nameLine.split("=")[1].slice(1, -1);
+ }
+ }
+}
diff --git a/utils/Paths.qml b/utils/Paths.qml
new file mode 100644
index 0000000..011bb1f
--- /dev/null
+++ b/utils/Paths.qml
@@ -0,0 +1,15 @@
+pragma Singleton
+
+import Quickshell
+import Qt.labs.platform
+
+Singleton {
+ id: root
+
+ readonly property url home: StandardPaths.standardLocations(StandardPaths.HomeLocation)[0]
+ readonly property url pictures: StandardPaths.standardLocations(StandardPaths.PicturesLocation)[0]
+
+ readonly property url data: `${StandardPaths.standardLocations(StandardPaths.GenericDataLocation)[0]}/caelestia`
+ readonly property url state: `${StandardPaths.standardLocations(StandardPaths.GenericStateLocation)[0]}/caelestia`
+ readonly property url cache: `${StandardPaths.standardLocations(StandardPaths.GenericCacheLocation)[0]}/caelestia`
+}
diff --git a/utils/scripts/fuzzysort.js b/utils/scripts/fuzzysort.js
new file mode 100644
index 0000000..f308fc6
--- /dev/null
+++ b/utils/scripts/fuzzysort.js
@@ -0,0 +1,679 @@
+.pragma library
+
+var single = (search, target) => {
+ if(!search || !target) return NULL
+
+ var preparedSearch = getPreparedSearch(search)
+ if(!isPrepared(target)) target = getPrepared(target)
+
+ var searchBitflags = preparedSearch.bitflags
+ if((searchBitflags & target._bitflags) !== searchBitflags) return NULL
+
+ return algorithm(preparedSearch, target)
+}
+
+var go = (search, targets, options) => {
+ if(!search) return options?.all ? all(targets, options) : noResults
+
+ var preparedSearch = getPreparedSearch(search)
+ var searchBitflags = preparedSearch.bitflags
+ var containsSpace = preparedSearch.containsSpace
+
+ var threshold = denormalizeScore( options?.threshold || 0 )
+ var limit = options?.limit || INFINITY
+
+ var resultsLen = 0; var limitedCount = 0
+ var targetsLen = targets.length
+
+ function push_result(result) {
+ if(resultsLen < limit) { q.add(result); ++resultsLen }
+ else {
+ ++limitedCount
+ if(result._score > q.peek()._score) q.replaceTop(result)
+ }
+ }
+
+ // This code is copy/pasted 3 times for performance reasons [options.key, options.keys, no keys]
+
+ // options.key
+ if(options?.key) {
+ var key = options.key
+ for(var i = 0; i < targetsLen; ++i) { var obj = targets[i]
+ var target = getValue(obj, key)
+ if(!target) continue
+ if(!isPrepared(target)) target = getPrepared(target)
+
+ if((searchBitflags & target._bitflags) !== searchBitflags) continue
+ var result = algorithm(preparedSearch, target)
+ if(result === NULL) continue
+ if(result._score < threshold) continue
+
+ result.obj = obj
+ push_result(result)
+ }
+
+ // options.keys
+ } else if(options?.keys) {
+ var keys = options.keys
+ var keysLen = keys.length
+
+ outer: for(var i = 0; i < targetsLen; ++i) { var obj = targets[i]
+
+ { // early out based on bitflags
+ var keysBitflags = 0
+ for (var keyI = 0; keyI < keysLen; ++keyI) {
+ var key = keys[keyI]
+ var target = getValue(obj, key)
+ if(!target) { tmpTargets[keyI] = noTarget; continue }
+ if(!isPrepared(target)) target = getPrepared(target)
+ tmpTargets[keyI] = target
+
+ keysBitflags |= target._bitflags
+ }
+
+ if((searchBitflags & keysBitflags) !== searchBitflags) continue
+ }
+
+ if(containsSpace) for(let i=0; i -1000) {
+ if(keysSpacesBestScores[i] > NEGATIVE_INFINITY) {
+ var tmp = (keysSpacesBestScores[i] + allowPartialMatchScores[i]) / 4/*bonus score for having multiple matches*/
+ if(tmp > keysSpacesBestScores[i]) keysSpacesBestScores[i] = tmp
+ }
+ }
+ if(allowPartialMatchScores[i] > keysSpacesBestScores[i]) keysSpacesBestScores[i] = allowPartialMatchScores[i]
+ }
+ }
+
+ if(containsSpace) {
+ for(let i=0; i -1000) {
+ if(score > NEGATIVE_INFINITY) {
+ var tmp = (score + result._score) / 4/*bonus score for having multiple matches*/
+ if(tmp > score) score = tmp
+ }
+ }
+ if(result._score > score) score = result._score
+ }
+ }
+
+ objResults.obj = obj
+ objResults._score = score
+ if(options?.scoreFn) {
+ score = options.scoreFn(objResults)
+ if(!score) continue
+ score = denormalizeScore(score)
+ objResults._score = score
+ }
+
+ if(score < threshold) continue
+ push_result(objResults)
+ }
+
+ // no keys
+ } else {
+ for(var i = 0; i < targetsLen; ++i) { var target = targets[i]
+ if(!target) continue
+ if(!isPrepared(target)) target = getPrepared(target)
+
+ if((searchBitflags & target._bitflags) !== searchBitflags) continue
+ var result = algorithm(preparedSearch, target)
+ if(result === NULL) continue
+ if(result._score < threshold) continue
+
+ push_result(result)
+ }
+ }
+
+ if(resultsLen === 0) return noResults
+ var results = new Array(resultsLen)
+ for(var i = resultsLen - 1; i >= 0; --i) results[i] = q.poll()
+ results.total = resultsLen + limitedCount
+ return results
+}
+
+
+// this is written as 1 function instead of 2 for minification. perf seems fine ...
+// except when minified. the perf is very slow
+var highlight = (result, open='', close='') => {
+ var callback = typeof open === 'function' ? open : undefined
+
+ var target = result.target
+ var targetLen = target.length
+ var indexes = result.indexes
+ var highlighted = ''
+ var matchI = 0
+ var indexesI = 0
+ var opened = false
+ var parts = []
+
+ for(var i = 0; i < targetLen; ++i) { var char = target[i]
+ if(indexes[indexesI] === i) {
+ ++indexesI
+ if(!opened) { opened = true
+ if(callback) {
+ parts.push(highlighted); highlighted = ''
+ } else {
+ highlighted += open
+ }
+ }
+
+ if(indexesI === indexes.length) {
+ if(callback) {
+ highlighted += char
+ parts.push(callback(highlighted, matchI++)); highlighted = ''
+ parts.push(target.substr(i+1))
+ } else {
+ highlighted += char + close + target.substr(i+1)
+ }
+ break
+ }
+ } else {
+ if(opened) { opened = false
+ if(callback) {
+ parts.push(callback(highlighted, matchI++)); highlighted = ''
+ } else {
+ highlighted += close
+ }
+ }
+ }
+ highlighted += char
+ }
+
+ return callback ? parts : highlighted
+}
+
+
+var prepare = (target) => {
+ if(typeof target === 'number') target = ''+target
+ else if(typeof target !== 'string') target = ''
+ var info = prepareLowerInfo(target)
+ return new_result(target, {_targetLower:info._lower, _targetLowerCodes:info.lowerCodes, _bitflags:info.bitflags})
+}
+
+var cleanup = () => { preparedCache.clear(); preparedSearchCache.clear() }
+
+
+// Below this point is only internal code
+// Below this point is only internal code
+// Below this point is only internal code
+// Below this point is only internal code
+
+
+class Result {
+ get ['indexes']() { return this._indexes.slice(0, this._indexes.len).sort((a,b)=>a-b) }
+ set ['indexes'](indexes) { return this._indexes = indexes }
+ ['highlight'](open, close) { return highlight(this, open, close) }
+ get ['score']() { return normalizeScore(this._score) }
+ set ['score'](score) { this._score = denormalizeScore(score) }
+}
+
+class KeysResult extends Array {
+ get ['score']() { return normalizeScore(this._score) }
+ set ['score'](score) { this._score = denormalizeScore(score) }
+}
+
+var new_result = (target, options) => {
+ const result = new Result()
+ result['target'] = target
+ result['obj'] = options.obj ?? NULL
+ result._score = options._score ?? NEGATIVE_INFINITY
+ result._indexes = options._indexes ?? []
+ result._targetLower = options._targetLower ?? ''
+ result._targetLowerCodes = options._targetLowerCodes ?? NULL
+ result._nextBeginningIndexes = options._nextBeginningIndexes ?? NULL
+ result._bitflags = options._bitflags ?? 0
+ return result
+}
+
+
+var normalizeScore = score => {
+ if(score === NEGATIVE_INFINITY) return 0
+ if(score > 1) return score
+ return Math.E ** ( ((-score + 1)**.04307 - 1) * -2)
+}
+var denormalizeScore = normalizedScore => {
+ if(normalizedScore === 0) return NEGATIVE_INFINITY
+ if(normalizedScore > 1) return normalizedScore
+ return 1 - Math.pow((Math.log(normalizedScore) / -2 + 1), 1 / 0.04307)
+}
+
+
+var prepareSearch = (search) => {
+ if(typeof search === 'number') search = ''+search
+ else if(typeof search !== 'string') search = ''
+ search = search.trim()
+ var info = prepareLowerInfo(search)
+
+ var spaceSearches = []
+ if(info.containsSpace) {
+ var searches = search.split(/\s+/)
+ searches = [...new Set(searches)] // distinct
+ for(var i=0; i {
+ if(target.length > 999) return prepare(target) // don't cache huge targets
+ var targetPrepared = preparedCache.get(target)
+ if(targetPrepared !== undefined) return targetPrepared
+ targetPrepared = prepare(target)
+ preparedCache.set(target, targetPrepared)
+ return targetPrepared
+}
+var getPreparedSearch = (search) => {
+ if(search.length > 999) return prepareSearch(search) // don't cache huge searches
+ var searchPrepared = preparedSearchCache.get(search)
+ if(searchPrepared !== undefined) return searchPrepared
+ searchPrepared = prepareSearch(search)
+ preparedSearchCache.set(search, searchPrepared)
+ return searchPrepared
+}
+
+
+var all = (targets, options) => {
+ var results = []; results.total = targets.length // this total can be wrong if some targets are skipped
+
+ var limit = options?.limit || INFINITY
+
+ if(options?.key) {
+ for(var i=0;i= limit) return results
+ }
+ } else if(options?.keys) {
+ for(var i=0;i= 0; --keyI) {
+ var target = getValue(obj, options.keys[keyI])
+ if(!target) { objResults[keyI] = noTarget; continue }
+ if(!isPrepared(target)) target = getPrepared(target)
+ target._score = NEGATIVE_INFINITY
+ target._indexes.len = 0
+ objResults[keyI] = target
+ }
+ objResults.obj = obj
+ objResults._score = NEGATIVE_INFINITY
+ results.push(objResults); if(results.length >= limit) return results
+ }
+ } else {
+ for(var i=0;i= limit) return results
+ }
+ }
+
+ return results
+}
+
+
+var algorithm = (preparedSearch, prepared, allowSpaces=false, allowPartialMatch=false) => {
+ if(allowSpaces===false && preparedSearch.containsSpace) return algorithmSpaces(preparedSearch, prepared, allowPartialMatch)
+
+ var searchLower = preparedSearch._lower
+ var searchLowerCodes = preparedSearch.lowerCodes
+ var searchLowerCode = searchLowerCodes[0]
+ var targetLowerCodes = prepared._targetLowerCodes
+ var searchLen = searchLowerCodes.length
+ var targetLen = targetLowerCodes.length
+ var searchI = 0 // where we at
+ var targetI = 0 // where you at
+ var matchesSimpleLen = 0
+
+ // very basic fuzzy match; to remove non-matching targets ASAP!
+ // walk through target. find sequential matches.
+ // if all chars aren't found then exit
+ for(;;) {
+ var isMatch = searchLowerCode === targetLowerCodes[targetI]
+ if(isMatch) {
+ matchesSimple[matchesSimpleLen++] = targetI
+ ++searchI; if(searchI === searchLen) break
+ searchLowerCode = searchLowerCodes[searchI]
+ }
+ ++targetI; if(targetI >= targetLen) return NULL // Failed to find searchI
+ }
+
+ var searchI = 0
+ var successStrict = false
+ var matchesStrictLen = 0
+
+ var nextBeginningIndexes = prepared._nextBeginningIndexes
+ if(nextBeginningIndexes === NULL) nextBeginningIndexes = prepared._nextBeginningIndexes = prepareNextBeginningIndexes(prepared.target)
+ targetI = matchesSimple[0]===0 ? 0 : nextBeginningIndexes[matchesSimple[0]-1]
+
+ // Our target string successfully matched all characters in sequence!
+ // Let's try a more advanced and strict test to improve the score
+ // only count it as a match if it's consecutive or a beginning character!
+ var backtrackCount = 0
+ if(targetI !== targetLen) for(;;) {
+ if(targetI >= targetLen) {
+ // We failed to find a good spot for this search char, go back to the previous search char and force it forward
+ if(searchI <= 0) break // We failed to push chars forward for a better match
+
+ ++backtrackCount; if(backtrackCount > 200) break // exponential backtracking is taking too long, just give up and return a bad match
+
+ --searchI
+ var lastMatch = matchesStrict[--matchesStrictLen]
+ targetI = nextBeginningIndexes[lastMatch]
+
+ } else {
+ var isMatch = searchLowerCodes[searchI] === targetLowerCodes[targetI]
+ if(isMatch) {
+ matchesStrict[matchesStrictLen++] = targetI
+ ++searchI; if(searchI === searchLen) { successStrict = true; break }
+ ++targetI
+ } else {
+ targetI = nextBeginningIndexes[targetI]
+ }
+ }
+ }
+
+ // check if it's a substring match
+ var substringIndex = searchLen <= 1 ? -1 : prepared._targetLower.indexOf(searchLower, matchesSimple[0]) // perf: this is slow
+ var isSubstring = !!~substringIndex
+ var isSubstringBeginning = !isSubstring ? false : substringIndex===0 || prepared._nextBeginningIndexes[substringIndex-1] === substringIndex
+
+ // if it's a substring match but not at a beginning index, let's try to find a substring starting at a beginning index for a better score
+ if(isSubstring && !isSubstringBeginning) {
+ for(var i=0; i {
+ var score = 0
+
+ var extraMatchGroupCount = 0
+ for(var i = 1; i < searchLen; ++i) {
+ if(matches[i] - matches[i-1] !== 1) {score -= matches[i]; ++extraMatchGroupCount}
+ }
+ var unmatchedDistance = matches[searchLen-1] - matches[0] - (searchLen-1)
+
+ score -= (12+unmatchedDistance) * extraMatchGroupCount // penality for more groups
+
+ if(matches[0] !== 0) score -= matches[0]*matches[0]*.2 // penality for not starting near the beginning
+
+ if(!successStrict) {
+ score *= 1000
+ } else {
+ // successStrict on a target with too many beginning indexes loses points for being a bad target
+ var uniqueBeginningIndexes = 1
+ for(var i = nextBeginningIndexes[0]; i < targetLen; i=nextBeginningIndexes[i]) ++uniqueBeginningIndexes
+
+ if(uniqueBeginningIndexes > 24) score *= (uniqueBeginningIndexes-24)*10 // quite arbitrary numbers here ...
+ }
+
+ score -= (targetLen - searchLen)/2 // penality for longer targets
+
+ if(isSubstring) score /= 1+searchLen*searchLen*1 // bonus for being a full substring
+ if(isSubstringBeginning) score /= 1+searchLen*searchLen*1 // bonus for substring starting on a beginningIndex
+
+ score -= (targetLen - searchLen)/2 // penality for longer targets
+
+ return score
+ }
+
+ if(!successStrict) {
+ if(isSubstring) for(var i=0; i {
+ var seen_indexes = new Set()
+ var score = 0
+ var result = NULL
+
+ var first_seen_index_last_search = 0
+ var searches = preparedSearch.spaceSearches
+ var searchesLen = searches.length
+ var changeslen = 0
+
+ // Return _nextBeginningIndexes back to its normal state
+ var resetNextBeginningIndexes = () => {
+ for(let i=changeslen-1; i>=0; i--) target._nextBeginningIndexes[nextBeginningIndexesChanges[i*2 + 0]] = nextBeginningIndexesChanges[i*2 + 1]
+ }
+
+ var hasAtLeast1Match = false
+ for(var i=0; i=0; i--) {
+ if(toReplace !== target._nextBeginningIndexes[i]) break
+ target._nextBeginningIndexes[i] = newBeginningIndex
+ nextBeginningIndexesChanges[changeslen*2 + 0] = i
+ nextBeginningIndexesChanges[changeslen*2 + 1] = toReplace
+ changeslen++
+ }
+ }
+ }
+
+ score += result._score / searchesLen
+ allowPartialMatchScores[i] = result._score / searchesLen
+
+ // dock points based on order otherwise "c man" returns Manifest.cpp instead of CheatManager.h
+ if(result._indexes[0] < first_seen_index_last_search) {
+ score -= (first_seen_index_last_search - result._indexes[0]) * 2
+ }
+ first_seen_index_last_search = result._indexes[0]
+
+ for(var j=0; j score) {
+ if(allowPartialMatch) {
+ for(var i=0; i str.replace(/\p{Script=Latin}+/gu, match => match.normalize('NFD')).replace(/[\u0300-\u036f]/g, '')
+
+var prepareLowerInfo = (str) => {
+ str = remove_accents(str)
+ var strLen = str.length
+ var lower = str.toLowerCase()
+ var lowerCodes = [] // new Array(strLen) sparse array is too slow
+ var bitflags = 0
+ var containsSpace = false // space isn't stored in bitflags because of how searching with a space works
+
+ for(var i = 0; i < strLen; ++i) {
+ var lowerCode = lowerCodes[i] = lower.charCodeAt(i)
+
+ if(lowerCode === 32) {
+ containsSpace = true
+ continue // it's important that we don't set any bitflags for space
+ }
+
+ var bit = lowerCode>=97&&lowerCode<=122 ? lowerCode-97 // alphabet
+ : lowerCode>=48&&lowerCode<=57 ? 26 // numbers
+ // 3 bits available
+ : lowerCode<=127 ? 30 // other ascii
+ : 31 // other utf8
+ bitflags |= 1< {
+ var targetLen = target.length
+ var beginningIndexes = []; var beginningIndexesLen = 0
+ var wasUpper = false
+ var wasAlphanum = false
+ for(var i = 0; i < targetLen; ++i) {
+ var targetCode = target.charCodeAt(i)
+ var isUpper = targetCode>=65&&targetCode<=90
+ var isAlphanum = isUpper || targetCode>=97&&targetCode<=122 || targetCode>=48&&targetCode<=57
+ var isBeginning = isUpper && !wasUpper || !wasAlphanum || !isAlphanum
+ wasUpper = isUpper
+ wasAlphanum = isAlphanum
+ if(isBeginning) beginningIndexes[beginningIndexesLen++] = i
+ }
+ return beginningIndexes
+}
+var prepareNextBeginningIndexes = (target) => {
+ target = remove_accents(target)
+ var targetLen = target.length
+ var beginningIndexes = prepareBeginningIndexes(target)
+ var nextBeginningIndexes = [] // new Array(targetLen) sparse array is too slow
+ var lastIsBeginning = beginningIndexes[0]
+ var lastIsBeginningI = 0
+ for(var i = 0; i < targetLen; ++i) {
+ if(lastIsBeginning > i) {
+ nextBeginningIndexes[i] = lastIsBeginning
+ } else {
+ lastIsBeginning = beginningIndexes[++lastIsBeginningI]
+ nextBeginningIndexes[i] = lastIsBeginning===undefined ? targetLen : lastIsBeginning
+ }
+ }
+ return nextBeginningIndexes
+}
+
+var preparedCache = new Map()
+var preparedSearchCache = new Map()
+
+// the theory behind these being globals is to reduce garbage collection by not making new arrays
+var matchesSimple = []; var matchesStrict = []
+var nextBeginningIndexesChanges = [] // allows straw berry to match strawberry well, by modifying the end of a substring to be considered a beginning index for the rest of the search
+var keysSpacesBestScores = []; var allowPartialMatchScores = []
+var tmpTargets = []; var tmpResults = []
+
+// prop = 'key' 2.5ms optimized for this case, seems to be about as fast as direct obj[prop]
+// prop = 'key1.key2' 10ms
+// prop = ['key1', 'key2'] 27ms
+// prop = obj => obj.tags.join() ??ms
+var getValue = (obj, prop) => {
+ var tmp = obj[prop]; if(tmp !== undefined) return tmp
+ if(typeof prop === 'function') return prop(obj) // this should run first. but that makes string props slower
+ var segs = prop
+ if(!Array.isArray(prop)) segs = prop.split('.')
+ var len = segs.length
+ var i = -1
+ while (obj && (++i < len)) obj = obj[segs[i]]
+ return obj
+}
+
+var isPrepared = (x) => { return typeof x === 'object' && typeof x._bitflags === 'number' }
+var INFINITY = Infinity; var NEGATIVE_INFINITY = -INFINITY
+var noResults = []; noResults.total = 0
+var NULL = null
+
+var noTarget = prepare('')
+
+// Hacked version of https://github.com/lemire/FastPriorityQueue.js
+var fastpriorityqueue=r=>{var e=[],o=0,a={},v=r=>{for(var a=0,v=e[a],c=1;c>1]=e[a],c=1+(a<<1)}for(var f=a-1>>1;a>0&&v._score>1)e[a]=e[f];e[a]=v};return a.add=(r=>{var a=o;e[o++]=r;for(var v=a-1>>1;a>0&&r._score>1)e[a]=e[v];e[a]=r}),a.poll=(r=>{if(0!==o){var a=e[0];return e[0]=e[--o],v(),a}}),a.peek=(r=>{if(0!==o)return e[0]}),a.replaceTop=(r=>{e[0]=r,v()}),a}
+var q = fastpriorityqueue() // reuse this
+
diff --git a/widgets/CachingImage.qml b/widgets/CachingImage.qml
new file mode 100644
index 0000000..cd164b4
--- /dev/null
+++ b/widgets/CachingImage.qml
@@ -0,0 +1,14 @@
+import "root:/services"
+import QtQuick
+
+Image {
+ id: root
+
+ property string path
+ property bool loadOriginal
+ readonly property Thumbnailer.Thumbnail thumbnail: Thumbnailer.go(this)
+
+ source: thumbnail.path ? `file://${thumbnail.path}` : ""
+ asynchronous: true
+ fillMode: Image.PreserveAspectCrop
+}
diff --git a/widgets/Colouriser.qml b/widgets/Colouriser.qml
new file mode 100644
index 0000000..3c06f5a
--- /dev/null
+++ b/widgets/Colouriser.qml
@@ -0,0 +1,15 @@
+import "root:/config"
+import QtQuick
+import QtQuick.Effects
+
+MultiEffect {
+ colorization: 1
+
+ Behavior on colorizationColor {
+ ColorAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+}
diff --git a/widgets/CustomShortcut.qml b/widgets/CustomShortcut.qml
new file mode 100644
index 0000000..aa35ed8
--- /dev/null
+++ b/widgets/CustomShortcut.qml
@@ -0,0 +1,5 @@
+import Quickshell.Hyprland
+
+GlobalShortcut {
+ appid: "caelestia"
+}
diff --git a/widgets/MaterialIcon.qml b/widgets/MaterialIcon.qml
new file mode 100644
index 0000000..c86e5ce
--- /dev/null
+++ b/widgets/MaterialIcon.qml
@@ -0,0 +1,11 @@
+import "root:/config"
+
+StyledText {
+ property real fill
+
+ font.family: Appearance.font.family.material
+ font.pointSize: Appearance.font.size.larger
+ font.variableAxes: ({
+ FILL: fill.toFixed(1)
+ })
+}
diff --git a/widgets/StateLayer.qml b/widgets/StateLayer.qml
new file mode 100644
index 0000000..1c8f947
--- /dev/null
+++ b/widgets/StateLayer.qml
@@ -0,0 +1,43 @@
+import "root:/widgets"
+import "root:/services"
+import "root:/config"
+import QtQuick
+
+StyledRect {
+ id: root
+
+ readonly property alias hovered: mouse.hovered
+ readonly property alias pressed: mouse.pressed
+ property bool disabled
+
+ function onClicked(event: MouseEvent): void {
+ }
+
+ anchors.fill: parent
+
+ color: Colours.palette.m3onSurface
+ opacity: disabled ? 0 : mouse.pressed ? 0.1 : mouse.hovered ? 0.08 : 0
+
+ MouseArea {
+ id: mouse
+
+ property bool hovered
+
+ anchors.fill: parent
+ cursorShape: root.disabled ? undefined : Qt.PointingHandCursor
+ hoverEnabled: true
+
+ onEntered: hovered = true
+ onExited: hovered = false
+
+ onClicked: event => !root.disabled && root.onClicked(event)
+ }
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Appearance.anim.durations.small
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+}
diff --git a/widgets/StyledClippingRect.qml b/widgets/StyledClippingRect.qml
new file mode 100644
index 0000000..477ce86
--- /dev/null
+++ b/widgets/StyledClippingRect.qml
@@ -0,0 +1,17 @@
+import "root:/config"
+import Quickshell.Widgets
+import QtQuick
+
+ClippingRectangle {
+ id: root
+
+ color: "transparent"
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+}
diff --git a/widgets/StyledRect.qml b/widgets/StyledRect.qml
new file mode 100644
index 0000000..108e5e0
--- /dev/null
+++ b/widgets/StyledRect.qml
@@ -0,0 +1,16 @@
+import "root:/config"
+import QtQuick
+
+Rectangle {
+ id: root
+
+ color: "transparent"
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+}
diff --git a/widgets/StyledScrollBar.qml b/widgets/StyledScrollBar.qml
new file mode 100644
index 0000000..c655536
--- /dev/null
+++ b/widgets/StyledScrollBar.qml
@@ -0,0 +1,34 @@
+import "root:/services"
+import "root:/config"
+import QtQuick
+import QtQuick.Controls
+
+ScrollBar {
+ id: root
+
+ contentItem: StyledRect {
+ implicitWidth: 6
+ opacity: root.pressed ? 1 : root.policy === ScrollBar.AlwaysOn || (root.active && root.size < 1) ? 0.8 : 0
+ radius: Appearance.rounding.full
+ color: Colours.palette.m3secondary
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+ }
+
+ MouseArea {
+ z: -1
+ anchors.fill: parent
+ onWheel: event => {
+ if (event.angleDelta.y > 0)
+ root.decrease();
+ else if (event.angleDelta.y < 0)
+ root.increase();
+ }
+ }
+}
diff --git a/widgets/StyledText.qml b/widgets/StyledText.qml
new file mode 100644
index 0000000..5cd4986
--- /dev/null
+++ b/widgets/StyledText.qml
@@ -0,0 +1,52 @@
+pragma ComponentBehavior: Bound
+
+import "root:/services"
+import "root:/config"
+import QtQuick
+
+Text {
+ id: root
+
+ property bool animate: false
+ property string animateProp: "scale"
+ property real animateFrom: 0
+ property real animateTo: 1
+ property int animateDuration: Appearance.anim.durations.normal
+
+ renderType: Text.NativeRendering
+ textFormat: Text.PlainText
+ color: Colours.palette.m3onSurface
+ font.family: Appearance.font.family.sans
+ font.pointSize: Appearance.font.size.smaller
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+
+ Behavior on text {
+ enabled: root.animate
+
+ SequentialAnimation {
+ Anim {
+ to: root.animateFrom
+ easing.bezierCurve: Appearance.anim.curves.standardAccel
+ }
+ PropertyAction {}
+ Anim {
+ to: root.animateTo
+ easing.bezierCurve: Appearance.anim.curves.standardDecel
+ }
+ }
+ }
+
+ component Anim: NumberAnimation {
+ target: root
+ property: root.animateProp
+ duration: root.animateDuration / 2
+ easing.type: Easing.BezierSpline
+ }
+}
diff --git a/widgets/StyledTextField.qml b/widgets/StyledTextField.qml
new file mode 100644
index 0000000..1209ec1
--- /dev/null
+++ b/widgets/StyledTextField.qml
@@ -0,0 +1,68 @@
+pragma ComponentBehavior: Bound
+
+import "root:/services"
+import "root:/config"
+import QtQuick
+import QtQuick.Controls
+
+TextField {
+ id: root
+
+ color: Colours.palette.m3onSurface
+ placeholderTextColor: Colours.palette.m3outline
+ font.family: Appearance.font.family.sans
+ font.pointSize: Appearance.font.size.smaller
+
+ cursorDelegate: StyledRect {
+ id: cursor
+
+ property bool disableBlink
+
+ implicitWidth: 2
+ color: Colours.palette.m3primary
+ radius: Appearance.rounding.normal
+ onXChanged: {
+ opacity = 1;
+ disableBlink = true;
+ enableBlink.start();
+ }
+
+ Timer {
+ id: enableBlink
+
+ interval: 100
+ onTriggered: cursor.disableBlink = false
+ }
+
+ Timer {
+ running: root.cursorVisible && !cursor.disableBlink
+ repeat: true
+ interval: 500
+ onTriggered: parent.opacity = parent.opacity === 1 ? 0 : 1
+ }
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Appearance.anim.durations.small
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+
+ Behavior on placeholderTextColor {
+ ColorAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+}
diff --git a/widgets/StyledWindow.qml b/widgets/StyledWindow.qml
new file mode 100644
index 0000000..67280b6
--- /dev/null
+++ b/widgets/StyledWindow.qml
@@ -0,0 +1,11 @@
+import "root:/utils"
+import "root:/config"
+import Quickshell
+import Quickshell.Wayland
+
+PanelWindow {
+ required property string name
+
+ WlrLayershell.namespace: `caelestia-${name}`
+ color: "transparent"
+}
diff --git a/widgets/VerticalSlider.qml b/widgets/VerticalSlider.qml
new file mode 100644
index 0000000..2b807a1
--- /dev/null
+++ b/widgets/VerticalSlider.qml
@@ -0,0 +1,136 @@
+import "root:/widgets"
+import "root:/services"
+import "root:/config"
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Effects
+
+Slider {
+ id: root
+
+ required property string icon
+ property real oldValue
+
+ orientation: Qt.Vertical
+
+ background: StyledRect {
+ color: Colours.alpha(Colours.palette.m3surfaceContainer, true)
+ radius: Appearance.rounding.full
+
+ StyledRect {
+ anchors.left: parent.left
+ anchors.right: parent.right
+
+ y: root.handle.y
+ implicitHeight: parent.height - y
+
+ color: Colours.alpha(Colours.palette.m3secondary, true)
+ radius: Appearance.rounding.full
+ }
+ }
+
+ handle: Item {
+ id: handle
+
+ property bool moving
+
+ y: root.visualPosition * (root.availableHeight - height)
+ implicitWidth: root.width
+ implicitHeight: root.width
+
+ RectangularShadow {
+ anchors.fill: parent
+ radius: rect.radius
+ color: Colours.palette.m3shadow
+ blur: 5
+ spread: 0
+ }
+
+ StyledRect {
+ id: rect
+
+ anchors.fill: parent
+
+ color: Colours.alpha(Colours.palette.m3inverseSurface, true)
+ radius: Appearance.rounding.full
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onPressed: event => event.accepted = false
+ }
+
+ MaterialIcon {
+ id: icon
+
+ property bool moving: handle.moving
+
+ function update(): void {
+ animate = !moving;
+ text = moving ? Qt.binding(() => Math.round(root.value * 100)) : Qt.binding(() => root.icon);
+ font.pointSize = moving ? Appearance.font.size.small : Appearance.font.size.normal;
+ font.family = moving ? Appearance.font.family.sans : Appearance.font.family.material;
+ }
+
+ animate: true
+ text: root.icon
+ color: Colours.palette.m3inverseOnSurface
+ anchors.centerIn: parent
+
+ Behavior on moving {
+ SequentialAnimation {
+ NumberAnimation {
+ target: icon
+ property: "scale"
+ from: 1
+ to: 0
+ duration: Appearance.anim.durations.normal / 2
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standardAccel
+ }
+ ScriptAction {
+ script: icon.update()
+ }
+ NumberAnimation {
+ target: icon
+ property: "scale"
+ from: 0
+ to: 1
+ duration: Appearance.anim.durations.normal / 2
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standardDecel
+ }
+ }
+ }
+ }
+ }
+ }
+
+ onPressedChanged: handle.moving = pressed
+
+ onValueChanged: {
+ if (Math.abs(value - oldValue) < 0.01)
+ return;
+ oldValue = value;
+ handle.moving = true;
+ stateChangeDelay.restart();
+ }
+
+ Timer {
+ id: stateChangeDelay
+
+ interval: 500
+ onTriggered: {
+ if (!root.pressed)
+ handle.moving = false;
+ }
+ }
+
+ Behavior on value {
+ NumberAnimation {
+ duration: Appearance.anim.durations.large
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+}