.
diff --git a/.config/quickshell/caelestia/README.md b/.config/quickshell/caelestia/README.md
new file mode 100644
index 0000000..886f061
--- /dev/null
+++ b/.config/quickshell/caelestia/README.md
@@ -0,0 +1,763 @@
+caelestia-shell
+
+
+
+
+
+
+[](https://ko-fi.com/soramane)
+[](https://discord.gg/BGDCFCmMBk)
+
+
+
+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
+
+> [!NOTE]
+> This repo is for the desktop shell of the caelestia dots. If you want installation instructions
+> for the entire dots, head to [the main repo](https://github.com/caelestia-dots/caelestia) instead.
+
+### Arch linux
+
+> [!NOTE]
+> If you want to make your own changes/tweaks to the shell do NOT edit the files installed by the AUR
+> package. Instead, follow the instructions in the [manual installation section](#manual-installation).
+
+The shell is available from the AUR as `caelestia-shell`. You can install it with an AUR helper
+like [`yay`](https://github.com/Jguer/yay) or manually downloading the PKGBUILD and running `makepkg -si`.
+
+A package following the latest commit also exists as `caelestia-shell-git`. This is bleeding edge
+and likely to be unstable/have bugs. Regular users are recommended to use the stable package
+(`caelestia-shell`).
+
+### Nix
+
+You can run the shell directly via `nix run`:
+
+```sh
+nix run github:caelestia-dots/shell
+```
+
+Or add it to your system configuration:
+
+```nix
+{
+ inputs = {
+ nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
+
+ caelestia-shell = {
+ url = "github:caelestia-dots/shell";
+ inputs.nixpkgs.follows = "nixpkgs";
+ };
+ };
+}
+```
+
+The package is available as `caelestia-shell.packages..default`, which can be added to your
+`environment.systemPackages`, `users.users..packages`, `home.packages` if using home-manager,
+or a devshell. The shell can then be run via `caelestia-shell`.
+
+> [!TIP]
+> The default package does not have the CLI enabled by default, which is required for full funcionality.
+> To enable the CLI, use the `with-cli` package.
+
+For home-manager, you can also use the Caelestia's home manager module (explained in [configuring](https://github.com/caelestia-dots/shell?tab=readme-ov-file#home-manager-module)) that installs and configures the shell and the CLI.
+
+### Manual installation
+
+Dependencies:
+
+- [`caelestia-cli`](https://github.com/caelestia-dots/cli)
+- [`quickshell-git`](https://quickshell.outfoxxed.me) - this has to be the git version, not the latest tagged version
+- [`ddcutil`](https://github.com/rockowitz/ddcutil)
+- [`brightnessctl`](https://github.com/Hummer12007/brightnessctl)
+- [`app2unit`](https://github.com/Vladimir-csp/app2unit)
+- [`libcava`](https://github.com/LukashonakV/cava)
+- [`networkmanager`](https://networkmanager.dev)
+- [`lm-sensors`](https://github.com/lm-sensors/lm-sensors)
+- [`fish`](https://github.com/fish-shell/fish-shell)
+- [`aubio`](https://github.com/aubio/aubio)
+- [`libpipewire`](https://pipewire.org)
+- `glibc`
+- `qt6-declarative`
+- `gcc-libs`
+- [`material-symbols`](https://fonts.google.com/icons)
+- [`caskaydia-cove-nerd`](https://www.nerdfonts.com/font-downloads)
+- [`swappy`](https://github.com/jtheoof/swappy)
+- [`libqalculate`](https://github.com/Qalculate/libqalculate)
+- [`bash`](https://www.gnu.org/software/bash)
+- `qt6-base`
+- `qt6-declarative`
+
+Build dependencies:
+
+- [`cmake`](https://cmake.org)
+- [`ninja`](https://github.com/ninja-build/ninja)
+
+To install the shell manually, install all dependencies and clone this repo to `$XDG_CONFIG_HOME/quickshell/caelestia`.
+Then simply build and install using `cmake`.
+
+```sh
+cd $XDG_CONFIG_HOME/quickshell
+git clone https://github.com/caelestia-dots/shell.git caelestia
+
+cd caelestia
+cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/
+cmake --build build
+sudo cmake --install build
+```
+
+> [!TIP]
+> You can customise the installation location via the `cmake` flags `INSTALL_LIBDIR`, `INSTALL_QMLDIR` and
+> `INSTALL_QSCONFDIR` for the libraries (the beat detector), QML plugin and Quickshell config directories
+> respectively. If changing the library directory, remember to set the `CAELESTIA_LIB_DIR` environment
+> variable to the custom directory when launching the shell.
+>
+> e.g. installing to `~/.config/quickshell/caelestia` for easy local changes:
+>
+> ```sh
+> mkdir -p ~/.config/quickshell/caelestia
+> cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/ -DINSTALL_QSCONFDIR=~/.config/quickshell/caelestia
+> cmake --build build
+> sudo cmake --install build
+> sudo chown -R $USER ~/.config/quickshell/caelestia
+> ```
+
+## Usage
+
+The shell can be started via the `caelestia shell -d` command or `qs -c caelestia`.
+If the entire caelestia dots are installed, the shell will be autostarted on login
+via an `exec-once` in the hyprland config.
+
+### Shortcuts/IPC
+
+All keybinds are accessible via Hyprland [global shortcuts](https://wiki.hyprland.org/Configuring/Binds/#dbus-global-shortcuts).
+If using the entire caelestia dots, the keybinds are already configured for you.
+Otherwise, [this file](https://github.com/caelestia-dots/caelestia/blob/main/hypr/hyprland/keybinds.conf#L1-L39)
+contains an example on how to use global shortcuts.
+
+All IPC commands can be accessed via `caelestia shell ...`. For example
+
+```sh
+caelestia shell mpris getActive trackTitle
+```
+
+The list of IPC commands can be shown via `caelestia shell -s`:
+
+```
+$ caelestia shell -s
+target drawers
+ function toggle(drawer: string): void
+ function list(): string
+target notifs
+ function clear(): void
+target lock
+ function lock(): void
+ function unlock(): void
+ function isLocked(): bool
+target mpris
+ function playPause(): void
+ function getActive(prop: string): string
+ function next(): void
+ function stop(): void
+ function play(): void
+ function list(): string
+ function pause(): void
+ function previous(): void
+target picker
+ function openFreeze(): void
+ function open(): void
+target wallpaper
+ function set(path: string): void
+ function get(): string
+ function list(): string
+```
+
+### PFP/Wallpapers
+
+The profile picture for the dashboard is read from the file `~/.face`, so to set
+it you can copy your image to there or set it via the dashboard.
+
+The wallpapers for the wallpaper switcher are read from `~/Pictures/Wallpapers`
+by default. To change it, change the wallpapers path in `~/.config/caelestia/shell.json`.
+
+To set the wallpaper, you can use the command `caelestia wallpaper`. Use `caelestia wallpaper -h` for more info about
+the command.
+
+## Updating
+
+If installed via the AUR package, simply update your system (e.g. using `yay`).
+
+If installed manually, you can update by running `git pull` in `$XDG_CONFIG_HOME/quickshell/caelestia`.
+
+```sh
+cd $XDG_CONFIG_HOME/quickshell/caelestia
+git pull
+```
+
+## Configuring
+
+All configuration options should be put in `~/.config/caelestia/shell.json`. This file is _not_ created by
+default, you must create it manually.
+
+### Example configuration
+
+> [!NOTE]
+> The example configuration only includes recommended configuration options. For more advanced customisation
+> such as modifying the size of individual items or changing constants in the code, there are some other
+> options which can be found in the source files in the `config` directory.
+
+Example
+
+```json
+{
+ "appearance": {
+ "mediaGifSpeedAdjustment": 300,
+ "sessionGifSpeed": 0.7,
+ "anim": {
+ "durations": {
+ "scale": 1
+ }
+ },
+ "font": {
+ "family": {
+ "clock": "Rubik",
+ "material": "Material Symbols Rounded",
+ "mono": "CaskaydiaCove NF",
+ "sans": "Rubik"
+ },
+ "size": {
+ "scale": 1
+ }
+ },
+ "padding": {
+ "scale": 1
+ },
+ "rounding": {
+ "scale": 1
+ },
+ "spacing": {
+ "scale": 1
+ },
+ "transparency": {
+ "enabled": false,
+ "base": 0.85,
+ "layers": 0.4
+ }
+ },
+ "general": {
+ "logo": "caelestia",
+ "apps": {
+ "terminal": ["foot"],
+ "audio": ["pavucontrol"],
+ "playback": ["mpv"],
+ "explorer": ["thunar"]
+ },
+ "battery": {
+ "warnLevels": [
+ {
+ "level": 20,
+ "title": "Low battery",
+ "message": "You might want to plug in a charger",
+ "icon": "battery_android_frame_2"
+ },
+ {
+ "level": 10,
+ "title": "Did you see the previous message?",
+ "message": "You should probably plug in a charger now ",
+ "icon": "battery_android_frame_1"
+ },
+ {
+ "level": 5,
+ "title": "Critical battery level",
+ "message": "PLUG THE CHARGER RIGHT NOW!!",
+ "icon": "battery_android_alert",
+ "critical": true
+ }
+ ],
+ "criticalLevel": 3
+ },
+ "idle": {
+ "lockBeforeSleep": true,
+ "inhibitWhenAudio": true,
+ "timeouts": [
+ {
+ "timeout": 180,
+ "idleAction": "lock"
+ },
+ {
+ "timeout": 300,
+ "idleAction": "dpms off",
+ "returnAction": "dpms on"
+ },
+ {
+ "timeout": 600,
+ "idleAction": ["systemctl", "suspend-then-hibernate"]
+ }
+ ]
+ }
+ },
+ "background": {
+ "desktopClock": {
+ "enabled": false,
+ "scale": 1.0,
+ "position": "bottom-right",
+ "shadow": {
+ "enabled": true,
+ "opacity": 0.7,
+ "blur": 0.4
+ },
+ "background": {
+ "enabled": false,
+ "opacity": 0.7,
+ "blur": true
+ },
+ "invertColors": false
+ },
+ "enabled": true,
+ "visualiser": {
+ "blur": false,
+ "enabled": false,
+ "autoHide": true,
+ "rounding": 1,
+ "spacing": 1
+ }
+ },
+ "bar": {
+ "clock": {
+ "showIcon": true
+ },
+ "dragThreshold": 20,
+ "entries": [
+ {
+ "id": "logo",
+ "enabled": true
+ },
+ {
+ "id": "workspaces",
+ "enabled": true
+ },
+ {
+ "id": "spacer",
+ "enabled": true
+ },
+ {
+ "id": "activeWindow",
+ "enabled": true
+ },
+ {
+ "id": "spacer",
+ "enabled": true
+ },
+ {
+ "id": "tray",
+ "enabled": true
+ },
+ {
+ "id": "clock",
+ "enabled": true
+ },
+ {
+ "id": "statusIcons",
+ "enabled": true
+ },
+ {
+ "id": "power",
+ "enabled": true
+ }
+ ],
+ "persistent": true,
+ "popouts": {
+ "activeWindow": true,
+ "statusIcons": true,
+ "tray": true
+ },
+ "scrollActions": {
+ "brightness": true,
+ "workspaces": true,
+ "volume": true
+ },
+ "showOnHover": true,
+ "status": {
+ "showAudio": false,
+ "showBattery": true,
+ "showBluetooth": true,
+ "showKbLayout": false,
+ "showMicrophone": false,
+ "showNetwork": true,
+ "showWifi": true,
+ "showLockStatus": true
+ },
+ "tray": {
+ "background": false,
+ "compact": false,
+ "iconSubs": [],
+ "recolour": false
+ },
+ "workspaces": {
+ "activeIndicator": true,
+ "activeLabel": "",
+ "activeTrail": false,
+ "label": " ",
+ "occupiedBg": false,
+ "occupiedLabel": "",
+ "perMonitorWorkspaces": true,
+ "showWindows": true,
+ "shown": 5,
+ "specialWorkspaceIcons": [
+ {
+ "name": "steam",
+ "icon": "sports_esports"
+ }
+ ]
+ },
+ "excludedScreens": [""],
+ "activeWindow": {
+ "inverted": false
+ }
+ },
+ "border": {
+ "rounding": 25,
+ "thickness": 10
+ },
+ "dashboard": {
+ "enabled": true,
+ "dragThreshold": 50,
+ "mediaUpdateInterval": 500,
+ "showOnHover": true
+ },
+ "launcher": {
+ "actionPrefix": ">",
+ "actions": [
+ {
+ "name": "Calculator",
+ "icon": "calculate",
+ "description": "Do simple math equations (powered by Qalc)",
+ "command": ["autocomplete", "calc"],
+ "enabled": true,
+ "dangerous": false
+ },
+ {
+ "name": "Scheme",
+ "icon": "palette",
+ "description": "Change the current colour scheme",
+ "command": ["autocomplete", "scheme"],
+ "enabled": true,
+ "dangerous": false
+ },
+ {
+ "name": "Wallpaper",
+ "icon": "image",
+ "description": "Change the current wallpaper",
+ "command": ["autocomplete", "wallpaper"],
+ "enabled": true,
+ "dangerous": false
+ },
+ {
+ "name": "Variant",
+ "icon": "colors",
+ "description": "Change the current scheme variant",
+ "command": ["autocomplete", "variant"],
+ "enabled": true,
+ "dangerous": false
+ },
+ {
+ "name": "Transparency",
+ "icon": "opacity",
+ "description": "Change shell transparency",
+ "command": ["autocomplete", "transparency"],
+ "enabled": false,
+ "dangerous": false
+ },
+ {
+ "name": "Random",
+ "icon": "casino",
+ "description": "Switch to a random wallpaper",
+ "command": ["caelestia", "wallpaper", "-r"],
+ "enabled": true,
+ "dangerous": false
+ },
+ {
+ "name": "Light",
+ "icon": "light_mode",
+ "description": "Change the scheme to light mode",
+ "command": ["setMode", "light"],
+ "enabled": true,
+ "dangerous": false
+ },
+ {
+ "name": "Dark",
+ "icon": "dark_mode",
+ "description": "Change the scheme to dark mode",
+ "command": ["setMode", "dark"],
+ "enabled": true,
+ "dangerous": false
+ },
+ {
+ "name": "Shutdown",
+ "icon": "power_settings_new",
+ "description": "Shutdown the system",
+ "command": ["systemctl", "poweroff"],
+ "enabled": true,
+ "dangerous": true
+ },
+ {
+ "name": "Reboot",
+ "icon": "cached",
+ "description": "Reboot the system",
+ "command": ["systemctl", "reboot"],
+ "enabled": true,
+ "dangerous": true
+ },
+ {
+ "name": "Logout",
+ "icon": "exit_to_app",
+ "description": "Log out of the current session",
+ "command": ["loginctl", "terminate-user", ""],
+ "enabled": true,
+ "dangerous": true
+ },
+ {
+ "name": "Lock",
+ "icon": "lock",
+ "description": "Lock the current session",
+ "command": ["loginctl", "lock-session"],
+ "enabled": true,
+ "dangerous": false
+ },
+ {
+ "name": "Sleep",
+ "icon": "bedtime",
+ "description": "Suspend then hibernate",
+ "command": ["systemctl", "suspend-then-hibernate"],
+ "enabled": true,
+ "dangerous": false
+ },
+ {
+ "name": "Settings",
+ "icon": "settings",
+ "description": "Configure the shell",
+ "command": ["caelestia", "shell", "controlCenter", "open"],
+ "enabled": true,
+ "dangerous": false
+ }
+ ],
+ "dragThreshold": 50,
+ "vimKeybinds": false,
+ "enableDangerousActions": false,
+ "maxShown": 7,
+ "maxWallpapers": 9,
+ "specialPrefix": "@",
+ "useFuzzy": {
+ "apps": false,
+ "actions": false,
+ "schemes": false,
+ "variants": false,
+ "wallpapers": false
+ },
+ "showOnHover": false,
+ "favouriteApps": [],
+ "hiddenApps": []
+ },
+ "lock": {
+ "recolourLogo": false
+ },
+ "notifs": {
+ "actionOnClick": false,
+ "clearThreshold": 0.3,
+ "defaultExpireTimeout": 5000,
+ "expandThreshold": 20,
+ "openExpanded": false,
+ "expire": false
+ },
+ "osd": {
+ "enabled": true,
+ "enableBrightness": true,
+ "enableMicrophone": false,
+ "hideDelay": 2000
+ },
+ "paths": {
+ "mediaGif": "root:/assets/bongocat.gif",
+ "sessionGif": "root:/assets/kurukuru.gif",
+ "wallpaperDir": "~/Pictures/Wallpapers"
+ },
+ "services": {
+ "audioIncrement": 0.1,
+ "brightnessIncrement": 0.1,
+ "maxVolume": 1.0,
+ "defaultPlayer": "Spotify",
+ "gpuType": "",
+ "playerAliases": [{ "from": "com.github.th_ch.youtube_music", "to": "YT Music" }],
+ "weatherLocation": "",
+ "useFahrenheit": false,
+ "useFahrenheitPerformance": false,
+ "useTwelveHourClock": false,
+ "smartScheme": true,
+ "visualiserBars": 45
+ },
+ "session": {
+ "dragThreshold": 30,
+ "enabled": true,
+ "vimKeybinds": false,
+ "icons": {
+ "logout": "logout",
+ "shutdown": "power_settings_new",
+ "hibernate": "downloading",
+ "reboot": "cached"
+ },
+ "commands": {
+ "logout": ["loginctl", "terminate-user", ""],
+ "shutdown": ["systemctl", "poweroff"],
+ "hibernate": ["systemctl", "hibernate"],
+ "reboot": ["systemctl", "reboot"]
+ }
+ },
+ "sidebar": {
+ "dragThreshold": 80,
+ "enabled": true
+ },
+ "utilities": {
+ "enabled": true,
+ "maxToasts": 4,
+ "toasts": {
+ "audioInputChanged": true,
+ "audioOutputChanged": true,
+ "capsLockChanged": true,
+ "chargingChanged": true,
+ "configLoaded": true,
+ "dndChanged": true,
+ "gameModeChanged": true,
+ "kbLayoutChanged": true,
+ "kbLimit": true,
+ "numLockChanged": true,
+ "vpnChanged": true,
+ "nowPlaying": false
+ },
+ "vpn": {
+ "enabled": true,
+ "provider": [
+ {
+ "name": "wireguard",
+ "interface": "your-connection-name",
+ "displayName": "Wireguard (Your VPN)",
+ "enabled": false
+ }
+ ]
+ }
+ }
+}
+```
+
+
+
+### Home Manager Module
+
+For NixOS users, a home manager module is also available.
+
+home.nix
+
+```nix
+programs.caelestia = {
+ enable = true;
+ systemd = {
+ enable = false; # if you prefer starting from your compositor
+ target = "graphical-session.target";
+ environment = [];
+ };
+ settings = {
+ bar.status = {
+ showBattery = false;
+ };
+ paths.wallpaperDir = "~/Images";
+ };
+ cli = {
+ enable = true; # Also add caelestia-cli to path
+ settings = {
+ theme.enableGtk = false;
+ };
+ };
+};
+```
+
+The module automatically adds Caelestia shell to the path with **full functionality**. The CLI is not required, however you have the option to enable and configure it.
+
+
+
+## FAQ
+
+### Need help or support?
+
+You can join the community Discord server for assistance and discussion:
+https://discord.gg/BGDCFCmMBk
+
+### My screen is flickering, help pls!
+
+Try disabling VRR in the hyprland config. You can do this by adding the following to `~/.config/caelestia/hypr-user.conf`:
+
+```conf
+misc {
+ vrr = 0
+}
+```
+
+### I want to make my own changes to the hyprland config!
+
+You can add your custom hyprland configs to `~/.config/caelestia/hypr-user.conf`.
+
+### I want to make my own changes to other stuff!
+
+See the [manual installation](https://github.com/caelestia-dots/shell?tab=readme-ov-file#manual-installation) section
+for the corresponding repo.
+
+### I want to disable XXX feature!
+
+Please read the [configuring](https://github.com/caelestia-dots/shell?tab=readme-ov-file#configuring) section in the readme.
+If there is no corresponding option, make feature request.
+
+### How do I make my colour scheme change with my wallpaper?
+
+Set a wallpaper via the launcher or `caelestia wallpaper` and set the scheme to the dynamic scheme via the launcher
+or `caelestia scheme set`. e.g.
+
+```sh
+caelestia wallpaper -f
+caelestia scheme set -n dynamic
+```
+
+### My wallpapers aren't showing up in the launcher!
+
+The launcher pulls wallpapers from `~/Pictures/Wallpapers` by default. You can change this in the config. Additionally,
+the launcher only shows an odd number of wallpapers at one time. If you only have 2 wallpapers, consider getting more
+(or just putting one).
+
+## 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.
+
+Finally another thank you to all the configs I took inspiration from (only one for now):
+
+- [Axenide/Ax-Shell](https://github.com/Axenide/Ax-Shell)
+
+## Stonks 📈
+
+
+
+
+
+
+
+
diff --git a/.config/quickshell/caelestia/assets/bongocat.gif b/.config/quickshell/caelestia/assets/bongocat.gif
new file mode 100644
index 0000000..f960fec
Binary files /dev/null and b/.config/quickshell/caelestia/assets/bongocat.gif differ
diff --git a/.config/quickshell/caelestia/assets/dino.png b/.config/quickshell/caelestia/assets/dino.png
new file mode 100644
index 0000000..b5bc7bb
Binary files /dev/null and b/.config/quickshell/caelestia/assets/dino.png differ
diff --git a/.config/quickshell/caelestia/assets/kurukuru.gif b/.config/quickshell/caelestia/assets/kurukuru.gif
new file mode 100644
index 0000000..38d203d
Binary files /dev/null and b/.config/quickshell/caelestia/assets/kurukuru.gif differ
diff --git a/.config/quickshell/caelestia/assets/logo.svg b/.config/quickshell/caelestia/assets/logo.svg
new file mode 100644
index 0000000..6879c92
--- /dev/null
+++ b/.config/quickshell/caelestia/assets/logo.svg
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.config/quickshell/caelestia/assets/pam.d/fprint b/.config/quickshell/caelestia/assets/pam.d/fprint
new file mode 100644
index 0000000..d4814e9
--- /dev/null
+++ b/.config/quickshell/caelestia/assets/pam.d/fprint
@@ -0,0 +1,3 @@
+#%PAM-1.0
+
+auth required pam_fprintd.so max-tries=1
diff --git a/.config/quickshell/caelestia/assets/pam.d/passwd b/.config/quickshell/caelestia/assets/pam.d/passwd
new file mode 100644
index 0000000..4b14064
--- /dev/null
+++ b/.config/quickshell/caelestia/assets/pam.d/passwd
@@ -0,0 +1,6 @@
+#%PAM-1.0
+
+auth required pam_faillock.so preauth
+auth [success=1 default=bad] pam_unix.so nullok
+auth [default=die] pam_faillock.so authfail
+auth required pam_faillock.so authsucc
diff --git a/.config/quickshell/caelestia/assets/shaders/opacitymask.frag b/.config/quickshell/caelestia/assets/shaders/opacitymask.frag
new file mode 100644
index 0000000..94a80b8
--- /dev/null
+++ b/.config/quickshell/caelestia/assets/shaders/opacitymask.frag
@@ -0,0 +1,19 @@
+#version 440
+
+layout(location = 0) in vec2 qt_TexCoord0;
+layout(location = 0) out vec4 fragColor;
+
+layout(std140, binding = 0) uniform buf {
+ // qt_Matrix and qt_Opacity must always be both present
+ // if the built-in vertex shader is used.
+ mat4 qt_Matrix;
+ float qt_Opacity;
+};
+
+layout(binding = 1) uniform sampler2D source;
+layout(binding = 2) uniform sampler2D maskSource;
+
+void main()
+{
+ fragColor = texture(source, qt_TexCoord0.st) * (texture(maskSource, qt_TexCoord0.st).a) * qt_Opacity;
+}
diff --git a/.config/quickshell/caelestia/assets/shaders/opacitymask.frag.qsb b/.config/quickshell/caelestia/assets/shaders/opacitymask.frag.qsb
new file mode 100644
index 0000000..7bf97c2
Binary files /dev/null and b/.config/quickshell/caelestia/assets/shaders/opacitymask.frag.qsb differ
diff --git a/.config/quickshell/caelestia/assets/wrap_term_launch.sh b/.config/quickshell/caelestia/assets/wrap_term_launch.sh
new file mode 100755
index 0000000..caf60c7
--- /dev/null
+++ b/.config/quickshell/caelestia/assets/wrap_term_launch.sh
@@ -0,0 +1,5 @@
+#!/usr/bin/env sh
+
+cat ~/.local/state/caelestia/sequences.txt 2>/dev/null
+
+exec "$@"
diff --git a/.config/quickshell/caelestia/components/Anim.qml b/.config/quickshell/caelestia/components/Anim.qml
new file mode 100644
index 0000000..6883a79
--- /dev/null
+++ b/.config/quickshell/caelestia/components/Anim.qml
@@ -0,0 +1,8 @@
+import qs.config
+import QtQuick
+
+NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+}
diff --git a/.config/quickshell/caelestia/components/CAnim.qml b/.config/quickshell/caelestia/components/CAnim.qml
new file mode 100644
index 0000000..49484b7
--- /dev/null
+++ b/.config/quickshell/caelestia/components/CAnim.qml
@@ -0,0 +1,8 @@
+import qs.config
+import QtQuick
+
+ColorAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+}
diff --git a/.config/quickshell/caelestia/components/ConnectionHeader.qml b/.config/quickshell/caelestia/components/ConnectionHeader.qml
new file mode 100644
index 0000000..12b4276
--- /dev/null
+++ b/.config/quickshell/caelestia/components/ConnectionHeader.qml
@@ -0,0 +1,31 @@
+import qs.components
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ required property string icon
+ required property string title
+
+ spacing: Appearance.spacing.normal
+ Layout.alignment: Qt.AlignHCenter
+
+ MaterialIcon {
+ Layout.alignment: Qt.AlignHCenter
+ animate: true
+ text: root.icon
+ font.pointSize: Appearance.font.size.extraLarge * 3
+ font.bold: true
+ }
+
+ StyledText {
+ Layout.alignment: Qt.AlignHCenter
+ animate: true
+ text: root.title
+ font.pointSize: Appearance.font.size.large
+ font.bold: true
+ }
+}
diff --git a/.config/quickshell/caelestia/components/ConnectionInfoSection.qml b/.config/quickshell/caelestia/components/ConnectionInfoSection.qml
new file mode 100644
index 0000000..927ef28
--- /dev/null
+++ b/.config/quickshell/caelestia/components/ConnectionInfoSection.qml
@@ -0,0 +1,59 @@
+import qs.components
+import qs.components.effects
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ required property var deviceDetails
+
+ spacing: Appearance.spacing.small / 2
+
+ StyledText {
+ text: qsTr("IP Address")
+ }
+
+ StyledText {
+ text: root.deviceDetails?.ipAddress || qsTr("Not available")
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ }
+
+ StyledText {
+ Layout.topMargin: Appearance.spacing.normal
+ text: qsTr("Subnet Mask")
+ }
+
+ StyledText {
+ text: root.deviceDetails?.subnet || qsTr("Not available")
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ }
+
+ StyledText {
+ Layout.topMargin: Appearance.spacing.normal
+ text: qsTr("Gateway")
+ }
+
+ StyledText {
+ text: root.deviceDetails?.gateway || qsTr("Not available")
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ }
+
+ StyledText {
+ Layout.topMargin: Appearance.spacing.normal
+ text: qsTr("DNS Servers")
+ }
+
+ StyledText {
+ text: (root.deviceDetails && root.deviceDetails.dns && root.deviceDetails.dns.length > 0) ? root.deviceDetails.dns.join(", ") : qsTr("Not available")
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ wrapMode: Text.Wrap
+ Layout.maximumWidth: parent.width
+ }
+}
diff --git a/.config/quickshell/caelestia/components/Logo.qml b/.config/quickshell/caelestia/components/Logo.qml
new file mode 100644
index 0000000..3ab4f2b
--- /dev/null
+++ b/.config/quickshell/caelestia/components/Logo.qml
@@ -0,0 +1,69 @@
+import QtQuick
+import QtQuick.Shapes
+import qs.services
+
+Item {
+ id: root
+ implicitWidth: designWidth
+ implicitHeight: designHeight
+
+ readonly property real designWidth: 128
+ readonly property real designHeight: 90.38
+
+ property color topColour: Colours.palette.m3primary
+ property color bottomColour: Colours.palette.m3onSurface
+
+ Shape {
+ anchors.centerIn: parent
+ width: root.designWidth
+ height: root.designHeight
+ scale: Math.min(root.width / width, root.height / height)
+ transformOrigin: Item.Center
+ preferredRendererType: Shape.CurveRenderer
+
+ ShapePath {
+ fillColor: root.topColour
+ strokeColor: "transparent"
+
+ PathSvg {
+ path: "m42.56,42.96c-7.76,1.6-16.36,4.22-22.44,6.22-.49.16-.88-.44-.53-.82,5.37-5.85,9.66-13.3,9.66-13.3,8.66-14.67,22.97-23.51,39.85-21.14,6.47.91,12.33,3.38,17.26,6.98.99.72,1.14,2.14.31,3.04-.4.44-.95.67-1.51.67-.34,0-.69-.09-1-.26-3.21-1.84-6.82-2.69-10.71-3.24-13.1-1.84-25.41,4.75-31.06,15.83-.94,1.84-.61,3.81.45,5.21.22.3.07.72-.29.8Z"
+ }
+ }
+
+ ShapePath {
+ fillColor: root.bottomColour
+ strokeColor: "transparent"
+
+ PathSvg {
+ path: "m103.02,51.8c-.65.11-1.26-.37-1.28-1.03-.06-1.96.15-3.89-.2-5.78-.28-1.48-1.66-2.5-3.16-2.34h-.05c-6.53.73-24.63,3.1-48,9.32-6.89,1.83-9.83,10-5.67,15.79,4.62,6.44,11.84,10.93,20.41,12.13,11.82,1.66,22.99-3.36,29.21-12.65.54-.81,1.54-1.17,2.47-.86.91.3,1.47,1.15,1.47,2.04,0,.33-.08.66-.24.98-7.23,14.21-22.91,22.95-39.59,20.6-7.84-1.1-14.8-4.5-20.28-9.43,0,0,0,0-.02-.01-7.28-5.14-14.7-9.99-27.24-11.98-18.82-2.98-9.53-8.75.46-13.78,7.36-3.13,25.17-7.9,36.24-10.73.16-.03.31-.06.47-.1,1.52-.4,3.2-.83,5.02-1.29,1.06-.26,1.93-.48,2.58-.64.09-.02.18-.04.26-.06.31-.08.56-.14.73-.18.03,0,.06-.01.08-.02.03,0,.05-.01.07-.02.02,0,.04,0,.06-.01.01,0,.03,0,.04-.01,0,0,.02,0,.03,0,.01,0,.02,0,.02,0,10.62-2.58,24.63-5.62,37.74-7.34,1.02-.13,2.03-.26,3.03-.37,7.49-.87,14.58-1.26,20.42-.81,25.43,1.95-4.71,16.77-15.12,18.61Z"
+ }
+ }
+
+ ShapePath {
+ fillColor: root.topColour
+ strokeColor: "transparent"
+
+ PathSvg {
+ path: "m98.12.06c-.29,2.08-1.72,8.42-8.36,9.19-.09,0-.09.13,0,.14,6.64.78,8.07,7.11,8.36,9.19.01.08.13.08.14,0,.29-2.08,1.72-8.42,8.36-9.19.09,0,.09-.13,0-.14-6.64-.78-8.07-7.11-8.36-9.19-.01-.08-.13-.08-.14,0Z"
+ }
+ }
+
+ ShapePath {
+ fillColor: root.topColour
+ strokeColor: "transparent"
+
+ PathSvg {
+ path: "m113.36,15.5c-.22,1.29-1.08,4.35-4.38,4.87-.08.01-.08.13,0,.14,3.3.52,4.17,3.58,4.38,4.87.01.08.13.08.14,0,.22-1.29,1.08-4.35,4.38-4.87.08-.01.08-.13,0-.14-3.3-.52-4.17-3.58-4.38-4.87-.01-.08-.13-.08-.14,0Z"
+ }
+ }
+
+ ShapePath {
+ fillColor: root.topColour
+ strokeColor: "transparent"
+
+ PathSvg {
+ path: "m112.69,65.22c-.19,1.01-.86,3.15-3.2,3.57-.08.01-.08.13,0,.14,2.34.42,3.01,2.56,3.2,3.57.01.08.13.08.14,0,.19-1.01.86-3.15,3.2-3.57.08-.01.08-.13,0-.14-2.34-.42-3.01-2.56-3.2-3.57-.01-.08-.13-.08-.14,0Z"
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/components/MaterialIcon.qml b/.config/quickshell/caelestia/components/MaterialIcon.qml
new file mode 100644
index 0000000..a1d19d3
--- /dev/null
+++ b/.config/quickshell/caelestia/components/MaterialIcon.qml
@@ -0,0 +1,16 @@
+import qs.services
+import qs.config
+
+StyledText {
+ property real fill
+ property int grade: Colours.light ? 0 : -25
+
+ font.family: Appearance.font.family.material
+ font.pointSize: Appearance.font.size.larger
+ font.variableAxes: ({
+ FILL: fill.toFixed(1),
+ GRAD: grade,
+ opsz: fontInfo.pixelSize,
+ wght: fontInfo.weight
+ })
+}
diff --git a/.config/quickshell/caelestia/components/PropertyRow.qml b/.config/quickshell/caelestia/components/PropertyRow.qml
new file mode 100644
index 0000000..640d5f7
--- /dev/null
+++ b/.config/quickshell/caelestia/components/PropertyRow.qml
@@ -0,0 +1,26 @@
+import qs.components
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ required property string label
+ required property string value
+ property bool showTopMargin: false
+
+ spacing: Appearance.spacing.small / 2
+
+ StyledText {
+ Layout.topMargin: root.showTopMargin ? Appearance.spacing.normal : 0
+ text: root.label
+ }
+
+ StyledText {
+ text: root.value
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ }
+}
diff --git a/.config/quickshell/caelestia/components/SectionContainer.qml b/.config/quickshell/caelestia/components/SectionContainer.qml
new file mode 100644
index 0000000..2b653a5
--- /dev/null
+++ b/.config/quickshell/caelestia/components/SectionContainer.qml
@@ -0,0 +1,32 @@
+import qs.components
+import qs.components.effects
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+StyledRect {
+ id: root
+
+ default property alias content: contentColumn.data
+ property real contentSpacing: Appearance.spacing.larger
+ property bool alignTop: false
+
+ Layout.fillWidth: true
+ implicitHeight: contentColumn.implicitHeight + Appearance.padding.large * 2
+
+ radius: Appearance.rounding.normal
+ color: Colours.transparency.enabled ? Colours.layer(Colours.palette.m3surfaceContainer, 2) : Colours.palette.m3surfaceContainerHigh
+
+ ColumnLayout {
+ id: contentColumn
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: root.alignTop ? parent.top : undefined
+ anchors.verticalCenter: root.alignTop ? undefined : parent.verticalCenter
+ anchors.margins: Appearance.padding.large
+
+ spacing: root.contentSpacing
+ }
+}
diff --git a/.config/quickshell/caelestia/components/SectionHeader.qml b/.config/quickshell/caelestia/components/SectionHeader.qml
new file mode 100644
index 0000000..502e918
--- /dev/null
+++ b/.config/quickshell/caelestia/components/SectionHeader.qml
@@ -0,0 +1,27 @@
+import qs.components
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ required property string title
+ property string description: ""
+
+ spacing: 0
+
+ StyledText {
+ Layout.topMargin: Appearance.spacing.large
+ text: root.title
+ font.pointSize: Appearance.font.size.larger
+ font.weight: 500
+ }
+
+ StyledText {
+ visible: root.description !== ""
+ text: root.description
+ color: Colours.palette.m3outline
+ }
+}
diff --git a/.config/quickshell/caelestia/components/StateLayer.qml b/.config/quickshell/caelestia/components/StateLayer.qml
new file mode 100644
index 0000000..a20e266
--- /dev/null
+++ b/.config/quickshell/caelestia/components/StateLayer.qml
@@ -0,0 +1,95 @@
+import qs.services
+import qs.config
+import QtQuick
+
+MouseArea {
+ id: root
+
+ property bool disabled
+ property bool showHoverBackground: true
+ property color color: Colours.palette.m3onSurface
+ property real radius: parent?.radius ?? 0
+ property alias rect: hoverLayer
+
+ function onClicked(): void {
+ }
+
+ anchors.fill: parent
+
+ enabled: !disabled
+ cursorShape: disabled ? undefined : Qt.PointingHandCursor
+ hoverEnabled: true
+
+ onPressed: event => {
+ if (disabled)
+ return;
+
+ rippleAnim.x = event.x;
+ rippleAnim.y = event.y;
+
+ const dist = (ox, oy) => ox * ox + oy * oy;
+ rippleAnim.radius = Math.sqrt(Math.max(dist(event.x, event.y), dist(event.x, height - event.y), dist(width - event.x, event.y), dist(width - event.x, height - event.y)));
+
+ rippleAnim.restart();
+ }
+
+ onClicked: event => !disabled && onClicked(event)
+
+ 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.08
+ }
+ Anim {
+ target: ripple
+ properties: "implicitWidth,implicitHeight"
+ from: 0
+ to: rippleAnim.radius * 2
+ easing.bezierCurve: Appearance.anim.curves.standardDecel
+ }
+ Anim {
+ target: ripple
+ property: "opacity"
+ to: 0
+ }
+ }
+
+ StyledClippingRect {
+ id: hoverLayer
+
+ anchors.fill: parent
+
+ color: Qt.alpha(root.color, root.disabled ? 0 : root.pressed ? 0.12 : (root.showHoverBackground && root.containsMouse) ? 0.08 : 0)
+ radius: root.radius
+
+ StyledRect {
+ id: ripple
+
+ radius: Appearance.rounding.full
+ color: root.color
+ opacity: 0
+
+ transform: Translate {
+ x: -ripple.width / 2
+ y: -ripple.height / 2
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/components/StyledClippingRect.qml b/.config/quickshell/caelestia/components/StyledClippingRect.qml
new file mode 100644
index 0000000..8f2630c
--- /dev/null
+++ b/.config/quickshell/caelestia/components/StyledClippingRect.qml
@@ -0,0 +1,12 @@
+import Quickshell.Widgets
+import QtQuick
+
+ClippingRectangle {
+ id: root
+
+ color: "transparent"
+
+ Behavior on color {
+ CAnim {}
+ }
+}
diff --git a/.config/quickshell/caelestia/components/StyledRect.qml b/.config/quickshell/caelestia/components/StyledRect.qml
new file mode 100644
index 0000000..f5d5143
--- /dev/null
+++ b/.config/quickshell/caelestia/components/StyledRect.qml
@@ -0,0 +1,11 @@
+import QtQuick
+
+Rectangle {
+ id: root
+
+ color: "transparent"
+
+ Behavior on color {
+ CAnim {}
+ }
+}
diff --git a/.config/quickshell/caelestia/components/StyledText.qml b/.config/quickshell/caelestia/components/StyledText.qml
new file mode 100644
index 0000000..ed961d2
--- /dev/null
+++ b/.config/quickshell/caelestia/components/StyledText.qml
@@ -0,0 +1,48 @@
+pragma ComponentBehavior: Bound
+
+import qs.services
+import qs.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 {
+ CAnim {}
+ }
+
+ 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/.config/quickshell/caelestia/components/containers/StyledFlickable.qml b/.config/quickshell/caelestia/components/containers/StyledFlickable.qml
new file mode 100644
index 0000000..bc6ae0f
--- /dev/null
+++ b/.config/quickshell/caelestia/components/containers/StyledFlickable.qml
@@ -0,0 +1,14 @@
+import ".."
+import QtQuick
+
+Flickable {
+ id: root
+
+ maximumFlickVelocity: 3000
+
+ rebound: Transition {
+ Anim {
+ properties: "x,y"
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/components/containers/StyledListView.qml b/.config/quickshell/caelestia/components/containers/StyledListView.qml
new file mode 100644
index 0000000..626d206
--- /dev/null
+++ b/.config/quickshell/caelestia/components/containers/StyledListView.qml
@@ -0,0 +1,14 @@
+import ".."
+import QtQuick
+
+ListView {
+ id: root
+
+ maximumFlickVelocity: 3000
+
+ rebound: Transition {
+ Anim {
+ properties: "x,y"
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/components/containers/StyledWindow.qml b/.config/quickshell/caelestia/components/containers/StyledWindow.qml
new file mode 100644
index 0000000..8c6e39f
--- /dev/null
+++ b/.config/quickshell/caelestia/components/containers/StyledWindow.qml
@@ -0,0 +1,9 @@
+import Quickshell
+import Quickshell.Wayland
+
+PanelWindow {
+ required property string name
+
+ WlrLayershell.namespace: `caelestia-${name}`
+ color: "transparent"
+}
diff --git a/.config/quickshell/caelestia/components/controls/CircularIndicator.qml b/.config/quickshell/caelestia/components/controls/CircularIndicator.qml
new file mode 100644
index 0000000..957899e
--- /dev/null
+++ b/.config/quickshell/caelestia/components/controls/CircularIndicator.qml
@@ -0,0 +1,108 @@
+import ".."
+import qs.services
+import qs.config
+import Caelestia.Internal
+import QtQuick
+import QtQuick.Templates
+
+BusyIndicator {
+ id: root
+
+ enum AnimType {
+ Advance = 0,
+ Retreat
+ }
+
+ enum AnimState {
+ Stopped,
+ Running,
+ Completing
+ }
+
+ property real implicitSize: Appearance.font.size.normal * 3
+ property real strokeWidth: Appearance.padding.small * 0.8
+ property color fgColour: Colours.palette.m3primary
+ property color bgColour: Colours.palette.m3secondaryContainer
+
+ property alias type: manager.indeterminateAnimationType
+ readonly property alias progress: manager.progress
+
+ property real internalStrokeWidth: strokeWidth
+ property int animState
+
+ padding: 0
+ implicitWidth: implicitSize
+ implicitHeight: implicitSize
+
+ Component.onCompleted: {
+ if (running) {
+ running = false;
+ running = true;
+ }
+ }
+
+ onRunningChanged: {
+ if (running) {
+ manager.completeEndProgress = 0;
+ animState = CircularIndicator.Running;
+ } else {
+ if (animState == CircularIndicator.Running)
+ animState = CircularIndicator.Completing;
+ }
+ }
+
+ states: State {
+ name: "stopped"
+ when: !root.running
+
+ PropertyChanges {
+ root.opacity: 0
+ root.internalStrokeWidth: root.strokeWidth / 3
+ }
+ }
+
+ transitions: Transition {
+ Anim {
+ properties: "opacity,internalStrokeWidth"
+ duration: manager.completeEndDuration * Appearance.anim.durations.scale
+ }
+ }
+
+ contentItem: CircularProgress {
+ anchors.fill: parent
+ strokeWidth: root.internalStrokeWidth
+ fgColour: root.fgColour
+ bgColour: root.bgColour
+ padding: root.padding
+ rotation: manager.rotation
+ startAngle: manager.startFraction * 360
+ value: manager.endFraction - manager.startFraction
+ }
+
+ CircularIndicatorManager {
+ id: manager
+ }
+
+ NumberAnimation {
+ running: root.animState !== CircularIndicator.Stopped
+ loops: Animation.Infinite
+ target: manager
+ property: "progress"
+ from: 0
+ to: 1
+ duration: manager.duration * Appearance.anim.durations.scale
+ }
+
+ NumberAnimation {
+ running: root.animState === CircularIndicator.Completing
+ target: manager
+ property: "completeEndProgress"
+ from: 0
+ to: 1
+ duration: manager.completeEndDuration * Appearance.anim.durations.scale
+ onFinished: {
+ if (root.animState === CircularIndicator.Completing)
+ root.animState = CircularIndicator.Stopped;
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/components/controls/CircularProgress.qml b/.config/quickshell/caelestia/components/controls/CircularProgress.qml
new file mode 100644
index 0000000..a15cd90
--- /dev/null
+++ b/.config/quickshell/caelestia/components/controls/CircularProgress.qml
@@ -0,0 +1,69 @@
+import ".."
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Shapes
+
+Shape {
+ id: root
+
+ property real value
+ property int startAngle: -90
+ property int strokeWidth: Appearance.padding.smaller
+ property int padding: 0
+ property int spacing: Appearance.spacing.small
+ property color fgColour: Colours.palette.m3primary
+ property color bgColour: Colours.palette.m3secondaryContainer
+
+ readonly property real size: Math.min(width, height)
+ readonly property real arcRadius: (size - padding - strokeWidth) / 2
+ readonly property real vValue: value || 1 / 360
+ readonly property real gapAngle: ((spacing + strokeWidth) / (arcRadius || 1)) * (180 / Math.PI)
+
+ preferredRendererType: Shape.CurveRenderer
+ asynchronous: true
+
+ ShapePath {
+ fillColor: "transparent"
+ strokeColor: root.bgColour
+ strokeWidth: root.strokeWidth
+ capStyle: Appearance.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap
+
+ PathAngleArc {
+ startAngle: root.startAngle + 360 * root.vValue + root.gapAngle
+ sweepAngle: Math.max(-root.gapAngle, 360 * (1 - root.vValue) - root.gapAngle * 2)
+ radiusX: root.arcRadius
+ radiusY: root.arcRadius
+ centerX: root.size / 2
+ centerY: root.size / 2
+ }
+
+ Behavior on strokeColor {
+ CAnim {
+ duration: Appearance.anim.durations.large
+ }
+ }
+ }
+
+ ShapePath {
+ fillColor: "transparent"
+ strokeColor: root.fgColour
+ strokeWidth: root.strokeWidth
+ capStyle: Appearance.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap
+
+ PathAngleArc {
+ startAngle: root.startAngle
+ sweepAngle: 360 * root.vValue
+ radiusX: root.arcRadius
+ radiusY: root.arcRadius
+ centerX: root.size / 2
+ centerY: root.size / 2
+ }
+
+ Behavior on strokeColor {
+ CAnim {
+ duration: Appearance.anim.durations.large
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/components/controls/CollapsibleSection.qml b/.config/quickshell/caelestia/components/controls/CollapsibleSection.qml
new file mode 100644
index 0000000..e3d8eef
--- /dev/null
+++ b/.config/quickshell/caelestia/components/controls/CollapsibleSection.qml
@@ -0,0 +1,132 @@
+import ".."
+import qs.components
+import qs.components.effects
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ required property string title
+ property string description: ""
+ property bool expanded: false
+ property bool showBackground: false
+ property bool nested: false
+
+ signal toggleRequested
+
+ spacing: Appearance.spacing.small
+ Layout.fillWidth: true
+
+ Item {
+ id: sectionHeaderItem
+ Layout.fillWidth: true
+ Layout.preferredHeight: Math.max(titleRow.implicitHeight + Appearance.padding.normal * 2, 48)
+
+ RowLayout {
+ id: titleRow
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.leftMargin: Appearance.padding.normal
+ anchors.rightMargin: Appearance.padding.normal
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ text: root.title
+ font.pointSize: Appearance.font.size.larger
+ font.weight: 500
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ MaterialIcon {
+ text: "expand_more"
+ rotation: root.expanded ? 180 : 0
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.normal
+ Behavior on rotation {
+ Anim {
+ duration: Appearance.anim.durations.small
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+ }
+ }
+
+ StateLayer {
+ anchors.fill: parent
+ color: Colours.palette.m3onSurface
+ radius: Appearance.rounding.normal
+ showHoverBackground: false
+ function onClicked(): void {
+ root.toggleRequested();
+ root.expanded = !root.expanded;
+ }
+ }
+ }
+
+ default property alias content: contentColumn.data
+
+ Item {
+ id: contentWrapper
+ Layout.fillWidth: true
+ Layout.preferredHeight: root.expanded ? (contentColumn.implicitHeight + Appearance.spacing.small * 2) : 0
+ clip: true
+
+ Behavior on Layout.preferredHeight {
+ Anim {
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+
+ StyledRect {
+ id: backgroundRect
+ anchors.fill: parent
+ radius: Appearance.rounding.normal
+ color: Colours.transparency.enabled ? Colours.layer(Colours.palette.m3surfaceContainer, root.nested ? 3 : 2) : (root.nested ? Colours.palette.m3surfaceContainerHigh : Colours.palette.m3surfaceContainer)
+ opacity: root.showBackground && root.expanded ? 1.0 : 0.0
+ visible: root.showBackground
+
+ Behavior on opacity {
+ Anim {
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+ }
+
+ ColumnLayout {
+ id: contentColumn
+ anchors.left: parent.left
+ anchors.right: parent.right
+ y: Appearance.spacing.small
+ anchors.leftMargin: Appearance.padding.normal
+ anchors.rightMargin: Appearance.padding.normal
+ anchors.bottomMargin: Appearance.spacing.small
+ spacing: Appearance.spacing.small
+ opacity: root.expanded ? 1.0 : 0.0
+
+ Behavior on opacity {
+ Anim {
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+
+ StyledText {
+ id: descriptionText
+ Layout.fillWidth: true
+ Layout.topMargin: root.description !== "" ? Appearance.spacing.smaller : 0
+ Layout.bottomMargin: root.description !== "" ? Appearance.spacing.small : 0
+ visible: root.description !== ""
+ text: root.description
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.small
+ wrapMode: Text.Wrap
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/components/controls/CustomMouseArea.qml b/.config/quickshell/caelestia/components/controls/CustomMouseArea.qml
new file mode 100644
index 0000000..7c973c2
--- /dev/null
+++ b/.config/quickshell/caelestia/components/controls/CustomMouseArea.qml
@@ -0,0 +1,21 @@
+import QtQuick
+
+MouseArea {
+ property int scrollAccumulatedY: 0
+
+ function onWheel(event: WheelEvent): void {
+ }
+
+ onWheel: event => {
+ // Update accumulated scroll
+ if (Math.sign(event.angleDelta.y) !== Math.sign(scrollAccumulatedY))
+ scrollAccumulatedY = 0;
+ scrollAccumulatedY += event.angleDelta.y;
+
+ // Trigger handler and reset if above threshold
+ if (Math.abs(scrollAccumulatedY) >= 120) {
+ onWheel(event);
+ scrollAccumulatedY = 0;
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/components/controls/CustomSpinBox.qml b/.config/quickshell/caelestia/components/controls/CustomSpinBox.qml
new file mode 100644
index 0000000..438dc08
--- /dev/null
+++ b/.config/quickshell/caelestia/components/controls/CustomSpinBox.qml
@@ -0,0 +1,170 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+RowLayout {
+ id: root
+
+ property real value
+ property real max: Infinity
+ property real min: -Infinity
+ property real step: 1
+ property alias repeatRate: timer.interval
+
+ signal valueModified(value: real)
+
+ spacing: Appearance.spacing.small
+
+ property bool isEditing: false
+ property string displayText: root.value.toString()
+
+ onValueChanged: {
+ if (!root.isEditing) {
+ root.displayText = root.value.toString();
+ }
+ }
+
+ StyledTextField {
+ id: textField
+
+ inputMethodHints: Qt.ImhFormattedNumbersOnly
+ text: root.isEditing ? text : root.displayText
+ validator: DoubleValidator {
+ bottom: root.min
+ top: root.max
+ decimals: root.step < 1 ? Math.max(1, Math.ceil(-Math.log10(root.step))) : 0
+ }
+ onActiveFocusChanged: {
+ if (activeFocus) {
+ root.isEditing = true;
+ } else {
+ root.isEditing = false;
+ root.displayText = root.value.toString();
+ }
+ }
+ onAccepted: {
+ const numValue = parseFloat(text);
+ if (!isNaN(numValue)) {
+ const clampedValue = Math.max(root.min, Math.min(root.max, numValue));
+ root.value = clampedValue;
+ root.displayText = clampedValue.toString();
+ root.valueModified(clampedValue);
+ } else {
+ text = root.displayText;
+ }
+ root.isEditing = false;
+ }
+ onEditingFinished: {
+ if (text !== root.displayText) {
+ const numValue = parseFloat(text);
+ if (!isNaN(numValue)) {
+ const clampedValue = Math.max(root.min, Math.min(root.max, numValue));
+ root.value = clampedValue;
+ root.displayText = clampedValue.toString();
+ root.valueModified(clampedValue);
+ } else {
+ text = root.displayText;
+ }
+ }
+ root.isEditing = false;
+ }
+
+ padding: Appearance.padding.small
+ leftPadding: Appearance.padding.normal
+ rightPadding: Appearance.padding.normal
+
+ background: StyledRect {
+ implicitWidth: 100
+ radius: Appearance.rounding.small
+ color: Colours.tPalette.m3surfaceContainerHigh
+ }
+ }
+
+ StyledRect {
+ radius: Appearance.rounding.small
+ color: Colours.palette.m3primary
+
+ implicitWidth: implicitHeight
+ implicitHeight: upIcon.implicitHeight + Appearance.padding.small * 2
+
+ StateLayer {
+ id: upState
+
+ color: Colours.palette.m3onPrimary
+
+ onPressAndHold: timer.start()
+ onReleased: timer.stop()
+
+ function onClicked(): void {
+ let newValue = Math.min(root.max, root.value + root.step);
+ // Round to avoid floating point precision errors
+ const decimals = root.step < 1 ? Math.max(1, Math.ceil(-Math.log10(root.step))) : 0;
+ newValue = Math.round(newValue * Math.pow(10, decimals)) / Math.pow(10, decimals);
+ root.value = newValue;
+ root.displayText = newValue.toString();
+ root.valueModified(newValue);
+ }
+ }
+
+ MaterialIcon {
+ id: upIcon
+
+ anchors.centerIn: parent
+ text: "keyboard_arrow_up"
+ color: Colours.palette.m3onPrimary
+ }
+ }
+
+ StyledRect {
+ radius: Appearance.rounding.small
+ color: Colours.palette.m3primary
+
+ implicitWidth: implicitHeight
+ implicitHeight: downIcon.implicitHeight + Appearance.padding.small * 2
+
+ StateLayer {
+ id: downState
+
+ color: Colours.palette.m3onPrimary
+
+ onPressAndHold: timer.start()
+ onReleased: timer.stop()
+
+ function onClicked(): void {
+ let newValue = Math.max(root.min, root.value - root.step);
+ // Round to avoid floating point precision errors
+ const decimals = root.step < 1 ? Math.max(1, Math.ceil(-Math.log10(root.step))) : 0;
+ newValue = Math.round(newValue * Math.pow(10, decimals)) / Math.pow(10, decimals);
+ root.value = newValue;
+ root.displayText = newValue.toString();
+ root.valueModified(newValue);
+ }
+ }
+
+ MaterialIcon {
+ id: downIcon
+
+ anchors.centerIn: parent
+ text: "keyboard_arrow_down"
+ color: Colours.palette.m3onPrimary
+ }
+ }
+
+ Timer {
+ id: timer
+
+ interval: 100
+ repeat: true
+ triggeredOnStart: true
+ onTriggered: {
+ if (upState.pressed)
+ upState.onClicked();
+ else if (downState.pressed)
+ downState.onClicked();
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/components/controls/FilledSlider.qml b/.config/quickshell/caelestia/components/controls/FilledSlider.qml
new file mode 100644
index 0000000..80dd44c
--- /dev/null
+++ b/.config/quickshell/caelestia/components/controls/FilledSlider.qml
@@ -0,0 +1,146 @@
+import ".."
+import "../effects"
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Templates
+
+Slider {
+ id: root
+
+ required property string icon
+ property real oldValue
+ property bool initialized
+
+ orientation: Qt.Vertical
+
+ background: StyledRect {
+ color: Colours.layer(Colours.palette.m3surfaceContainer, 2)
+ radius: Appearance.rounding.full
+
+ StyledRect {
+ anchors.left: parent.left
+ anchors.right: parent.right
+
+ y: root.handle.y
+ implicitHeight: parent.height - y
+
+ color: Colours.palette.m3secondary
+ radius: parent.radius
+ }
+ }
+
+ handle: Item {
+ id: handle
+
+ property alias moving: icon.moving
+
+ y: root.visualPosition * (root.availableHeight - height)
+ implicitWidth: root.width
+ implicitHeight: root.width
+
+ Elevation {
+ anchors.fill: parent
+ radius: rect.radius
+ level: handleInteraction.containsMouse ? 2 : 1
+ }
+
+ StyledRect {
+ id: rect
+
+ anchors.fill: parent
+
+ color: Colours.palette.m3inverseSurface
+ radius: Appearance.rounding.full
+
+ MouseArea {
+ id: handleInteraction
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ acceptedButtons: Qt.NoButton
+ }
+
+ MaterialIcon {
+ id: icon
+
+ property bool moving
+
+ function update(): void {
+ animate = !moving;
+ binding.when = moving;
+ font.pointSize = moving ? Appearance.font.size.small : Appearance.font.size.larger;
+ font.family = moving ? Appearance.font.family.sans : Appearance.font.family.material;
+ }
+
+ text: root.icon
+ color: Colours.palette.m3inverseOnSurface
+ anchors.centerIn: parent
+
+ onMovingChanged: anim.restart()
+
+ Binding {
+ id: binding
+
+ target: icon
+ property: "text"
+ value: Math.round(root.value * 100)
+ when: false
+ }
+
+ SequentialAnimation {
+ id: anim
+
+ Anim {
+ target: icon
+ property: "scale"
+ to: 0
+ duration: Appearance.anim.durations.normal / 2
+ easing.bezierCurve: Appearance.anim.curves.standardAccel
+ }
+ ScriptAction {
+ script: icon.update()
+ }
+ Anim {
+ target: icon
+ property: "scale"
+ to: 1
+ duration: Appearance.anim.durations.normal / 2
+ easing.bezierCurve: Appearance.anim.curves.standardDecel
+ }
+ }
+ }
+ }
+ }
+
+ onPressedChanged: handle.moving = pressed
+
+ onValueChanged: {
+ if (!initialized) {
+ initialized = true;
+ return;
+ }
+ 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 {
+ Anim {
+ duration: Appearance.anim.durations.large
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/components/controls/IconButton.qml b/.config/quickshell/caelestia/components/controls/IconButton.qml
new file mode 100644
index 0000000..ffb1d06
--- /dev/null
+++ b/.config/quickshell/caelestia/components/controls/IconButton.qml
@@ -0,0 +1,83 @@
+import ".."
+import qs.services
+import qs.config
+import QtQuick
+
+StyledRect {
+ id: root
+
+ enum Type {
+ Filled,
+ Tonal,
+ Text
+ }
+
+ property alias icon: label.text
+ property bool checked
+ property bool toggle
+ property real padding: type === IconButton.Text ? Appearance.padding.small / 2 : Appearance.padding.smaller
+ property alias font: label.font
+ property int type: IconButton.Filled
+ property bool disabled
+
+ property alias stateLayer: stateLayer
+ property alias label: label
+ property alias radiusAnim: radiusAnim
+
+ property bool internalChecked
+ property color activeColour: type === IconButton.Filled ? Colours.palette.m3primary : Colours.palette.m3secondary
+ property color inactiveColour: {
+ if (!toggle && type === IconButton.Filled)
+ return Colours.palette.m3primary;
+ return type === IconButton.Filled ? Colours.tPalette.m3surfaceContainer : Colours.palette.m3secondaryContainer;
+ }
+ property color activeOnColour: type === IconButton.Filled ? Colours.palette.m3onPrimary : type === IconButton.Tonal ? Colours.palette.m3onSecondary : Colours.palette.m3primary
+ property color inactiveOnColour: {
+ if (!toggle && type === IconButton.Filled)
+ return Colours.palette.m3onPrimary;
+ return type === IconButton.Tonal ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurfaceVariant;
+ }
+ property color disabledColour: Qt.alpha(Colours.palette.m3onSurface, 0.1)
+ property color disabledOnColour: Qt.alpha(Colours.palette.m3onSurface, 0.38)
+
+ signal clicked
+
+ onCheckedChanged: internalChecked = checked
+
+ radius: internalChecked ? Appearance.rounding.small : implicitHeight / 2 * Math.min(1, Appearance.rounding.scale)
+ color: type === IconButton.Text ? "transparent" : disabled ? disabledColour : internalChecked ? activeColour : inactiveColour
+
+ implicitWidth: implicitHeight
+ implicitHeight: label.implicitHeight + padding * 2
+
+ StateLayer {
+ id: stateLayer
+
+ color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour
+ disabled: root.disabled
+
+ function onClicked(): void {
+ if (root.toggle)
+ root.internalChecked = !root.internalChecked;
+ root.clicked();
+ }
+ }
+
+ MaterialIcon {
+ id: label
+
+ anchors.centerIn: parent
+ color: root.disabled ? root.disabledOnColour : root.internalChecked ? root.activeOnColour : root.inactiveOnColour
+ fill: !root.toggle || root.internalChecked ? 1 : 0
+
+ Behavior on fill {
+ Anim {}
+ }
+ }
+
+ Behavior on radius {
+ Anim {
+ id: radiusAnim
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/components/controls/IconTextButton.qml b/.config/quickshell/caelestia/components/controls/IconTextButton.qml
new file mode 100644
index 0000000..b2bb96c
--- /dev/null
+++ b/.config/quickshell/caelestia/components/controls/IconTextButton.qml
@@ -0,0 +1,88 @@
+import ".."
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+StyledRect {
+ id: root
+
+ enum Type {
+ Filled,
+ Tonal,
+ Text
+ }
+
+ property alias icon: iconLabel.text
+ property alias text: label.text
+ property bool checked
+ property bool toggle
+ property real horizontalPadding: Appearance.padding.normal
+ property real verticalPadding: Appearance.padding.smaller
+ property alias font: label.font
+ property int type: IconTextButton.Filled
+
+ property alias stateLayer: stateLayer
+ property alias iconLabel: iconLabel
+ property alias label: label
+
+ property bool internalChecked
+ property color activeColour: type === IconTextButton.Filled ? Colours.palette.m3primary : Colours.palette.m3secondary
+ property color inactiveColour: type === IconTextButton.Filled ? Colours.tPalette.m3surfaceContainer : Colours.palette.m3secondaryContainer
+ property color activeOnColour: type === IconTextButton.Filled ? Colours.palette.m3onPrimary : Colours.palette.m3onSecondary
+ property color inactiveOnColour: type === IconTextButton.Filled ? Colours.palette.m3onSurface : Colours.palette.m3onSecondaryContainer
+
+ signal clicked
+
+ onCheckedChanged: internalChecked = checked
+
+ radius: internalChecked ? Appearance.rounding.small : implicitHeight / 2 * Math.min(1, Appearance.rounding.scale)
+ color: type === IconTextButton.Text ? "transparent" : internalChecked ? activeColour : inactiveColour
+
+ implicitWidth: row.implicitWidth + horizontalPadding * 2
+ implicitHeight: row.implicitHeight + verticalPadding * 2
+
+ StateLayer {
+ id: stateLayer
+
+ color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour
+
+ function onClicked(): void {
+ if (root.toggle)
+ root.internalChecked = !root.internalChecked;
+ root.clicked();
+ }
+ }
+
+ RowLayout {
+ id: row
+
+ anchors.centerIn: parent
+ spacing: Appearance.spacing.small
+
+ MaterialIcon {
+ id: iconLabel
+
+ Layout.alignment: Qt.AlignVCenter
+ Layout.topMargin: Math.round(fontInfo.pointSize * 0.0575)
+ color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour
+ fill: root.internalChecked ? 1 : 0
+
+ Behavior on fill {
+ Anim {}
+ }
+ }
+
+ StyledText {
+ id: label
+
+ Layout.alignment: Qt.AlignVCenter
+ Layout.topMargin: -Math.round(iconLabel.fontInfo.pointSize * 0.0575)
+ color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour
+ }
+ }
+
+ Behavior on radius {
+ Anim {}
+ }
+}
diff --git a/.config/quickshell/caelestia/components/controls/Menu.qml b/.config/quickshell/caelestia/components/controls/Menu.qml
new file mode 100644
index 0000000..c763b54
--- /dev/null
+++ b/.config/quickshell/caelestia/components/controls/Menu.qml
@@ -0,0 +1,113 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../effects"
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+Elevation {
+ id: root
+
+ property list items
+ property MenuItem active: items[0] ?? null
+ property bool expanded
+
+ signal itemSelected(item: MenuItem)
+
+ radius: Appearance.rounding.small / 2
+ level: 2
+
+ implicitWidth: Math.max(200, column.implicitWidth)
+ implicitHeight: root.expanded ? column.implicitHeight : 0
+ opacity: root.expanded ? 1 : 0
+
+ StyledClippingRect {
+ anchors.fill: parent
+ radius: parent.radius
+ color: Colours.palette.m3surfaceContainer
+
+ ColumnLayout {
+ id: column
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ spacing: 0
+
+ Repeater {
+ model: root.items
+
+ StyledRect {
+ id: item
+
+ required property int index
+ required property MenuItem modelData
+ readonly property bool active: modelData === root.active
+
+ Layout.fillWidth: true
+ implicitWidth: menuOptionRow.implicitWidth + Appearance.padding.normal * 2
+ implicitHeight: menuOptionRow.implicitHeight + Appearance.padding.normal * 2
+
+ color: Qt.alpha(Colours.palette.m3secondaryContainer, active ? 1 : 0)
+
+ StateLayer {
+ color: item.active ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface
+ disabled: !root.expanded
+
+ function onClicked(): void {
+ root.itemSelected(item.modelData);
+ root.active = item.modelData;
+ root.expanded = false;
+ }
+ }
+
+ RowLayout {
+ id: menuOptionRow
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.normal
+ spacing: Appearance.spacing.small
+
+ MaterialIcon {
+ Layout.alignment: Qt.AlignVCenter
+ text: item.modelData.icon
+ color: item.active ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurfaceVariant
+ }
+
+ StyledText {
+ Layout.alignment: Qt.AlignVCenter
+ Layout.fillWidth: true
+ text: item.modelData.text
+ color: item.active ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface
+ }
+
+ Loader {
+ Layout.alignment: Qt.AlignVCenter
+ active: item.modelData.trailingIcon.length > 0
+ visible: active
+
+ sourceComponent: MaterialIcon {
+ text: item.modelData.trailingIcon
+ color: item.active ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Behavior on opacity {
+ Anim {
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ }
+ }
+
+ Behavior on implicitHeight {
+ Anim {
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/components/controls/MenuItem.qml b/.config/quickshell/caelestia/components/controls/MenuItem.qml
new file mode 100644
index 0000000..5348bbe
--- /dev/null
+++ b/.config/quickshell/caelestia/components/controls/MenuItem.qml
@@ -0,0 +1,11 @@
+import QtQuick
+
+QtObject {
+ required property string text
+ property string icon
+ property string trailingIcon
+ property string activeIcon: icon
+ property string activeText: text
+
+ signal clicked
+}
diff --git a/.config/quickshell/caelestia/components/controls/SpinBoxRow.qml b/.config/quickshell/caelestia/components/controls/SpinBoxRow.qml
new file mode 100644
index 0000000..fe6a198
--- /dev/null
+++ b/.config/quickshell/caelestia/components/controls/SpinBoxRow.qml
@@ -0,0 +1,52 @@
+import ".."
+import qs.components
+import qs.components.effects
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+StyledRect {
+ id: root
+
+ required property string label
+ required property real value
+ required property real min
+ required property real max
+ property real step: 1
+ property var onValueModified: function (value) {}
+
+ Layout.fillWidth: true
+ implicitHeight: row.implicitHeight + Appearance.padding.large * 2
+ radius: Appearance.rounding.normal
+ color: Colours.layer(Colours.palette.m3surfaceContainer, 2)
+
+ Behavior on implicitHeight {
+ Anim {}
+ }
+
+ RowLayout {
+ id: row
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.large
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ Layout.fillWidth: true
+ text: root.label
+ }
+
+ CustomSpinBox {
+ min: root.min
+ max: root.max
+ step: root.step
+ value: root.value
+ onValueModified: value => {
+ root.onValueModified(value);
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/components/controls/SplitButton.qml b/.config/quickshell/caelestia/components/controls/SplitButton.qml
new file mode 100644
index 0000000..c91474e
--- /dev/null
+++ b/.config/quickshell/caelestia/components/controls/SplitButton.qml
@@ -0,0 +1,164 @@
+import ".."
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+Row {
+ id: root
+
+ enum Type {
+ Filled,
+ Tonal
+ }
+
+ property real horizontalPadding: Appearance.padding.normal
+ property real verticalPadding: Appearance.padding.smaller
+ property int type: SplitButton.Filled
+ property bool disabled
+ property bool menuOnTop
+ property string fallbackIcon
+ property string fallbackText
+
+ property alias menuItems: menu.items
+ property alias active: menu.active
+ property alias expanded: menu.expanded
+ property alias menu: menu
+ property alias iconLabel: iconLabel
+ property alias label: label
+ property alias stateLayer: stateLayer
+
+ property color colour: type == SplitButton.Filled ? Colours.palette.m3primary : Colours.palette.m3secondaryContainer
+ property color textColour: type == SplitButton.Filled ? Colours.palette.m3onPrimary : Colours.palette.m3onSecondaryContainer
+ property color disabledColour: Qt.alpha(Colours.palette.m3onSurface, 0.1)
+ property color disabledTextColour: Qt.alpha(Colours.palette.m3onSurface, 0.38)
+
+ spacing: Math.floor(Appearance.spacing.small / 2)
+
+ StyledRect {
+ radius: implicitHeight / 2 * Math.min(1, Appearance.rounding.scale)
+ topRightRadius: Appearance.rounding.small / 2
+ bottomRightRadius: Appearance.rounding.small / 2
+ color: root.disabled ? root.disabledColour : root.colour
+
+ implicitWidth: textRow.implicitWidth + root.horizontalPadding * 2
+ implicitHeight: expandBtn.implicitHeight
+
+ StateLayer {
+ id: stateLayer
+
+ rect.topRightRadius: parent.topRightRadius
+ rect.bottomRightRadius: parent.bottomRightRadius
+ color: root.textColour
+ disabled: root.disabled
+
+ function onClicked(): void {
+ root.active?.clicked();
+ }
+ }
+
+ RowLayout {
+ id: textRow
+
+ anchors.centerIn: parent
+ anchors.horizontalCenterOffset: Math.floor(root.verticalPadding / 4)
+ spacing: Appearance.spacing.small
+
+ MaterialIcon {
+ id: iconLabel
+
+ Layout.alignment: Qt.AlignVCenter
+ animate: true
+ text: root.active?.activeIcon ?? root.fallbackIcon
+ color: root.disabled ? root.disabledTextColour : root.textColour
+ fill: 1
+ }
+
+ StyledText {
+ id: label
+
+ Layout.alignment: Qt.AlignVCenter
+ Layout.preferredWidth: implicitWidth
+ animate: true
+ text: root.active?.activeText ?? root.fallbackText
+ color: root.disabled ? root.disabledTextColour : root.textColour
+ clip: true
+
+ Behavior on Layout.preferredWidth {
+ Anim {
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+ }
+ }
+ }
+
+ StyledRect {
+ id: expandBtn
+
+ property real rad: root.expanded ? implicitHeight / 2 * Math.min(1, Appearance.rounding.scale) : Appearance.rounding.small / 2
+
+ radius: implicitHeight / 2 * Math.min(1, Appearance.rounding.scale)
+ topLeftRadius: rad
+ bottomLeftRadius: rad
+ color: root.disabled ? root.disabledColour : root.colour
+
+ implicitWidth: implicitHeight
+ implicitHeight: expandIcon.implicitHeight + root.verticalPadding * 2
+
+ StateLayer {
+ id: expandStateLayer
+
+ rect.topLeftRadius: parent.topLeftRadius
+ rect.bottomLeftRadius: parent.bottomLeftRadius
+ color: root.textColour
+ disabled: root.disabled
+
+ function onClicked(): void {
+ root.expanded = !root.expanded;
+ }
+ }
+
+ MaterialIcon {
+ id: expandIcon
+
+ anchors.centerIn: parent
+ anchors.horizontalCenterOffset: root.expanded ? 0 : -Math.floor(root.verticalPadding / 4)
+
+ text: "expand_more"
+ color: root.disabled ? root.disabledTextColour : root.textColour
+ rotation: root.expanded ? 180 : 0
+
+ Behavior on anchors.horizontalCenterOffset {
+ Anim {}
+ }
+
+ Behavior on rotation {
+ Anim {}
+ }
+ }
+
+ Behavior on rad {
+ Anim {}
+ }
+
+ Menu {
+ id: menu
+
+ states: State {
+ when: root.menuOnTop
+
+ AnchorChanges {
+ target: menu
+ anchors.top: undefined
+ anchors.bottom: expandBtn.top
+ }
+ }
+
+ anchors.top: parent.bottom
+ anchors.right: parent.right
+ anchors.topMargin: Appearance.spacing.small
+ anchors.bottomMargin: Appearance.spacing.small
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/components/controls/SplitButtonRow.qml b/.config/quickshell/caelestia/components/controls/SplitButtonRow.qml
new file mode 100644
index 0000000..db9925f
--- /dev/null
+++ b/.config/quickshell/caelestia/components/controls/SplitButtonRow.qml
@@ -0,0 +1,62 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import qs.components
+import qs.components.effects
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+StyledRect {
+ id: root
+
+ required property string label
+ property int expandedZ: 100
+ property bool enabled: true
+
+ property alias menuItems: splitButton.menuItems
+ property alias active: splitButton.active
+ property alias expanded: splitButton.expanded
+ property alias type: splitButton.type
+
+ signal selected(item: MenuItem)
+
+ Layout.fillWidth: true
+ implicitHeight: row.implicitHeight + Appearance.padding.large * 2
+ radius: Appearance.rounding.normal
+ color: Colours.layer(Colours.palette.m3surfaceContainer, 2)
+
+ clip: false
+ z: splitButton.menu.implicitHeight > 0 ? expandedZ : 1
+ opacity: enabled ? 1.0 : 0.5
+
+ RowLayout {
+ id: row
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.large
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ Layout.fillWidth: true
+ text: root.label
+ color: root.enabled ? Colours.palette.m3onSurface : Colours.palette.m3onSurfaceVariant
+ }
+
+ SplitButton {
+ id: splitButton
+ enabled: root.enabled
+ type: SplitButton.Filled
+
+ menu.z: 1
+
+ stateLayer.onClicked: {
+ splitButton.expanded = !splitButton.expanded;
+ }
+
+ menu.onItemSelected: item => {
+ root.selected(item);
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/components/controls/StyledInputField.qml b/.config/quickshell/caelestia/components/controls/StyledInputField.qml
new file mode 100644
index 0000000..0d199c7
--- /dev/null
+++ b/.config/quickshell/caelestia/components/controls/StyledInputField.qml
@@ -0,0 +1,79 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import qs.components
+import qs.services
+import qs.config
+import QtQuick
+
+Item {
+ id: root
+
+ property string text: ""
+ property var validator: null
+ property bool readOnly: false
+ property int horizontalAlignment: TextInput.AlignHCenter
+ property int implicitWidth: 70
+ property bool enabled: true
+
+ // Expose activeFocus through alias to avoid FINAL property override
+ readonly property alias hasFocus: inputField.activeFocus
+
+ signal textEdited(string text)
+ signal editingFinished
+
+ implicitHeight: inputField.implicitHeight + Appearance.padding.small * 2
+
+ StyledRect {
+ id: container
+
+ anchors.fill: parent
+ color: inputHover.containsMouse || inputField.activeFocus ? Colours.layer(Colours.palette.m3surfaceContainer, 3) : Colours.layer(Colours.palette.m3surfaceContainer, 2)
+ radius: Appearance.rounding.small
+ border.width: 1
+ border.color: inputField.activeFocus ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3outline, 0.3)
+ opacity: root.enabled ? 1 : 0.5
+
+ Behavior on color {
+ CAnim {}
+ }
+ Behavior on border.color {
+ CAnim {}
+ }
+
+ MouseArea {
+ id: inputHover
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.IBeamCursor
+ acceptedButtons: Qt.NoButton
+ enabled: root.enabled
+ }
+
+ StyledTextField {
+ id: inputField
+ anchors.centerIn: parent
+ width: parent.width - Appearance.padding.normal
+ horizontalAlignment: root.horizontalAlignment
+ validator: root.validator
+ readOnly: root.readOnly
+ enabled: root.enabled
+
+ Binding {
+ target: inputField
+ property: "text"
+ value: root.text
+ when: !inputField.activeFocus
+ }
+
+ onTextChanged: {
+ root.text = text;
+ root.textEdited(text);
+ }
+
+ onEditingFinished: {
+ root.editingFinished();
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/components/controls/StyledRadioButton.qml b/.config/quickshell/caelestia/components/controls/StyledRadioButton.qml
new file mode 100644
index 0000000..b72fc77
--- /dev/null
+++ b/.config/quickshell/caelestia/components/controls/StyledRadioButton.qml
@@ -0,0 +1,57 @@
+import qs.components
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Templates
+
+RadioButton {
+ id: root
+
+ font.pointSize: Appearance.font.size.smaller
+
+ implicitWidth: implicitIndicatorWidth + implicitContentWidth + contentItem.anchors.leftMargin
+ implicitHeight: Math.max(implicitIndicatorHeight, implicitContentHeight)
+
+ indicator: Rectangle {
+ id: outerCircle
+
+ implicitWidth: 20
+ implicitHeight: 20
+ radius: Appearance.rounding.full
+ color: "transparent"
+ border.color: root.checked ? Colours.palette.m3primary : Colours.palette.m3onSurfaceVariant
+ border.width: 2
+ anchors.verticalCenter: parent.verticalCenter
+
+ StateLayer {
+ anchors.margins: -Appearance.padding.smaller
+ color: root.checked ? Colours.palette.m3onSurface : Colours.palette.m3primary
+ z: -1
+
+ function onClicked(): void {
+ root.click();
+ }
+ }
+
+ StyledRect {
+ anchors.centerIn: parent
+ implicitWidth: 8
+ implicitHeight: 8
+
+ radius: Appearance.rounding.full
+ color: Qt.alpha(Colours.palette.m3primary, root.checked ? 1 : 0)
+ }
+
+ Behavior on border.color {
+ CAnim {}
+ }
+ }
+
+ contentItem: StyledText {
+ text: root.text
+ font.pointSize: root.font.pointSize
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: outerCircle.right
+ anchors.leftMargin: Appearance.spacing.smaller
+ }
+}
diff --git a/.config/quickshell/caelestia/components/controls/StyledScrollBar.qml b/.config/quickshell/caelestia/components/controls/StyledScrollBar.qml
new file mode 100644
index 0000000..de8b679
--- /dev/null
+++ b/.config/quickshell/caelestia/components/controls/StyledScrollBar.qml
@@ -0,0 +1,190 @@
+import ".."
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Templates
+
+ScrollBar {
+ id: root
+
+ required property Flickable flickable
+ property bool shouldBeActive
+ property real nonAnimPosition
+ property bool animating
+
+ onHoveredChanged: {
+ if (hovered)
+ shouldBeActive = true;
+ else
+ shouldBeActive = flickable.moving;
+ }
+
+ property bool _updatingFromFlickable: false
+ property bool _updatingFromUser: false
+
+ // Sync nonAnimPosition with Qt's automatic position binding
+ onPositionChanged: {
+ if (_updatingFromUser) {
+ _updatingFromUser = false;
+ return;
+ }
+ if (position === nonAnimPosition) {
+ animating = false;
+ return;
+ }
+ if (!animating && !_updatingFromFlickable && !fullMouse.pressed) {
+ nonAnimPosition = position;
+ }
+ }
+
+ // Sync nonAnimPosition with flickable when not animating
+ Connections {
+ target: flickable
+ function onContentYChanged() {
+ if (!animating && !fullMouse.pressed) {
+ _updatingFromFlickable = true;
+ const contentHeight = flickable.contentHeight;
+ const height = flickable.height;
+ if (contentHeight > height) {
+ nonAnimPosition = Math.max(0, Math.min(1, flickable.contentY / (contentHeight - height)));
+ } else {
+ nonAnimPosition = 0;
+ }
+ _updatingFromFlickable = false;
+ }
+ }
+ }
+
+ Component.onCompleted: {
+ if (flickable) {
+ const contentHeight = flickable.contentHeight;
+ const height = flickable.height;
+ if (contentHeight > height) {
+ nonAnimPosition = Math.max(0, Math.min(1, flickable.contentY / (contentHeight - height)));
+ }
+ }
+ }
+ implicitWidth: Appearance.padding.small
+
+ contentItem: StyledRect {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ opacity: {
+ if (root.size === 1)
+ return 0;
+ if (fullMouse.pressed)
+ return 1;
+ if (mouse.containsMouse)
+ return 0.8;
+ if (root.policy === ScrollBar.AlwaysOn || root.shouldBeActive)
+ return 0.6;
+ return 0;
+ }
+ radius: Appearance.rounding.full
+ color: Colours.palette.m3secondary
+
+ MouseArea {
+ id: mouse
+
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ hoverEnabled: true
+ acceptedButtons: Qt.NoButton
+ }
+
+ Behavior on opacity {
+ Anim {}
+ }
+ }
+
+ Connections {
+ target: root.flickable
+
+ function onMovingChanged(): void {
+ if (root.flickable.moving)
+ root.shouldBeActive = true;
+ else
+ hideDelay.restart();
+ }
+ }
+
+ Timer {
+ id: hideDelay
+
+ interval: 600
+ onTriggered: root.shouldBeActive = root.flickable.moving || root.hovered
+ }
+
+ CustomMouseArea {
+ id: fullMouse
+
+ anchors.fill: parent
+ preventStealing: true
+
+ onPressed: event => {
+ root.animating = true;
+ root._updatingFromUser = true;
+ const newPos = Math.max(0, Math.min(1 - root.size, event.y / root.height - root.size / 2));
+ root.nonAnimPosition = newPos;
+ // Update flickable position
+ // Map scrollbar position [0, 1-size] to contentY [0, maxContentY]
+ if (root.flickable) {
+ const contentHeight = root.flickable.contentHeight;
+ const height = root.flickable.height;
+ if (contentHeight > height) {
+ const maxContentY = contentHeight - height;
+ const maxPos = 1 - root.size;
+ const contentY = maxPos > 0 ? (newPos / maxPos) * maxContentY : 0;
+ root.flickable.contentY = Math.max(0, Math.min(maxContentY, contentY));
+ }
+ }
+ }
+
+ onPositionChanged: event => {
+ root._updatingFromUser = true;
+ const newPos = Math.max(0, Math.min(1 - root.size, event.y / root.height - root.size / 2));
+ root.nonAnimPosition = newPos;
+ // Update flickable position
+ // Map scrollbar position [0, 1-size] to contentY [0, maxContentY]
+ if (root.flickable) {
+ const contentHeight = root.flickable.contentHeight;
+ const height = root.flickable.height;
+ if (contentHeight > height) {
+ const maxContentY = contentHeight - height;
+ const maxPos = 1 - root.size;
+ const contentY = maxPos > 0 ? (newPos / maxPos) * maxContentY : 0;
+ root.flickable.contentY = Math.max(0, Math.min(maxContentY, contentY));
+ }
+ }
+ }
+
+ function onWheel(event: WheelEvent): void {
+ root.animating = true;
+ root._updatingFromUser = true;
+ let newPos = root.nonAnimPosition;
+ if (event.angleDelta.y > 0)
+ newPos = Math.max(0, root.nonAnimPosition - 0.1);
+ else if (event.angleDelta.y < 0)
+ newPos = Math.min(1 - root.size, root.nonAnimPosition + 0.1);
+ root.nonAnimPosition = newPos;
+ // Update flickable position
+ // Map scrollbar position [0, 1-size] to contentY [0, maxContentY]
+ if (root.flickable) {
+ const contentHeight = root.flickable.contentHeight;
+ const height = root.flickable.height;
+ if (contentHeight > height) {
+ const maxContentY = contentHeight - height;
+ const maxPos = 1 - root.size;
+ const contentY = maxPos > 0 ? (newPos / maxPos) * maxContentY : 0;
+ root.flickable.contentY = Math.max(0, Math.min(maxContentY, contentY));
+ }
+ }
+ }
+ }
+
+ Behavior on position {
+ enabled: !fullMouse.pressed
+
+ Anim {}
+ }
+}
diff --git a/.config/quickshell/caelestia/components/controls/StyledSlider.qml b/.config/quickshell/caelestia/components/controls/StyledSlider.qml
new file mode 100644
index 0000000..0ef229d
--- /dev/null
+++ b/.config/quickshell/caelestia/components/controls/StyledSlider.qml
@@ -0,0 +1,57 @@
+import qs.components
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Templates
+
+Slider {
+ id: root
+
+ background: Item {
+ StyledRect {
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ anchors.left: parent.left
+ anchors.topMargin: root.implicitHeight / 3
+ anchors.bottomMargin: root.implicitHeight / 3
+
+ implicitWidth: root.handle.x - root.implicitHeight / 6
+
+ color: Colours.palette.m3primary
+ radius: Appearance.rounding.full
+ topRightRadius: root.implicitHeight / 15
+ bottomRightRadius: root.implicitHeight / 15
+ }
+
+ StyledRect {
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ anchors.right: parent.right
+ anchors.topMargin: root.implicitHeight / 3
+ anchors.bottomMargin: root.implicitHeight / 3
+
+ implicitWidth: parent.width - root.handle.x - root.handle.implicitWidth - root.implicitHeight / 6
+
+ color: Colours.palette.m3surfaceContainerHighest
+ radius: Appearance.rounding.full
+ topLeftRadius: root.implicitHeight / 15
+ bottomLeftRadius: root.implicitHeight / 15
+ }
+ }
+
+ handle: StyledRect {
+ x: root.visualPosition * root.availableWidth - implicitWidth / 2
+
+ implicitWidth: root.implicitHeight / 4.5
+ implicitHeight: root.implicitHeight
+
+ color: Colours.palette.m3primary
+ radius: Appearance.rounding.full
+
+ MouseArea {
+ anchors.fill: parent
+ acceptedButtons: Qt.NoButton
+ cursorShape: Qt.PointingHandCursor
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/components/controls/StyledSwitch.qml b/.config/quickshell/caelestia/components/controls/StyledSwitch.qml
new file mode 100644
index 0000000..ce93cd5
--- /dev/null
+++ b/.config/quickshell/caelestia/components/controls/StyledSwitch.qml
@@ -0,0 +1,152 @@
+import ".."
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Templates
+import QtQuick.Shapes
+
+Switch {
+ id: root
+
+ property int cLayer: 1
+
+ implicitWidth: implicitIndicatorWidth
+ implicitHeight: implicitIndicatorHeight
+
+ indicator: StyledRect {
+ radius: Appearance.rounding.full
+ color: root.checked ? Colours.palette.m3primary : Colours.layer(Colours.palette.m3surfaceContainerHighest, root.cLayer)
+
+ implicitWidth: implicitHeight * 1.7
+ implicitHeight: Appearance.font.size.normal + Appearance.padding.smaller * 2
+
+ StyledRect {
+ readonly property real nonAnimWidth: root.pressed ? implicitHeight * 1.3 : implicitHeight
+
+ radius: Appearance.rounding.full
+ color: root.checked ? Colours.palette.m3onPrimary : Colours.layer(Colours.palette.m3outline, root.cLayer + 1)
+
+ x: root.checked ? parent.implicitWidth - nonAnimWidth - Appearance.padding.small / 2 : Appearance.padding.small / 2
+ implicitWidth: nonAnimWidth
+ implicitHeight: parent.implicitHeight - Appearance.padding.small
+ anchors.verticalCenter: parent.verticalCenter
+
+ StyledRect {
+ anchors.fill: parent
+ radius: parent.radius
+
+ color: root.checked ? Colours.palette.m3primary : Colours.palette.m3onSurface
+ opacity: root.pressed ? 0.1 : root.hovered ? 0.08 : 0
+
+ Behavior on opacity {
+ Anim {}
+ }
+ }
+
+ Shape {
+ id: icon
+
+ property point start1: {
+ if (root.pressed)
+ return Qt.point(width * 0.2, height / 2);
+ if (root.checked)
+ return Qt.point(width * 0.15, height / 2);
+ return Qt.point(width * 0.15, height * 0.15);
+ }
+ property point end1: {
+ if (root.pressed) {
+ if (root.checked)
+ return Qt.point(width * 0.4, height / 2);
+ return Qt.point(width * 0.8, height / 2);
+ }
+ if (root.checked)
+ return Qt.point(width * 0.4, height * 0.7);
+ return Qt.point(width * 0.85, height * 0.85);
+ }
+ property point start2: {
+ if (root.pressed) {
+ if (root.checked)
+ return Qt.point(width * 0.4, height / 2);
+ return Qt.point(width * 0.2, height / 2);
+ }
+ if (root.checked)
+ return Qt.point(width * 0.4, height * 0.7);
+ return Qt.point(width * 0.15, height * 0.85);
+ }
+ property point end2: {
+ if (root.pressed)
+ return Qt.point(width * 0.8, height / 2);
+ if (root.checked)
+ return Qt.point(width * 0.85, height * 0.2);
+ return Qt.point(width * 0.85, height * 0.15);
+ }
+
+ anchors.centerIn: parent
+ width: height
+ height: parent.implicitHeight - Appearance.padding.small * 2
+ preferredRendererType: Shape.CurveRenderer
+ asynchronous: true
+
+ ShapePath {
+ strokeWidth: Appearance.font.size.larger * 0.15
+ strokeColor: root.checked ? Colours.palette.m3primary : Colours.palette.m3surfaceContainerHighest
+ fillColor: "transparent"
+ capStyle: Appearance.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap
+
+ startX: icon.start1.x
+ startY: icon.start1.y
+
+ PathLine {
+ x: icon.end1.x
+ y: icon.end1.y
+ }
+ PathMove {
+ x: icon.start2.x
+ y: icon.start2.y
+ }
+ PathLine {
+ x: icon.end2.x
+ y: icon.end2.y
+ }
+
+ Behavior on strokeColor {
+ CAnim {}
+ }
+ }
+
+ Behavior on start1 {
+ PropAnim {}
+ }
+ Behavior on end1 {
+ PropAnim {}
+ }
+ Behavior on start2 {
+ PropAnim {}
+ }
+ Behavior on end2 {
+ PropAnim {}
+ }
+ }
+
+ Behavior on x {
+ Anim {}
+ }
+
+ Behavior on implicitWidth {
+ Anim {}
+ }
+ }
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ enabled: false
+ }
+
+ component PropAnim: PropertyAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+}
diff --git a/.config/quickshell/caelestia/components/controls/StyledTextField.qml b/.config/quickshell/caelestia/components/controls/StyledTextField.qml
new file mode 100644
index 0000000..60bcff2
--- /dev/null
+++ b/.config/quickshell/caelestia/components/controls/StyledTextField.qml
@@ -0,0 +1,76 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import qs.services
+import qs.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
+ renderType: echoMode === TextField.Password ? TextField.QtRendering : TextField.NativeRendering
+ cursorVisible: !readOnly
+
+ background: null
+
+ cursorDelegate: StyledRect {
+ id: cursor
+
+ property bool disableBlink
+
+ implicitWidth: 2
+ color: Colours.palette.m3primary
+ radius: Appearance.rounding.normal
+
+ Connections {
+ target: root
+
+ function onCursorPositionChanged(): void {
+ if (root.activeFocus && root.cursorVisible) {
+ cursor.opacity = 1;
+ cursor.disableBlink = true;
+ enableBlink.restart();
+ }
+ }
+ }
+
+ Timer {
+ id: enableBlink
+
+ interval: 100
+ onTriggered: cursor.disableBlink = false
+ }
+
+ Timer {
+ running: root.activeFocus && root.cursorVisible && !cursor.disableBlink
+ repeat: true
+ triggeredOnStart: true
+ interval: 500
+ onTriggered: parent.opacity = parent.opacity === 1 ? 0 : 1
+ }
+
+ Binding {
+ when: !root.activeFocus || !root.cursorVisible
+ cursor.opacity: 0
+ }
+
+ Behavior on opacity {
+ Anim {
+ duration: Appearance.anim.durations.small
+ }
+ }
+ }
+
+ Behavior on color {
+ CAnim {}
+ }
+
+ Behavior on placeholderTextColor {
+ CAnim {}
+ }
+}
diff --git a/.config/quickshell/caelestia/components/controls/SwitchRow.qml b/.config/quickshell/caelestia/components/controls/SwitchRow.qml
new file mode 100644
index 0000000..6dda3f0
--- /dev/null
+++ b/.config/quickshell/caelestia/components/controls/SwitchRow.qml
@@ -0,0 +1,48 @@
+import ".."
+import qs.components
+import qs.components.effects
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+StyledRect {
+ id: root
+
+ required property string label
+ required property bool checked
+ property bool enabled: true
+ property var onToggled: function (checked) {}
+
+ Layout.fillWidth: true
+ implicitHeight: row.implicitHeight + Appearance.padding.large * 2
+ radius: Appearance.rounding.normal
+ color: Colours.layer(Colours.palette.m3surfaceContainer, 2)
+
+ Behavior on implicitHeight {
+ Anim {}
+ }
+
+ RowLayout {
+ id: row
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.large
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ Layout.fillWidth: true
+ text: root.label
+ }
+
+ StyledSwitch {
+ checked: root.checked
+ enabled: root.enabled
+ onToggled: {
+ root.onToggled(checked);
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/components/controls/TextButton.qml b/.config/quickshell/caelestia/components/controls/TextButton.qml
new file mode 100644
index 0000000..ecf7eb1
--- /dev/null
+++ b/.config/quickshell/caelestia/components/controls/TextButton.qml
@@ -0,0 +1,78 @@
+import ".."
+import qs.services
+import qs.config
+import QtQuick
+
+StyledRect {
+ id: root
+
+ enum Type {
+ Filled,
+ Tonal,
+ Text
+ }
+
+ property alias text: label.text
+ property bool checked
+ property bool toggle
+ property real horizontalPadding: Appearance.padding.normal
+ property real verticalPadding: Appearance.padding.smaller
+ property alias font: label.font
+ property int type: TextButton.Filled
+
+ property alias stateLayer: stateLayer
+ property alias label: label
+
+ property bool internalChecked
+ property color activeColour: type === TextButton.Filled ? Colours.palette.m3primary : Colours.palette.m3secondary
+ property color inactiveColour: {
+ if (!toggle && type === TextButton.Filled)
+ return Colours.palette.m3primary;
+ return type === TextButton.Filled ? Colours.tPalette.m3surfaceContainer : Colours.palette.m3secondaryContainer;
+ }
+ property color activeOnColour: {
+ if (type === TextButton.Text)
+ return Colours.palette.m3primary;
+ return type === TextButton.Filled ? Colours.palette.m3onPrimary : Colours.palette.m3onSecondary;
+ }
+ property color inactiveOnColour: {
+ if (!toggle && type === TextButton.Filled)
+ return Colours.palette.m3onPrimary;
+ if (type === TextButton.Text)
+ return Colours.palette.m3primary;
+ return type === TextButton.Filled ? Colours.palette.m3onSurface : Colours.palette.m3onSecondaryContainer;
+ }
+
+ signal clicked
+
+ onCheckedChanged: internalChecked = checked
+
+ radius: internalChecked ? Appearance.rounding.small : implicitHeight / 2 * Math.min(1, Appearance.rounding.scale)
+ color: type === TextButton.Text ? "transparent" : internalChecked ? activeColour : inactiveColour
+
+ implicitWidth: label.implicitWidth + horizontalPadding * 2
+ implicitHeight: label.implicitHeight + verticalPadding * 2
+
+ StateLayer {
+ id: stateLayer
+
+ color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour
+
+ function onClicked(): void {
+ if (root.toggle)
+ root.internalChecked = !root.internalChecked;
+ root.clicked();
+ }
+ }
+
+ StyledText {
+ id: label
+
+ anchors.centerIn: parent
+ color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour
+ }
+
+ Behavior on radius {
+ Anim {}
+ }
+}
diff --git a/.config/quickshell/caelestia/components/controls/ToggleButton.qml b/.config/quickshell/caelestia/components/controls/ToggleButton.qml
new file mode 100644
index 0000000..98c7564
--- /dev/null
+++ b/.config/quickshell/caelestia/components/controls/ToggleButton.qml
@@ -0,0 +1,124 @@
+import ".."
+import qs.components
+import qs.components.controls
+import qs.components.effects
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+StyledRect {
+ id: root
+
+ required property bool toggled
+ property string icon
+ property string label
+ property string accent: "Secondary"
+ property real iconSize: Appearance.font.size.large
+ property real horizontalPadding: Appearance.padding.large
+ property real verticalPadding: Appearance.padding.normal
+ property string tooltip: ""
+
+ property bool hovered: false
+ signal clicked
+
+ Component.onCompleted: {
+ hovered = toggleStateLayer.containsMouse;
+ }
+
+ Connections {
+ target: toggleStateLayer
+ function onContainsMouseChanged() {
+ const newHovered = toggleStateLayer.containsMouse;
+ if (hovered !== newHovered) {
+ hovered = newHovered;
+ }
+ }
+ }
+
+ Layout.preferredWidth: implicitWidth + (toggleStateLayer.pressed ? Appearance.padding.normal * 2 : toggled ? Appearance.padding.small * 2 : 0)
+ implicitWidth: toggleBtnInner.implicitWidth + horizontalPadding * 2
+ implicitHeight: toggleBtnIcon.implicitHeight + verticalPadding * 2
+
+ radius: toggled || toggleStateLayer.pressed ? Appearance.rounding.small : Math.min(width, height) / 2 * Math.min(1, Appearance.rounding.scale)
+ color: toggled ? Colours.palette[`m3${accent.toLowerCase()}`] : Colours.palette[`m3${accent.toLowerCase()}Container`]
+
+ StateLayer {
+ id: toggleStateLayer
+
+ color: root.toggled ? Colours.palette[`m3on${root.accent}`] : Colours.palette[`m3on${root.accent}Container`]
+
+ function onClicked(): void {
+ root.clicked();
+ }
+ }
+
+ RowLayout {
+ id: toggleBtnInner
+
+ anchors.centerIn: parent
+ spacing: Appearance.spacing.normal
+
+ MaterialIcon {
+ id: toggleBtnIcon
+
+ visible: !!text
+ fill: root.toggled ? 1 : 0
+ text: root.icon
+ color: root.toggled ? Colours.palette[`m3on${root.accent}`] : Colours.palette[`m3on${root.accent}Container`]
+ font.pointSize: root.iconSize
+
+ Behavior on fill {
+ Anim {}
+ }
+ }
+
+ Loader {
+ active: !!root.label
+ visible: active
+
+ sourceComponent: StyledText {
+ text: root.label
+ color: root.toggled ? Colours.palette[`m3on${root.accent}`] : Colours.palette[`m3on${root.accent}Container`]
+ }
+ }
+ }
+
+ Behavior on radius {
+ Anim {
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
+ }
+ }
+
+ Behavior on Layout.preferredWidth {
+ Anim {
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
+ }
+ }
+
+ // Tooltip - positioned absolutely, doesn't affect layout
+ Loader {
+ id: tooltipLoader
+ active: root.tooltip !== ""
+ z: 10000
+ width: 0
+ height: 0
+ sourceComponent: Component {
+ Tooltip {
+ target: root
+ text: root.tooltip
+ }
+ }
+ // Completely remove from layout
+ Layout.fillWidth: false
+ Layout.fillHeight: false
+ Layout.preferredWidth: 0
+ Layout.preferredHeight: 0
+ Layout.maximumWidth: 0
+ Layout.maximumHeight: 0
+ Layout.minimumWidth: 0
+ Layout.minimumHeight: 0
+ }
+}
diff --git a/.config/quickshell/caelestia/components/controls/ToggleRow.qml b/.config/quickshell/caelestia/components/controls/ToggleRow.qml
new file mode 100644
index 0000000..269d3d6
--- /dev/null
+++ b/.config/quickshell/caelestia/components/controls/ToggleRow.qml
@@ -0,0 +1,28 @@
+import qs.components
+import qs.components.controls
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+RowLayout {
+ id: root
+
+ required property string label
+ property alias checked: toggle.checked
+ property alias toggle: toggle
+
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ Layout.fillWidth: true
+ text: root.label
+ }
+
+ StyledSwitch {
+ id: toggle
+
+ cLayer: 2
+ }
+}
diff --git a/.config/quickshell/caelestia/components/controls/Tooltip.qml b/.config/quickshell/caelestia/components/controls/Tooltip.qml
new file mode 100644
index 0000000..b129a37
--- /dev/null
+++ b/.config/quickshell/caelestia/components/controls/Tooltip.qml
@@ -0,0 +1,185 @@
+import ".."
+import qs.components.effects
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+
+Popup {
+ id: root
+
+ required property Item target
+ required property string text
+ property int delay: 500
+ property int timeout: 0
+
+ property bool tooltipVisible: false
+ property Timer showTimer: Timer {
+ interval: root.delay
+ onTriggered: root.tooltipVisible = true
+ }
+ property Timer hideTimer: Timer {
+ interval: root.timeout
+ onTriggered: root.tooltipVisible = false
+ }
+
+ // Popup properties - doesn't affect layout
+ parent: {
+ let p = target;
+ // Walk up to find the root Item (usually has anchors.fill: parent)
+ while (p && p.parent) {
+ const parentItem = p.parent;
+ // Check if this looks like a root pane Item
+ if (parentItem && parentItem.anchors && parentItem.anchors.fill !== undefined) {
+ return parentItem;
+ }
+ p = parentItem;
+ }
+ // Fallback
+ return target.parent?.parent?.parent ?? target.parent?.parent ?? target.parent ?? target;
+ }
+
+ visible: tooltipVisible
+ modal: false
+ closePolicy: Popup.NoAutoClose
+ padding: 0
+ margins: 0
+ background: Item {}
+
+ // Update position when target moves or tooltip becomes visible
+ onTooltipVisibleChanged: {
+ if (tooltipVisible) {
+ Qt.callLater(updatePosition);
+ }
+ }
+ Connections {
+ target: root.target
+ function onXChanged() {
+ if (root.tooltipVisible)
+ root.updatePosition();
+ }
+ function onYChanged() {
+ if (root.tooltipVisible)
+ root.updatePosition();
+ }
+ function onWidthChanged() {
+ if (root.tooltipVisible)
+ root.updatePosition();
+ }
+ function onHeightChanged() {
+ if (root.tooltipVisible)
+ root.updatePosition();
+ }
+ }
+
+ function updatePosition() {
+ if (!target || !parent)
+ return;
+
+ // Wait for tooltipRect to have its size calculated
+ Qt.callLater(() => {
+ if (!target || !parent || !tooltipRect)
+ return;
+
+ // Get target position in parent's coordinate system
+ const targetPos = target.mapToItem(parent, 0, 0);
+ const targetCenterX = targetPos.x + target.width / 2;
+
+ // Get tooltip size (use width/height if available, otherwise implicit)
+ const tooltipWidth = tooltipRect.width > 0 ? tooltipRect.width : tooltipRect.implicitWidth;
+ const tooltipHeight = tooltipRect.height > 0 ? tooltipRect.height : tooltipRect.implicitHeight;
+
+ // Center tooltip horizontally on target
+ let newX = targetCenterX - tooltipWidth / 2;
+
+ // Position tooltip above target
+ let newY = targetPos.y - tooltipHeight - Appearance.spacing.small;
+
+ // Keep within bounds
+ const padding = Appearance.padding.normal;
+ if (newX < padding) {
+ newX = padding;
+ } else if (newX + tooltipWidth > (parent.width - padding)) {
+ newX = parent.width - tooltipWidth - padding;
+ }
+
+ // Update popup position
+ x = newX;
+ y = newY;
+ });
+ }
+
+ enter: Transition {
+ Anim {
+ property: "opacity"
+ from: 0
+ to: 1
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
+ }
+ }
+
+ exit: Transition {
+ Anim {
+ property: "opacity"
+ from: 1
+ to: 0
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
+ }
+ }
+
+ // Monitor hover state
+ Connections {
+ target: root.target
+ function onHoveredChanged() {
+ if (target.hovered) {
+ showTimer.start();
+ if (timeout > 0) {
+ hideTimer.stop();
+ hideTimer.start();
+ }
+ } else {
+ showTimer.stop();
+ hideTimer.stop();
+ tooltipVisible = false;
+ }
+ }
+ }
+
+ contentItem: StyledRect {
+ id: tooltipRect
+
+ implicitWidth: tooltipText.implicitWidth + Appearance.padding.normal * 2
+ implicitHeight: tooltipText.implicitHeight + Appearance.padding.smaller * 2
+
+ color: Colours.palette.m3surfaceContainerHighest
+ radius: Appearance.rounding.small
+ antialiasing: true
+
+ // Add elevation for depth
+ Elevation {
+ anchors.fill: parent
+ radius: parent.radius
+ z: -1
+ level: 3
+ }
+
+ StyledText {
+ id: tooltipText
+
+ anchors.centerIn: parent
+
+ text: root.text
+ color: Colours.palette.m3onSurface
+ font.pointSize: Appearance.font.size.small
+ }
+ }
+
+ Component.onCompleted: {
+ if (tooltipVisible) {
+ updatePosition();
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/components/effects/ColouredIcon.qml b/.config/quickshell/caelestia/components/effects/ColouredIcon.qml
new file mode 100644
index 0000000..5ef4d4c
--- /dev/null
+++ b/.config/quickshell/caelestia/components/effects/ColouredIcon.qml
@@ -0,0 +1,35 @@
+pragma ComponentBehavior: Bound
+
+import Caelestia
+import Quickshell.Widgets
+import QtQuick
+
+IconImage {
+ id: root
+
+ required property color colour
+
+ asynchronous: true
+
+ layer.enabled: true
+ layer.effect: Colouriser {
+ sourceColor: analyser.dominantColour
+ colorizationColor: root.colour
+ }
+
+ layer.onEnabledChanged: {
+ if (layer.enabled && status === Image.Ready)
+ analyser.requestUpdate();
+ }
+
+ onStatusChanged: {
+ if (layer.enabled && status === Image.Ready)
+ analyser.requestUpdate();
+ }
+
+ ImageAnalyser {
+ id: analyser
+
+ sourceItem: root
+ }
+}
diff --git a/.config/quickshell/caelestia/components/effects/Colouriser.qml b/.config/quickshell/caelestia/components/effects/Colouriser.qml
new file mode 100644
index 0000000..2948155
--- /dev/null
+++ b/.config/quickshell/caelestia/components/effects/Colouriser.qml
@@ -0,0 +1,14 @@
+import ".."
+import QtQuick
+import QtQuick.Effects
+
+MultiEffect {
+ property color sourceColor: "black"
+
+ colorization: 1
+ brightness: 1 - sourceColor.hslLightness
+
+ Behavior on colorizationColor {
+ CAnim {}
+ }
+}
diff --git a/.config/quickshell/caelestia/components/effects/Elevation.qml b/.config/quickshell/caelestia/components/effects/Elevation.qml
new file mode 100644
index 0000000..fb29f16
--- /dev/null
+++ b/.config/quickshell/caelestia/components/effects/Elevation.qml
@@ -0,0 +1,18 @@
+import ".."
+import qs.services
+import QtQuick
+import QtQuick.Effects
+
+RectangularShadow {
+ property int level
+ property real dp: [0, 1, 3, 6, 8, 12][level]
+
+ color: Qt.alpha(Colours.palette.m3shadow, 0.7)
+ blur: (dp * 5) ** 0.7
+ spread: -dp * 0.3 + (dp * 0.1) ** 2
+ offset.y: dp / 2
+
+ Behavior on dp {
+ Anim {}
+ }
+}
diff --git a/.config/quickshell/caelestia/components/effects/InnerBorder.qml b/.config/quickshell/caelestia/components/effects/InnerBorder.qml
new file mode 100644
index 0000000..d4a751f
--- /dev/null
+++ b/.config/quickshell/caelestia/components/effects/InnerBorder.qml
@@ -0,0 +1,44 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Effects
+
+StyledRect {
+ property alias innerRadius: maskInner.radius
+ property alias thickness: maskInner.anchors.margins
+ property alias leftThickness: maskInner.anchors.leftMargin
+ property alias topThickness: maskInner.anchors.topMargin
+ property alias rightThickness: maskInner.anchors.rightMargin
+ property alias bottomThickness: maskInner.anchors.bottomMargin
+
+ anchors.fill: parent
+ color: Colours.tPalette.m3surfaceContainer
+
+ layer.enabled: true
+ layer.effect: MultiEffect {
+ maskSource: mask
+ maskEnabled: true
+ maskInverted: true
+ maskThresholdMin: 0.5
+ maskSpreadAtMin: 1
+ }
+
+ Item {
+ id: mask
+
+ anchors.fill: parent
+ layer.enabled: true
+ visible: false
+
+ Rectangle {
+ id: maskInner
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.normal
+ radius: Appearance.rounding.small
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/components/effects/OpacityMask.qml b/.config/quickshell/caelestia/components/effects/OpacityMask.qml
new file mode 100644
index 0000000..22e4249
--- /dev/null
+++ b/.config/quickshell/caelestia/components/effects/OpacityMask.qml
@@ -0,0 +1,9 @@
+import Quickshell
+import QtQuick
+
+ShaderEffect {
+ required property Item source
+ required property Item maskSource
+
+ fragmentShader: Qt.resolvedUrl(`${Quickshell.shellDir}/assets/shaders/opacitymask.frag.qsb`)
+}
diff --git a/.config/quickshell/caelestia/components/filedialog/CurrentItem.qml b/.config/quickshell/caelestia/components/filedialog/CurrentItem.qml
new file mode 100644
index 0000000..bb87133
--- /dev/null
+++ b/.config/quickshell/caelestia/components/filedialog/CurrentItem.qml
@@ -0,0 +1,102 @@
+import ".."
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Shapes
+
+Item {
+ id: root
+
+ required property var currentItem
+
+ implicitWidth: content.implicitWidth + Appearance.padding.larger + content.anchors.rightMargin
+ implicitHeight: currentItem ? content.implicitHeight + Appearance.padding.normal + content.anchors.bottomMargin : 0
+
+ Shape {
+ preferredRendererType: Shape.CurveRenderer
+
+ ShapePath {
+ id: path
+
+ readonly property real rounding: Appearance.rounding.small
+ readonly property bool flatten: root.implicitHeight < rounding * 2
+ readonly property real roundingY: flatten ? root.implicitHeight / 2 : rounding
+
+ strokeWidth: -1
+ fillColor: Colours.tPalette.m3surfaceContainer
+
+ startX: root.implicitWidth
+ startY: root.implicitHeight
+
+ PathLine {
+ relativeX: -(root.implicitWidth + path.rounding)
+ relativeY: 0
+ }
+ PathArc {
+ relativeX: path.rounding
+ relativeY: -path.roundingY
+ radiusX: path.rounding
+ radiusY: Math.min(path.rounding, root.implicitHeight)
+ direction: PathArc.Counterclockwise
+ }
+ PathLine {
+ relativeX: 0
+ relativeY: -(root.implicitHeight - path.roundingY * 2)
+ }
+ PathArc {
+ relativeX: path.rounding
+ relativeY: -path.roundingY
+ radiusX: path.rounding
+ radiusY: Math.min(path.rounding, root.implicitHeight)
+ }
+ PathLine {
+ relativeX: root.implicitHeight > 0 ? root.implicitWidth - path.rounding * 2 : root.implicitWidth
+ relativeY: 0
+ }
+ PathArc {
+ relativeX: path.rounding
+ relativeY: -path.rounding
+ radiusX: path.rounding
+ radiusY: path.rounding
+ direction: PathArc.Counterclockwise
+ }
+
+ Behavior on fillColor {
+ CAnim {}
+ }
+ }
+ }
+
+ Item {
+ anchors.fill: parent
+ clip: true
+
+ StyledText {
+ id: content
+
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ anchors.rightMargin: Appearance.padding.larger - Appearance.padding.small
+ anchors.bottomMargin: Appearance.padding.normal - Appearance.padding.small
+
+ Connections {
+ target: root
+
+ function onCurrentItemChanged(): void {
+ if (root.currentItem)
+ content.text = qsTr(`"%1" selected`).arg(root.currentItem.modelData.name);
+ }
+ }
+ }
+ }
+
+ Behavior on implicitWidth {
+ enabled: !!root.currentItem
+
+ Anim {}
+ }
+
+ Behavior on implicitHeight {
+ Anim {}
+ }
+}
diff --git a/.config/quickshell/caelestia/components/filedialog/DialogButtons.qml b/.config/quickshell/caelestia/components/filedialog/DialogButtons.qml
new file mode 100644
index 0000000..bde9ac2
--- /dev/null
+++ b/.config/quickshell/caelestia/components/filedialog/DialogButtons.qml
@@ -0,0 +1,93 @@
+import ".."
+import qs.services
+import qs.config
+import QtQuick.Layouts
+
+StyledRect {
+ id: root
+
+ required property var dialog
+ required property FolderContents folder
+
+ implicitHeight: inner.implicitHeight + Appearance.padding.normal * 2
+
+ color: Colours.tPalette.m3surfaceContainer
+
+ RowLayout {
+ id: inner
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.normal
+
+ spacing: Appearance.spacing.small
+
+ StyledText {
+ text: qsTr("Filter:")
+ }
+
+ StyledRect {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ Layout.rightMargin: Appearance.spacing.normal
+
+ color: Colours.tPalette.m3surfaceContainerHigh
+ radius: Appearance.rounding.small
+
+ StyledText {
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.normal
+
+ text: `${root.dialog.filterLabel} (${root.dialog.filters.map(f => `*.${f}`).join(", ")})`
+ }
+ }
+
+ StyledRect {
+ color: Colours.tPalette.m3surfaceContainerHigh
+ radius: Appearance.rounding.small
+
+ implicitWidth: cancelText.implicitWidth + Appearance.padding.normal * 2
+ implicitHeight: cancelText.implicitHeight + Appearance.padding.normal * 2
+
+ StateLayer {
+ disabled: !root.dialog.selectionValid
+
+ function onClicked(): void {
+ root.dialog.accepted(root.folder.currentItem.modelData.path);
+ }
+ }
+
+ StyledText {
+ id: selectText
+
+ anchors.centerIn: parent
+ anchors.margins: Appearance.padding.normal
+
+ text: qsTr("Select")
+ color: root.dialog.selectionValid ? Colours.palette.m3onSurface : Colours.palette.m3outline
+ }
+ }
+
+ StyledRect {
+ color: Colours.tPalette.m3surfaceContainerHigh
+ radius: Appearance.rounding.small
+
+ implicitWidth: cancelText.implicitWidth + Appearance.padding.normal * 2
+ implicitHeight: cancelText.implicitHeight + Appearance.padding.normal * 2
+
+ StateLayer {
+ function onClicked(): void {
+ root.dialog.rejected();
+ }
+ }
+
+ StyledText {
+ id: cancelText
+
+ anchors.centerIn: parent
+ anchors.margins: Appearance.padding.normal
+
+ text: qsTr("Cancel")
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/components/filedialog/FileDialog.qml b/.config/quickshell/caelestia/components/filedialog/FileDialog.qml
new file mode 100644
index 0000000..f3187a5
--- /dev/null
+++ b/.config/quickshell/caelestia/components/filedialog/FileDialog.qml
@@ -0,0 +1,102 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.services
+import Quickshell
+import QtQuick
+import QtQuick.Layouts
+
+LazyLoader {
+ id: loader
+
+ property list cwd: ["Home"]
+ property string filterLabel: "All files"
+ property list filters: ["*"]
+ property string title: qsTr("Select a file")
+
+ signal accepted(path: string)
+ signal rejected
+
+ function open(): void {
+ activeAsync = true;
+ }
+
+ function close(): void {
+ rejected();
+ }
+
+ onAccepted: activeAsync = false
+ onRejected: activeAsync = false
+
+ FloatingWindow {
+ id: root
+
+ property list cwd: loader.cwd
+ property string filterLabel: loader.filterLabel
+ property list filters: loader.filters
+
+ readonly property bool selectionValid: {
+ const file = folderContents.currentItem?.modelData;
+ return (file && !file.isDir && (filters.includes("*") || filters.includes(file.suffix))) ?? false;
+ }
+
+ function accepted(path: string): void {
+ loader.accepted(path);
+ }
+
+ function rejected(): void {
+ loader.rejected();
+ }
+
+ implicitWidth: 1000
+ implicitHeight: 600
+ color: Colours.tPalette.m3surface
+ title: loader.title
+
+ onVisibleChanged: {
+ if (!visible)
+ rejected();
+ }
+
+ RowLayout {
+ anchors.fill: parent
+
+ spacing: 0
+
+ Sidebar {
+ Layout.fillHeight: true
+ dialog: root
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+
+ spacing: 0
+
+ HeaderBar {
+ Layout.fillWidth: true
+ dialog: root
+ }
+
+ FolderContents {
+ id: folderContents
+
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ dialog: root
+ }
+
+ DialogButtons {
+ Layout.fillWidth: true
+ dialog: root
+ folder: folderContents
+ }
+ }
+ }
+
+ Behavior on color {
+ CAnim {}
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/components/filedialog/FolderContents.qml b/.config/quickshell/caelestia/components/filedialog/FolderContents.qml
new file mode 100644
index 0000000..e16c7a1
--- /dev/null
+++ b/.config/quickshell/caelestia/components/filedialog/FolderContents.qml
@@ -0,0 +1,228 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../controls"
+import "../images"
+import qs.services
+import qs.config
+import qs.utils
+import Caelestia.Models
+import Quickshell
+import QtQuick
+import QtQuick.Layouts
+import QtQuick.Effects
+
+Item {
+ id: root
+
+ required property var dialog
+ property alias currentItem: view.currentItem
+
+ StyledRect {
+ anchors.fill: parent
+ color: Colours.tPalette.m3surfaceContainer
+
+ layer.enabled: true
+ layer.effect: MultiEffect {
+ maskSource: mask
+ maskEnabled: true
+ maskInverted: true
+ maskThresholdMin: 0.5
+ maskSpreadAtMin: 1
+ }
+ }
+
+ Item {
+ id: mask
+
+ anchors.fill: parent
+ layer.enabled: true
+ visible: false
+
+ Rectangle {
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.small
+ radius: Appearance.rounding.small
+ }
+ }
+
+ Loader {
+ anchors.centerIn: parent
+
+ opacity: view.count === 0 ? 1 : 0
+ active: opacity > 0
+
+ sourceComponent: ColumnLayout {
+ MaterialIcon {
+ Layout.alignment: Qt.AlignHCenter
+ text: "scan_delete"
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.extraLarge * 2
+ font.weight: 500
+ }
+
+ StyledText {
+ text: qsTr("This folder is empty")
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.large
+ font.weight: 500
+ }
+ }
+
+ Behavior on opacity {
+ Anim {}
+ }
+ }
+
+ GridView {
+ id: view
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.small + Appearance.padding.normal
+
+ cellWidth: Sizes.itemWidth + Appearance.spacing.small
+ cellHeight: Sizes.itemWidth + Appearance.spacing.small * 2 + Appearance.padding.normal * 2 + 1
+
+ clip: true
+ focus: true
+ currentIndex: -1
+ Keys.onEscapePressed: currentIndex = -1
+
+ Keys.onReturnPressed: {
+ if (root.dialog.selectionValid)
+ root.dialog.accepted(currentItem.modelData.path);
+ }
+ Keys.onEnterPressed: {
+ if (root.dialog.selectionValid)
+ root.dialog.accepted(currentItem.modelData.path);
+ }
+
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: view
+ }
+
+ model: FileSystemModel {
+ path: {
+ if (root.dialog.cwd[0] === "Home")
+ return `${Paths.home}/${root.dialog.cwd.slice(1).join("/")}`;
+ else
+ return root.dialog.cwd.join("/");
+ }
+ onPathChanged: view.currentIndex = -1
+ }
+
+ delegate: StyledRect {
+ id: item
+
+ required property int index
+ required property FileSystemEntry modelData
+
+ readonly property real nonAnimHeight: icon.implicitHeight + name.anchors.topMargin + name.implicitHeight + Appearance.padding.normal * 2
+
+ implicitWidth: Sizes.itemWidth
+ implicitHeight: nonAnimHeight
+
+ radius: Appearance.rounding.normal
+ color: Qt.alpha(Colours.tPalette.m3surfaceContainerHighest, GridView.isCurrentItem ? Colours.tPalette.m3surfaceContainerHighest.a : 0)
+ z: GridView.isCurrentItem || implicitHeight !== nonAnimHeight ? 1 : 0
+ clip: true
+
+ StateLayer {
+ onDoubleClicked: {
+ if (item.modelData.isDir)
+ root.dialog.cwd.push(item.modelData.name);
+ else if (root.dialog.selectionValid)
+ root.dialog.accepted(item.modelData.path);
+ }
+
+ function onClicked(): void {
+ view.currentIndex = item.index;
+ }
+ }
+
+ CachingIconImage {
+ id: icon
+
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.top: parent.top
+ anchors.topMargin: Appearance.padding.normal
+
+ implicitSize: Sizes.itemWidth - Appearance.padding.normal * 2
+
+ Component.onCompleted: {
+ const file = item.modelData;
+ if (file.isImage)
+ source = Qt.resolvedUrl(file.path);
+ else if (!file.isDir)
+ source = Quickshell.iconPath(file.mimeType.replace("/", "-"), "application-x-zerosize");
+ else if (root.dialog.cwd.length === 1 && ["Desktop", "Documents", "Downloads", "Music", "Pictures", "Public", "Templates", "Videos"].includes(file.name))
+ source = Quickshell.iconPath(`folder-${file.name.toLowerCase()}`);
+ else
+ source = Quickshell.iconPath("inode-directory");
+ }
+ }
+
+ StyledText {
+ id: name
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: icon.bottom
+ anchors.topMargin: Appearance.spacing.small
+ anchors.margins: Appearance.padding.normal
+
+ horizontalAlignment: Text.AlignHCenter
+ elide: item.GridView.isCurrentItem ? Text.ElideNone : Text.ElideRight
+ wrapMode: item.GridView.isCurrentItem ? Text.WrapAtWordBoundaryOrAnywhere : Text.NoWrap
+
+ Component.onCompleted: text = item.modelData.name
+ }
+
+ Behavior on implicitHeight {
+ Anim {}
+ }
+ }
+
+ add: Transition {
+ Anim {
+ properties: "opacity,scale"
+ from: 0
+ to: 1
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ }
+
+ remove: Transition {
+ Anim {
+ property: "opacity"
+ to: 0
+ }
+ Anim {
+ property: "scale"
+ to: 0.5
+ }
+ }
+
+ displaced: Transition {
+ Anim {
+ properties: "opacity,scale"
+ to: 1
+ easing.bezierCurve: Appearance.anim.curves.standardDecel
+ }
+ Anim {
+ properties: "x,y"
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ }
+ }
+
+ CurrentItem {
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ anchors.margins: Appearance.padding.small
+
+ currentItem: view.currentItem
+ }
+}
diff --git a/.config/quickshell/caelestia/components/filedialog/HeaderBar.qml b/.config/quickshell/caelestia/components/filedialog/HeaderBar.qml
new file mode 100644
index 0000000..c9a3feb
--- /dev/null
+++ b/.config/quickshell/caelestia/components/filedialog/HeaderBar.qml
@@ -0,0 +1,139 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+StyledRect {
+ id: root
+
+ required property var dialog
+
+ implicitWidth: inner.implicitWidth + Appearance.padding.normal * 2
+ implicitHeight: inner.implicitHeight + Appearance.padding.normal * 2
+
+ color: Colours.tPalette.m3surfaceContainer
+
+ RowLayout {
+ id: inner
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.normal
+ spacing: Appearance.spacing.small
+
+ Item {
+ implicitWidth: implicitHeight
+ implicitHeight: upIcon.implicitHeight + Appearance.padding.small * 2
+
+ StateLayer {
+ radius: Appearance.rounding.small
+ disabled: root.dialog.cwd.length === 1
+
+ function onClicked(): void {
+ root.dialog.cwd.pop();
+ }
+ }
+
+ MaterialIcon {
+ id: upIcon
+
+ anchors.centerIn: parent
+ text: "drive_folder_upload"
+ color: root.dialog.cwd.length === 1 ? Colours.palette.m3outline : Colours.palette.m3onSurface
+ grade: 200
+ }
+ }
+
+ StyledRect {
+ Layout.fillWidth: true
+
+ radius: Appearance.rounding.small
+ color: Colours.tPalette.m3surfaceContainerHigh
+
+ implicitHeight: pathComponents.implicitHeight + pathComponents.anchors.margins * 2
+
+ RowLayout {
+ id: pathComponents
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.small / 2
+ anchors.leftMargin: 0
+
+ spacing: Appearance.spacing.small
+
+ Repeater {
+ model: root.dialog.cwd
+
+ RowLayout {
+ id: folder
+
+ required property string modelData
+ required property int index
+
+ spacing: 0
+
+ Loader {
+ Layout.rightMargin: Appearance.spacing.small
+ active: folder.index > 0
+ sourceComponent: StyledText {
+ text: "/"
+ color: Colours.palette.m3onSurfaceVariant
+ font.bold: true
+ }
+ }
+
+ Item {
+ implicitWidth: homeIcon.implicitWidth + (homeIcon.active ? Appearance.padding.small : 0) + folderName.implicitWidth + Appearance.padding.normal * 2
+ implicitHeight: folderName.implicitHeight + Appearance.padding.small * 2
+
+ Loader {
+ anchors.fill: parent
+ active: folder.index < root.dialog.cwd.length - 1
+ sourceComponent: StateLayer {
+ radius: Appearance.rounding.small
+
+ function onClicked(): void {
+ root.dialog.cwd = root.dialog.cwd.slice(0, folder.index + 1);
+ }
+ }
+ }
+
+ Loader {
+ id: homeIcon
+
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.leftMargin: Appearance.padding.normal
+
+ active: folder.index === 0 && folder.modelData === "Home"
+ sourceComponent: MaterialIcon {
+ text: "home"
+ color: root.dialog.cwd.length === 1 ? Colours.palette.m3onSurface : Colours.palette.m3onSurfaceVariant
+ fill: 1
+ }
+ }
+
+ StyledText {
+ id: folderName
+
+ anchors.left: homeIcon.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.leftMargin: homeIcon.active ? Appearance.padding.small : 0
+
+ text: folder.modelData
+ color: folder.index < root.dialog.cwd.length - 1 ? Colours.palette.m3onSurfaceVariant : Colours.palette.m3onSurface
+ font.bold: true
+ }
+ }
+ }
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/components/filedialog/Sidebar.qml b/.config/quickshell/caelestia/components/filedialog/Sidebar.qml
new file mode 100644
index 0000000..b55d7b3
--- /dev/null
+++ b/.config/quickshell/caelestia/components/filedialog/Sidebar.qml
@@ -0,0 +1,113 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+StyledRect {
+ id: root
+
+ required property var dialog
+
+ implicitWidth: Sizes.sidebarWidth
+ implicitHeight: inner.implicitHeight + Appearance.padding.normal * 2
+
+ color: Colours.tPalette.m3surfaceContainer
+
+ ColumnLayout {
+ id: inner
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.margins: Appearance.padding.normal
+ spacing: Appearance.spacing.small / 2
+
+ StyledText {
+ Layout.alignment: Qt.AlignHCenter
+ Layout.topMargin: Appearance.padding.small / 2
+ Layout.bottomMargin: Appearance.spacing.normal
+ text: qsTr("Files")
+ color: Colours.palette.m3onSurface
+ font.pointSize: Appearance.font.size.larger
+ font.bold: true
+ }
+
+ Repeater {
+ model: ["Home", "Downloads", "Desktop", "Documents", "Music", "Pictures", "Videos"]
+
+ StyledRect {
+ id: place
+
+ required property string modelData
+ readonly property bool selected: modelData === root.dialog.cwd[root.dialog.cwd.length - 1]
+
+ Layout.fillWidth: true
+ implicitHeight: placeInner.implicitHeight + Appearance.padding.normal * 2
+
+ radius: Appearance.rounding.full
+ color: Qt.alpha(Colours.palette.m3secondaryContainer, selected ? 1 : 0)
+
+ StateLayer {
+ color: place.selected ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface
+
+ function onClicked(): void {
+ if (place.modelData === "Home")
+ root.dialog.cwd = ["Home"];
+ else
+ root.dialog.cwd = ["Home", place.modelData];
+ }
+ }
+
+ RowLayout {
+ id: placeInner
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.normal
+ anchors.leftMargin: Appearance.padding.large
+ anchors.rightMargin: Appearance.padding.large
+
+ spacing: Appearance.spacing.normal
+
+ MaterialIcon {
+ text: {
+ const p = place.modelData;
+ if (p === "Home")
+ return "home";
+ if (p === "Downloads")
+ return "file_download";
+ if (p === "Desktop")
+ return "desktop_windows";
+ if (p === "Documents")
+ return "description";
+ if (p === "Music")
+ return "music_note";
+ if (p === "Pictures")
+ return "image";
+ if (p === "Videos")
+ return "video_library";
+ return "folder";
+ }
+ color: place.selected ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface
+ font.pointSize: Appearance.font.size.large
+ fill: place.selected ? 1 : 0
+
+ Behavior on fill {
+ Anim {}
+ }
+ }
+
+ StyledText {
+ Layout.fillWidth: true
+ text: place.modelData
+ color: place.selected ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface
+ font.pointSize: Appearance.font.size.normal
+ elide: Text.ElideRight
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/components/filedialog/Sizes.qml b/.config/quickshell/caelestia/components/filedialog/Sizes.qml
new file mode 100644
index 0000000..2ad31f9
--- /dev/null
+++ b/.config/quickshell/caelestia/components/filedialog/Sizes.qml
@@ -0,0 +1,8 @@
+pragma Singleton
+
+import Quickshell
+
+Singleton {
+ property int itemWidth: 103
+ property int sidebarWidth: 200
+}
diff --git a/.config/quickshell/caelestia/components/images/CachingIconImage.qml b/.config/quickshell/caelestia/components/images/CachingIconImage.qml
new file mode 100644
index 0000000..1acc6a1
--- /dev/null
+++ b/.config/quickshell/caelestia/components/images/CachingIconImage.qml
@@ -0,0 +1,42 @@
+pragma ComponentBehavior: Bound
+
+import qs.utils
+import Quickshell.Widgets
+import QtQuick
+
+Item {
+ id: root
+
+ readonly property int status: loader.item?.status ?? Image.Null
+ readonly property real actualSize: Math.min(width, height)
+ property real implicitSize
+ property url source
+
+ implicitWidth: implicitSize
+ implicitHeight: implicitSize
+
+ Loader {
+ id: loader
+
+ anchors.fill: parent
+ sourceComponent: root.source ? root.source.toString().startsWith("image://icon/") ? iconImage : cachingImage : null
+ }
+
+ Component {
+ id: cachingImage
+
+ CachingImage {
+ path: Paths.toLocalFile(root.source)
+ fillMode: Image.PreserveAspectFit
+ }
+ }
+
+ Component {
+ id: iconImage
+
+ IconImage {
+ source: root.source
+ asynchronous: true
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/components/images/CachingImage.qml b/.config/quickshell/caelestia/components/images/CachingImage.qml
new file mode 100644
index 0000000..e8f957a
--- /dev/null
+++ b/.config/quickshell/caelestia/components/images/CachingImage.qml
@@ -0,0 +1,28 @@
+import qs.utils
+import Caelestia.Internal
+import Quickshell
+import QtQuick
+
+Image {
+ id: root
+
+ property alias path: manager.path
+
+ asynchronous: true
+ fillMode: Image.PreserveAspectCrop
+
+ Connections {
+ target: QsWindow.window
+
+ function onDevicePixelRatioChanged(): void {
+ manager.updateSource();
+ }
+ }
+
+ CachingImageManager {
+ id: manager
+
+ item: root
+ cacheDir: Qt.resolvedUrl(Paths.imagecache)
+ }
+}
diff --git a/.config/quickshell/caelestia/components/misc/CustomShortcut.qml b/.config/quickshell/caelestia/components/misc/CustomShortcut.qml
new file mode 100644
index 0000000..aa35ed8
--- /dev/null
+++ b/.config/quickshell/caelestia/components/misc/CustomShortcut.qml
@@ -0,0 +1,5 @@
+import Quickshell.Hyprland
+
+GlobalShortcut {
+ appid: "caelestia"
+}
diff --git a/.config/quickshell/caelestia/components/misc/Ref.qml b/.config/quickshell/caelestia/components/misc/Ref.qml
new file mode 100644
index 0000000..0a694a4
--- /dev/null
+++ b/.config/quickshell/caelestia/components/misc/Ref.qml
@@ -0,0 +1,8 @@
+import QtQuick
+
+QtObject {
+ required property var service
+
+ Component.onCompleted: service.refCount++
+ Component.onDestruction: service.refCount--
+}
diff --git a/.config/quickshell/caelestia/components/widgets/ExtraIndicator.qml b/.config/quickshell/caelestia/components/widgets/ExtraIndicator.qml
new file mode 100644
index 0000000..db73ea0
--- /dev/null
+++ b/.config/quickshell/caelestia/components/widgets/ExtraIndicator.qml
@@ -0,0 +1,51 @@
+import ".."
+import "../effects"
+import qs.services
+import qs.config
+import QtQuick
+
+StyledRect {
+ required property int extra
+
+ anchors.right: parent.right
+ anchors.margins: Appearance.padding.normal
+
+ color: Colours.palette.m3tertiary
+ radius: Appearance.rounding.small
+
+ implicitWidth: count.implicitWidth + Appearance.padding.normal * 2
+ implicitHeight: count.implicitHeight + Appearance.padding.small * 2
+
+ opacity: extra > 0 ? 1 : 0
+ scale: extra > 0 ? 1 : 0.5
+
+ Elevation {
+ anchors.fill: parent
+ radius: parent.radius
+ opacity: parent.opacity
+ z: -1
+ level: 2
+ }
+
+ StyledText {
+ id: count
+
+ anchors.centerIn: parent
+ animate: parent.opacity > 0
+ text: qsTr("+%1").arg(parent.extra)
+ color: Colours.palette.m3onTertiary
+ }
+
+ Behavior on opacity {
+ Anim {
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ }
+ }
+
+ Behavior on scale {
+ Anim {
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/config/Appearance.qml b/.config/quickshell/caelestia/config/Appearance.qml
new file mode 100644
index 0000000..241c21a
--- /dev/null
+++ b/.config/quickshell/caelestia/config/Appearance.qml
@@ -0,0 +1,14 @@
+pragma Singleton
+
+import Quickshell
+
+Singleton {
+ // Literally just here to shorten accessing stuff :woe:
+ // Also kinda so I can keep accessing it with `Appearance.xxx` instead of `Config.appearance.xxx`
+ readonly property AppearanceConfig.Rounding rounding: Config.appearance.rounding
+ readonly property AppearanceConfig.Spacing spacing: Config.appearance.spacing
+ readonly property AppearanceConfig.Padding padding: Config.appearance.padding
+ readonly property AppearanceConfig.FontStuff font: Config.appearance.font
+ readonly property AppearanceConfig.Anim anim: Config.appearance.anim
+ readonly property AppearanceConfig.Transparency transparency: Config.appearance.transparency
+}
diff --git a/.config/quickshell/caelestia/config/AppearanceConfig.qml b/.config/quickshell/caelestia/config/AppearanceConfig.qml
new file mode 100644
index 0000000..3d590dc
--- /dev/null
+++ b/.config/quickshell/caelestia/config/AppearanceConfig.qml
@@ -0,0 +1,94 @@
+import Quickshell.Io
+
+JsonObject {
+ property Rounding rounding: Rounding {}
+ property Spacing spacing: Spacing {}
+ property Padding padding: Padding {}
+ property FontStuff font: FontStuff {}
+ property Anim anim: Anim {}
+ property Transparency transparency: Transparency {}
+
+ component Rounding: JsonObject {
+ property real scale: 1
+ property int small: 12 * scale
+ property int normal: 17 * scale
+ property int large: 25 * scale
+ property int full: 1000 * scale
+ }
+
+ component Spacing: JsonObject {
+ property real scale: 1
+ property int small: 7 * scale
+ property int smaller: 10 * scale
+ property int normal: 12 * scale
+ property int larger: 15 * scale
+ property int large: 20 * scale
+ }
+
+ component Padding: JsonObject {
+ property real scale: 1
+ property int small: 5 * scale
+ property int smaller: 7 * scale
+ property int normal: 10 * scale
+ property int larger: 12 * scale
+ property int large: 15 * scale
+ }
+
+ component FontFamily: JsonObject {
+ property string sans: "Rubik"
+ property string mono: "CaskaydiaCove NF"
+ property string material: "Material Symbols Rounded"
+ property string clock: "Rubik"
+ }
+
+ component FontSize: JsonObject {
+ property real scale: 1
+ property int small: 11 * scale
+ property int smaller: 12 * scale
+ property int normal: 13 * scale
+ property int larger: 15 * scale
+ property int large: 18 * scale
+ property int extraLarge: 28 * scale
+ }
+
+ component FontStuff: JsonObject {
+ property FontFamily family: FontFamily {}
+ property FontSize size: FontSize {}
+ }
+
+ component AnimCurves: JsonObject {
+ property list emphasized: [0.05, 0, 2 / 15, 0.06, 1 / 6, 0.4, 5 / 24, 0.82, 0.25, 1, 1, 1]
+ property list emphasizedAccel: [0.3, 0, 0.8, 0.15, 1, 1]
+ property list emphasizedDecel: [0.05, 0.7, 0.1, 1, 1, 1]
+ property list standard: [0.2, 0, 0, 1, 1, 1]
+ property list standardAccel: [0.3, 0, 1, 1, 1, 1]
+ property list standardDecel: [0, 0, 0, 1, 1, 1]
+ property list expressiveFastSpatial: [0.42, 1.67, 0.21, 0.9, 1, 1]
+ property list expressiveDefaultSpatial: [0.38, 1.21, 0.22, 1, 1, 1]
+ property list expressiveEffects: [0.34, 0.8, 0.34, 1, 1, 1]
+ }
+
+ component AnimDurations: JsonObject {
+ property real scale: 1
+ property int small: 200 * scale
+ property int normal: 400 * scale
+ property int large: 600 * scale
+ property int extraLarge: 1000 * scale
+ property int expressiveFastSpatial: 350 * scale
+ property int expressiveDefaultSpatial: 500 * scale
+ property int expressiveEffects: 200 * scale
+ }
+
+ component Anim: JsonObject {
+ property real mediaGifSpeedAdjustment: 300
+ property real sessionGifSpeed: 0.7
+ property AnimCurves curves: AnimCurves {}
+ property AnimDurations durations: AnimDurations {}
+ }
+
+ component Transparency: JsonObject {
+ property bool enabled: false
+ property real base: 0.85
+ property real layers: 0.4
+ }
+}
diff --git a/.config/quickshell/caelestia/config/BackgroundConfig.qml b/.config/quickshell/caelestia/config/BackgroundConfig.qml
new file mode 100644
index 0000000..8383f52
--- /dev/null
+++ b/.config/quickshell/caelestia/config/BackgroundConfig.qml
@@ -0,0 +1,37 @@
+import Quickshell.Io
+
+JsonObject {
+ property bool enabled: true
+ property bool wallpaperEnabled: true
+ property DesktopClock desktopClock: DesktopClock {}
+ property Visualiser visualiser: Visualiser {}
+
+ component DesktopClock: JsonObject {
+ property bool enabled: false
+ property real scale: 1.0
+ property string position: "bottom-right"
+ property bool invertColors: false
+ property DesktopClockBackground background: DesktopClockBackground {}
+ property DesktopClockShadow shadow: DesktopClockShadow {}
+ }
+
+ component DesktopClockBackground: JsonObject {
+ property bool enabled: false
+ property real opacity: 0.7
+ property bool blur: true
+ }
+
+ component DesktopClockShadow: JsonObject {
+ property bool enabled: true
+ property real opacity: 0.7
+ property real blur: 0.4
+ }
+
+ component Visualiser: JsonObject {
+ property bool enabled: false
+ property bool autoHide: true
+ property bool blur: false
+ property real rounding: 1
+ property real spacing: 1
+ }
+}
diff --git a/.config/quickshell/caelestia/config/BarConfig.qml b/.config/quickshell/caelestia/config/BarConfig.qml
new file mode 100644
index 0000000..36a5f78
--- /dev/null
+++ b/.config/quickshell/caelestia/config/BarConfig.qml
@@ -0,0 +1,118 @@
+import Quickshell.Io
+
+JsonObject {
+ property bool persistent: true
+ property bool showOnHover: true
+ property int dragThreshold: 20
+ property ScrollActions scrollActions: ScrollActions {}
+ property Popouts popouts: Popouts {}
+ property Workspaces workspaces: Workspaces {}
+ property ActiveWindow activeWindow: ActiveWindow {}
+ property Tray tray: Tray {}
+ property Status status: Status {}
+ property Clock clock: Clock {}
+ property Sizes sizes: Sizes {}
+ property list excludedScreens: []
+
+ property list entries: [
+ {
+ id: "logo",
+ enabled: true
+ },
+ {
+ id: "workspaces",
+ enabled: true
+ },
+ {
+ id: "spacer",
+ enabled: true
+ },
+ {
+ id: "activeWindow",
+ enabled: true
+ },
+ {
+ id: "spacer",
+ enabled: true
+ },
+ {
+ id: "tray",
+ enabled: true
+ },
+ {
+ id: "clock",
+ enabled: true
+ },
+ {
+ id: "statusIcons",
+ enabled: true
+ },
+ {
+ id: "power",
+ enabled: true
+ }
+ ]
+
+ component ScrollActions: JsonObject {
+ property bool workspaces: true
+ property bool volume: true
+ property bool brightness: true
+ }
+
+ component Popouts: JsonObject {
+ property bool activeWindow: true
+ property bool tray: true
+ property bool statusIcons: true
+ }
+
+ component Workspaces: JsonObject {
+ property int shown: 5
+ property bool activeIndicator: true
+ property bool occupiedBg: false
+ property bool showWindows: true
+ property bool showWindowsOnSpecialWorkspaces: showWindows
+ property bool activeTrail: false
+ property bool perMonitorWorkspaces: true
+ property string label: " " // if empty, will show workspace name's first letter
+ property string occupiedLabel: ""
+ property string activeLabel: ""
+ property string capitalisation: "preserve" // upper, lower, or preserve - relevant only if label is empty
+ property list specialWorkspaceIcons: []
+ }
+
+ component ActiveWindow: JsonObject {
+ property bool inverted: false
+ }
+
+ component Tray: JsonObject {
+ property bool background: false
+ property bool recolour: false
+ property bool compact: false
+ property list iconSubs: []
+ property list hiddenIcons: []
+ }
+
+ component Status: JsonObject {
+ property bool showAudio: false
+ property bool showMicrophone: false
+ property bool showKbLayout: false
+ property bool showNetwork: true
+ property bool showWifi: true
+ property bool showBluetooth: true
+ property bool showBattery: true
+ property bool showLockStatus: true
+ }
+
+ component Clock: JsonObject {
+ property bool showIcon: true
+ }
+
+ component Sizes: JsonObject {
+ property int innerWidth: 40
+ property int windowPreviewSize: 400
+ property int trayMenuWidth: 300
+ property int batteryWidth: 250
+ property int networkWidth: 320
+ property int kbLayoutWidth: 320
+ }
+}
diff --git a/.config/quickshell/caelestia/config/BorderConfig.qml b/.config/quickshell/caelestia/config/BorderConfig.qml
new file mode 100644
index 0000000..b15811f
--- /dev/null
+++ b/.config/quickshell/caelestia/config/BorderConfig.qml
@@ -0,0 +1,6 @@
+import Quickshell.Io
+
+JsonObject {
+ property int thickness: Appearance.padding.normal
+ property int rounding: Appearance.rounding.large
+}
diff --git a/.config/quickshell/caelestia/config/Config.qml b/.config/quickshell/caelestia/config/Config.qml
new file mode 100644
index 0000000..8c01014
--- /dev/null
+++ b/.config/quickshell/caelestia/config/Config.qml
@@ -0,0 +1,522 @@
+pragma Singleton
+
+import qs.utils
+import Caelestia
+import Quickshell
+import Quickshell.Io
+import QtQuick
+
+Singleton {
+ id: root
+
+ property alias appearance: adapter.appearance
+ property alias general: adapter.general
+ property alias background: adapter.background
+ property alias bar: adapter.bar
+ property alias border: adapter.border
+ property alias dashboard: adapter.dashboard
+ property alias controlCenter: adapter.controlCenter
+ property alias launcher: adapter.launcher
+ property alias notifs: adapter.notifs
+ property alias osd: adapter.osd
+ property alias session: adapter.session
+ property alias winfo: adapter.winfo
+ property alias lock: adapter.lock
+ property alias utilities: adapter.utilities
+ property alias sidebar: adapter.sidebar
+ property alias services: adapter.services
+ property alias paths: adapter.paths
+
+ // Public save function - call this to persist config changes
+ function save(): void {
+ saveTimer.restart();
+ recentlySaved = true;
+ recentSaveCooldown.restart();
+ }
+
+ property bool recentlySaved: false
+
+ ElapsedTimer {
+ id: timer
+ }
+
+ Timer {
+ id: saveTimer
+
+ interval: 500
+ onTriggered: {
+ timer.restart();
+ try {
+ // Parse current config to preserve structure and comments if possible
+ let config = {};
+ try {
+ config = JSON.parse(fileView.text());
+ } catch (e) {
+ // If parsing fails, start with empty object
+ config = {};
+ }
+
+ // Update config with current values
+ config = serializeConfig();
+
+ // Save to file with pretty printing
+ fileView.setText(JSON.stringify(config, null, 2));
+ } catch (e) {
+ Toaster.toast(qsTr("Failed to serialize config"), e.message, "settings_alert", Toast.Error);
+ }
+ }
+ }
+
+ Timer {
+ id: recentSaveCooldown
+
+ interval: 2000
+ onTriggered: {
+ recentlySaved = false;
+ }
+ }
+
+ // Helper function to serialize the config object
+ function serializeConfig(): var {
+ return {
+ appearance: serializeAppearance(),
+ general: serializeGeneral(),
+ background: serializeBackground(),
+ bar: serializeBar(),
+ border: serializeBorder(),
+ dashboard: serializeDashboard(),
+ controlCenter: serializeControlCenter(),
+ launcher: serializeLauncher(),
+ notifs: serializeNotifs(),
+ osd: serializeOsd(),
+ session: serializeSession(),
+ winfo: serializeWinfo(),
+ lock: serializeLock(),
+ utilities: serializeUtilities(),
+ sidebar: serializeSidebar(),
+ services: serializeServices(),
+ paths: serializePaths()
+ };
+ }
+
+ function serializeAppearance(): var {
+ return {
+ rounding: {
+ scale: appearance.rounding.scale
+ },
+ spacing: {
+ scale: appearance.spacing.scale
+ },
+ padding: {
+ scale: appearance.padding.scale
+ },
+ font: {
+ family: {
+ sans: appearance.font.family.sans,
+ mono: appearance.font.family.mono,
+ material: appearance.font.family.material,
+ clock: appearance.font.family.clock
+ },
+ size: {
+ scale: appearance.font.size.scale
+ }
+ },
+ anim: {
+ mediaGifSpeedAdjustment: 300,
+ sessionGifSpeed: 0.7,
+ durations: {
+ scale: appearance.anim.durations.scale
+ }
+ },
+ transparency: {
+ enabled: appearance.transparency.enabled,
+ base: appearance.transparency.base,
+ layers: appearance.transparency.layers
+ }
+ };
+ }
+
+ function serializeGeneral(): var {
+ return {
+ logo: general.logo,
+ apps: {
+ terminal: general.apps.terminal,
+ audio: general.apps.audio,
+ playback: general.apps.playback,
+ explorer: general.apps.explorer
+ },
+ idle: {
+ lockBeforeSleep: general.idle.lockBeforeSleep,
+ inhibitWhenAudio: general.idle.inhibitWhenAudio,
+ timeouts: general.idle.timeouts
+ },
+ battery: {
+ warnLevels: general.battery.warnLevels,
+ criticalLevel: general.battery.criticalLevel
+ }
+ };
+ }
+
+ function serializeBackground(): var {
+ return {
+ enabled: background.enabled,
+ wallpaperEnabled: background.wallpaperEnabled,
+ desktopClock: {
+ enabled: background.desktopClock.enabled,
+ scale: background.desktopClock.scale,
+ position: background.desktopClock.position,
+ invertColors: background.desktopClock.invertColors,
+ background: {
+ enabled: background.desktopClock.background.enabled,
+ opacity: background.desktopClock.background.opacity,
+ blur: background.desktopClock.background.blur
+ },
+ shadow: {
+ enabled: background.desktopClock.shadow.enabled,
+ opacity: background.desktopClock.shadow.opacity,
+ blur: background.desktopClock.shadow.blur
+ }
+ },
+ visualiser: {
+ enabled: background.visualiser.enabled,
+ autoHide: background.visualiser.autoHide,
+ blur: background.visualiser.blur,
+ rounding: background.visualiser.rounding,
+ spacing: background.visualiser.spacing
+ }
+ };
+ }
+
+ function serializeBar(): var {
+ return {
+ persistent: bar.persistent,
+ showOnHover: bar.showOnHover,
+ dragThreshold: bar.dragThreshold,
+ scrollActions: {
+ workspaces: bar.scrollActions.workspaces,
+ volume: bar.scrollActions.volume,
+ brightness: bar.scrollActions.brightness
+ },
+ popouts: {
+ activeWindow: bar.popouts.activeWindow,
+ tray: bar.popouts.tray,
+ statusIcons: bar.popouts.statusIcons
+ },
+ workspaces: {
+ shown: bar.workspaces.shown,
+ activeIndicator: bar.workspaces.activeIndicator,
+ occupiedBg: bar.workspaces.occupiedBg,
+ showWindows: bar.workspaces.showWindows,
+ showWindowsOnSpecialWorkspaces: bar.workspaces.showWindowsOnSpecialWorkspaces,
+ activeTrail: bar.workspaces.activeTrail,
+ perMonitorWorkspaces: bar.workspaces.perMonitorWorkspaces,
+ label: bar.workspaces.label,
+ occupiedLabel: bar.workspaces.occupiedLabel,
+ activeLabel: bar.workspaces.activeLabel,
+ capitalisation: bar.workspaces.capitalisation,
+ specialWorkspaceIcons: bar.workspaces.specialWorkspaceIcons
+ },
+ tray: {
+ background: bar.tray.background,
+ recolour: bar.tray.recolour,
+ compact: bar.tray.compact,
+ iconSubs: bar.tray.iconSubs
+ },
+ status: {
+ showAudio: bar.status.showAudio,
+ showMicrophone: bar.status.showMicrophone,
+ showKbLayout: bar.status.showKbLayout,
+ showNetwork: bar.status.showNetwork,
+ showWifi: bar.status.showWifi,
+ showBluetooth: bar.status.showBluetooth,
+ showBattery: bar.status.showBattery,
+ showLockStatus: bar.status.showLockStatus
+ },
+ clock: {
+ showIcon: bar.clock.showIcon
+ },
+ sizes: {
+ innerWidth: bar.sizes.innerWidth,
+ windowPreviewSize: bar.sizes.windowPreviewSize,
+ trayMenuWidth: bar.sizes.trayMenuWidth,
+ batteryWidth: bar.sizes.batteryWidth,
+ networkWidth: bar.sizes.networkWidth
+ },
+ entries: bar.entries,
+ excludedScreens: bar.excludedScreens
+ };
+ }
+
+ function serializeBorder(): var {
+ return {
+ thickness: border.thickness,
+ rounding: border.rounding
+ };
+ }
+
+ function serializeDashboard(): var {
+ return {
+ enabled: dashboard.enabled,
+ showOnHover: dashboard.showOnHover,
+ updateInterval: dashboard.updateInterval,
+ dragThreshold: dashboard.dragThreshold,
+ performance: {
+ showBattery: dashboard.performance.showBattery,
+ showGpu: dashboard.performance.showGpu,
+ showCpu: dashboard.performance.showCpu,
+ showMemory: dashboard.performance.showMemory,
+ showStorage: dashboard.performance.showStorage,
+ showNetwork: dashboard.performance.showNetwork
+ },
+ sizes: {
+ tabIndicatorHeight: dashboard.sizes.tabIndicatorHeight,
+ tabIndicatorSpacing: dashboard.sizes.tabIndicatorSpacing,
+ infoWidth: dashboard.sizes.infoWidth,
+ infoIconSize: dashboard.sizes.infoIconSize,
+ dateTimeWidth: dashboard.sizes.dateTimeWidth,
+ mediaWidth: dashboard.sizes.mediaWidth,
+ mediaProgressSweep: dashboard.sizes.mediaProgressSweep,
+ mediaProgressThickness: dashboard.sizes.mediaProgressThickness,
+ resourceProgessThickness: dashboard.sizes.resourceProgessThickness,
+ weatherWidth: dashboard.sizes.weatherWidth,
+ mediaCoverArtSize: dashboard.sizes.mediaCoverArtSize,
+ mediaVisualiserSize: dashboard.sizes.mediaVisualiserSize,
+ resourceSize: dashboard.sizes.resourceSize
+ }
+ };
+ }
+
+ function serializeControlCenter(): var {
+ return {
+ sizes: {
+ heightMult: controlCenter.sizes.heightMult,
+ ratio: controlCenter.sizes.ratio
+ }
+ };
+ }
+
+ function serializeLauncher(): var {
+ return {
+ enabled: launcher.enabled,
+ showOnHover: launcher.showOnHover,
+ maxShown: launcher.maxShown,
+ maxWallpapers: launcher.maxWallpapers,
+ specialPrefix: launcher.specialPrefix,
+ actionPrefix: launcher.actionPrefix,
+ enableDangerousActions: launcher.enableDangerousActions,
+ dragThreshold: launcher.dragThreshold,
+ vimKeybinds: launcher.vimKeybinds,
+ favouriteApps: launcher.favouriteApps,
+ hiddenApps: launcher.hiddenApps,
+ useFuzzy: {
+ apps: launcher.useFuzzy.apps,
+ actions: launcher.useFuzzy.actions,
+ schemes: launcher.useFuzzy.schemes,
+ variants: launcher.useFuzzy.variants,
+ wallpapers: launcher.useFuzzy.wallpapers
+ },
+ sizes: {
+ itemWidth: launcher.sizes.itemWidth,
+ itemHeight: launcher.sizes.itemHeight,
+ wallpaperWidth: launcher.sizes.wallpaperWidth,
+ wallpaperHeight: launcher.sizes.wallpaperHeight
+ },
+ actions: launcher.actions
+ };
+ }
+
+ function serializeNotifs(): var {
+ return {
+ expire: notifs.expire,
+ defaultExpireTimeout: notifs.defaultExpireTimeout,
+ clearThreshold: notifs.clearThreshold,
+ expandThreshold: notifs.expandThreshold,
+ actionOnClick: notifs.actionOnClick,
+ groupPreviewNum: notifs.groupPreviewNum,
+ sizes: {
+ width: notifs.sizes.width,
+ image: notifs.sizes.image,
+ badge: notifs.sizes.badge
+ }
+ };
+ }
+
+ function serializeOsd(): var {
+ return {
+ enabled: osd.enabled,
+ hideDelay: osd.hideDelay,
+ enableBrightness: osd.enableBrightness,
+ enableMicrophone: osd.enableMicrophone,
+ sizes: {
+ sliderWidth: osd.sizes.sliderWidth,
+ sliderHeight: osd.sizes.sliderHeight
+ }
+ };
+ }
+
+ function serializeSession(): var {
+ return {
+ enabled: session.enabled,
+ dragThreshold: session.dragThreshold,
+ vimKeybinds: session.vimKeybinds,
+ icons: {
+ logout: session.icons.logout,
+ shutdown: session.icons.shutdown,
+ hibernate: session.icons.hibernate,
+ reboot: session.icons.reboot
+ },
+ commands: {
+ logout: session.commands.logout,
+ shutdown: session.commands.shutdown,
+ hibernate: session.commands.hibernate,
+ reboot: session.commands.reboot
+ },
+ sizes: {
+ button: session.sizes.button
+ }
+ };
+ }
+
+ function serializeWinfo(): var {
+ return {
+ sizes: {
+ heightMult: winfo.sizes.heightMult,
+ detailsWidth: winfo.sizes.detailsWidth
+ }
+ };
+ }
+
+ function serializeLock(): var {
+ return {
+ recolourLogo: lock.recolourLogo,
+ enableFprint: lock.enableFprint,
+ maxFprintTries: lock.maxFprintTries,
+ sizes: {
+ heightMult: lock.sizes.heightMult,
+ ratio: lock.sizes.ratio,
+ centerWidth: lock.sizes.centerWidth
+ }
+ };
+ }
+
+ function serializeUtilities(): var {
+ return {
+ enabled: utilities.enabled,
+ maxToasts: utilities.maxToasts,
+ sizes: {
+ width: utilities.sizes.width,
+ toastWidth: utilities.sizes.toastWidth
+ },
+ toasts: {
+ configLoaded: utilities.toasts.configLoaded,
+ chargingChanged: utilities.toasts.chargingChanged,
+ gameModeChanged: utilities.toasts.gameModeChanged,
+ dndChanged: utilities.toasts.dndChanged,
+ audioOutputChanged: utilities.toasts.audioOutputChanged,
+ audioInputChanged: utilities.toasts.audioInputChanged,
+ capsLockChanged: utilities.toasts.capsLockChanged,
+ numLockChanged: utilities.toasts.numLockChanged,
+ kbLayoutChanged: utilities.toasts.kbLayoutChanged,
+ vpnChanged: utilities.toasts.vpnChanged,
+ nowPlaying: utilities.toasts.nowPlaying
+ },
+ vpn: {
+ enabled: utilities.vpn.enabled,
+ provider: utilities.vpn.provider
+ }
+ };
+ }
+
+ function serializeSidebar(): var {
+ return {
+ enabled: sidebar.enabled,
+ dragThreshold: sidebar.dragThreshold,
+ sizes: {
+ width: sidebar.sizes.width
+ }
+ };
+ }
+
+ function serializeServices(): var {
+ return {
+ weatherLocation: services.weatherLocation,
+ useFahrenheit: services.useFahrenheit,
+ useFahrenheitPerformance: services.useFahrenheitPerformance,
+ useTwelveHourClock: services.useTwelveHourClock,
+ gpuType: services.gpuType,
+ visualiserBars: services.visualiserBars,
+ audioIncrement: services.audioIncrement,
+ brightnessIncrement: services.brightnessIncrement,
+ maxVolume: services.maxVolume,
+ smartScheme: services.smartScheme,
+ defaultPlayer: services.defaultPlayer,
+ playerAliases: services.playerAliases
+ };
+ }
+
+ function serializePaths(): var {
+ return {
+ wallpaperDir: paths.wallpaperDir,
+ sessionGif: paths.sessionGif,
+ mediaGif: paths.mediaGif
+ };
+ }
+
+ FileView {
+ id: fileView
+
+ path: `${Paths.config}/shell.json`
+ watchChanges: true
+ onFileChanged: {
+ // Prevent reload loop - don't reload if we just saved
+ if (!recentlySaved) {
+ timer.restart();
+ reload();
+ } else {
+ // Self-initiated save - reload without toast
+ reload();
+ }
+ }
+ onLoaded: {
+ try {
+ JSON.parse(text());
+ const elapsed = timer.elapsedMs();
+ // Only show toast for external changes (not our own saves) and when elapsed time is meaningful
+ if (adapter.utilities.toasts.configLoaded && !recentlySaved && elapsed > 0) {
+ Toaster.toast(qsTr("Config loaded"), qsTr("Config loaded in %1ms").arg(elapsed), "rule_settings");
+ } else if (adapter.utilities.toasts.configLoaded && recentlySaved && elapsed > 0) {
+ Toaster.toast(qsTr("Config saved"), qsTr("Config reloaded in %1ms").arg(elapsed), "rule_settings");
+ }
+ } catch (e) {
+ Toaster.toast(qsTr("Failed to load config"), e.message, "settings_alert", Toast.Error);
+ }
+ }
+ onLoadFailed: err => {
+ if (err !== FileViewError.FileNotFound)
+ Toaster.toast(qsTr("Failed to read config file"), FileViewError.toString(err), "settings_alert", Toast.Warning);
+ }
+ onSaveFailed: err => Toaster.toast(qsTr("Failed to save config"), FileViewError.toString(err), "settings_alert", Toast.Error)
+
+ JsonAdapter {
+ id: adapter
+
+ property AppearanceConfig appearance: AppearanceConfig {}
+ property GeneralConfig general: GeneralConfig {}
+ property BackgroundConfig background: BackgroundConfig {}
+ property BarConfig bar: BarConfig {}
+ property BorderConfig border: BorderConfig {}
+ property DashboardConfig dashboard: DashboardConfig {}
+ property ControlCenterConfig controlCenter: ControlCenterConfig {}
+ property LauncherConfig launcher: LauncherConfig {}
+ property NotifsConfig notifs: NotifsConfig {}
+ property OsdConfig osd: OsdConfig {}
+ property SessionConfig session: SessionConfig {}
+ property WInfoConfig winfo: WInfoConfig {}
+ property LockConfig lock: LockConfig {}
+ property UtilitiesConfig utilities: UtilitiesConfig {}
+ property SidebarConfig sidebar: SidebarConfig {}
+ property ServiceConfig services: ServiceConfig {}
+ property UserPaths paths: UserPaths {}
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/config/ControlCenterConfig.qml b/.config/quickshell/caelestia/config/ControlCenterConfig.qml
new file mode 100644
index 0000000..a588949
--- /dev/null
+++ b/.config/quickshell/caelestia/config/ControlCenterConfig.qml
@@ -0,0 +1,10 @@
+import Quickshell.Io
+
+JsonObject {
+ property Sizes sizes: Sizes {}
+
+ component Sizes: JsonObject {
+ property real heightMult: 0.7
+ property real ratio: 16 / 9
+ }
+}
diff --git a/.config/quickshell/caelestia/config/DashboardConfig.qml b/.config/quickshell/caelestia/config/DashboardConfig.qml
new file mode 100644
index 0000000..e089550
--- /dev/null
+++ b/.config/quickshell/caelestia/config/DashboardConfig.qml
@@ -0,0 +1,36 @@
+import Quickshell.Io
+
+JsonObject {
+ property bool enabled: true
+ property bool showOnHover: true
+ property int mediaUpdateInterval: 500
+ property int resourceUpdateInterval: 1000
+ property int dragThreshold: 50
+ property Sizes sizes: Sizes {}
+ property Performance performance: Performance {}
+
+ component Performance: JsonObject {
+ property bool showBattery: true
+ property bool showGpu: true
+ property bool showCpu: true
+ property bool showMemory: true
+ property bool showStorage: true
+ property bool showNetwork: true
+ }
+
+ component Sizes: JsonObject {
+ 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/quickshell/caelestia/config/GeneralConfig.qml b/.config/quickshell/caelestia/config/GeneralConfig.qml
new file mode 100644
index 0000000..52ef0de
--- /dev/null
+++ b/.config/quickshell/caelestia/config/GeneralConfig.qml
@@ -0,0 +1,60 @@
+import Quickshell.Io
+
+JsonObject {
+ property string logo: ""
+ property Apps apps: Apps {}
+ property Idle idle: Idle {}
+ property Battery battery: Battery {}
+
+ component Apps: JsonObject {
+ property list terminal: ["foot"]
+ property list audio: ["pavucontrol"]
+ property list playback: ["mpv"]
+ property list explorer: ["thunar"]
+ }
+
+ component Idle: JsonObject {
+ property bool lockBeforeSleep: true
+ property bool inhibitWhenAudio: true
+ property list timeouts: [
+ {
+ timeout: 180,
+ idleAction: "lock"
+ },
+ {
+ timeout: 300,
+ idleAction: "dpms off",
+ returnAction: "dpms on"
+ },
+ {
+ timeout: 600,
+ idleAction: ["systemctl", "suspend-then-hibernate"]
+ }
+ ]
+ }
+
+ component Battery: JsonObject {
+ property list warnLevels: [
+ {
+ level: 20,
+ title: qsTr("Low battery"),
+ message: qsTr("You might want to plug in a charger"),
+ icon: "battery_android_frame_2"
+ },
+ {
+ level: 10,
+ title: qsTr("Did you see the previous message?"),
+ message: qsTr("You should probably plug in a charger now "),
+ icon: "battery_android_frame_1"
+ },
+ {
+ level: 5,
+ title: qsTr("Critical battery level"),
+ message: qsTr("PLUG THE CHARGER RIGHT NOW!!"),
+ icon: "battery_android_alert",
+ critical: true
+ },
+ ]
+ property int criticalLevel: 3
+ }
+}
diff --git a/.config/quickshell/caelestia/config/LauncherConfig.qml b/.config/quickshell/caelestia/config/LauncherConfig.qml
new file mode 100644
index 0000000..d9e3a73
--- /dev/null
+++ b/.config/quickshell/caelestia/config/LauncherConfig.qml
@@ -0,0 +1,147 @@
+import Quickshell.Io
+
+JsonObject {
+ property bool enabled: true
+ property bool showOnHover: false
+ property int maxShown: 7
+ property int maxWallpapers: 9 // Warning: even numbers look bad
+ property string specialPrefix: "@"
+ property string actionPrefix: ">"
+ property bool enableDangerousActions: false // Allow actions that can cause losing data, like shutdown, reboot and logout
+ property int dragThreshold: 50
+ property bool vimKeybinds: false
+ property list favouriteApps: []
+ property list hiddenApps: []
+ property UseFuzzy useFuzzy: UseFuzzy {}
+ property Sizes sizes: Sizes {}
+
+ component UseFuzzy: JsonObject {
+ property bool apps: false
+ property bool actions: false
+ property bool schemes: false
+ property bool variants: false
+ property bool wallpapers: false
+ }
+
+ component Sizes: JsonObject {
+ property int itemWidth: 600
+ property int itemHeight: 57
+ property int wallpaperWidth: 280
+ property int wallpaperHeight: 200
+ }
+
+ property list actions: [
+ {
+ name: "Calculator",
+ icon: "calculate",
+ description: "Do simple math equations (powered by Qalc)",
+ command: ["autocomplete", "calc"],
+ enabled: true,
+ dangerous: false
+ },
+ {
+ name: "Scheme",
+ icon: "palette",
+ description: "Change the current colour scheme",
+ command: ["autocomplete", "scheme"],
+ enabled: true,
+ dangerous: false
+ },
+ {
+ name: "Wallpaper",
+ icon: "image",
+ description: "Change the current wallpaper",
+ command: ["autocomplete", "wallpaper"],
+ enabled: true,
+ dangerous: false
+ },
+ {
+ name: "Variant",
+ icon: "colors",
+ description: "Change the current scheme variant",
+ command: ["autocomplete", "variant"],
+ enabled: true,
+ dangerous: false
+ },
+ {
+ name: "Transparency",
+ icon: "opacity",
+ description: "Change shell transparency",
+ command: ["autocomplete", "transparency"],
+ enabled: false,
+ dangerous: false
+ },
+ {
+ name: "Random",
+ icon: "casino",
+ description: "Switch to a random wallpaper",
+ command: ["caelestia", "wallpaper", "-r"],
+ enabled: true,
+ dangerous: false
+ },
+ {
+ name: "Light",
+ icon: "light_mode",
+ description: "Change the scheme to light mode",
+ command: ["setMode", "light"],
+ enabled: true,
+ dangerous: false
+ },
+ {
+ name: "Dark",
+ icon: "dark_mode",
+ description: "Change the scheme to dark mode",
+ command: ["setMode", "dark"],
+ enabled: true,
+ dangerous: false
+ },
+ {
+ name: "Shutdown",
+ icon: "power_settings_new",
+ description: "Shutdown the system",
+ command: ["systemctl", "poweroff"],
+ enabled: true,
+ dangerous: true
+ },
+ {
+ name: "Reboot",
+ icon: "cached",
+ description: "Reboot the system",
+ command: ["systemctl", "reboot"],
+ enabled: true,
+ dangerous: true
+ },
+ {
+ name: "Logout",
+ icon: "exit_to_app",
+ description: "Log out of the current session",
+ command: ["loginctl", "terminate-user", ""],
+ enabled: true,
+ dangerous: true
+ },
+ {
+ name: "Lock",
+ icon: "lock",
+ description: "Lock the current session",
+ command: ["loginctl", "lock-session"],
+ enabled: true,
+ dangerous: false
+ },
+ {
+ name: "Sleep",
+ icon: "bedtime",
+ description: "Suspend then hibernate",
+ command: ["systemctl", "suspend-then-hibernate"],
+ enabled: true,
+ dangerous: false
+ },
+ {
+ name: "Settings",
+ icon: "settings",
+ description: "Configure the shell",
+ command: ["caelestia", "shell", "controlCenter", "open"],
+ enabled: true,
+ dangerous: false
+ }
+ ]
+}
diff --git a/.config/quickshell/caelestia/config/LockConfig.qml b/.config/quickshell/caelestia/config/LockConfig.qml
new file mode 100644
index 0000000..2af4e2c
--- /dev/null
+++ b/.config/quickshell/caelestia/config/LockConfig.qml
@@ -0,0 +1,14 @@
+import Quickshell.Io
+
+JsonObject {
+ property bool recolourLogo: false
+ property bool enableFprint: true
+ property int maxFprintTries: 3
+ property Sizes sizes: Sizes {}
+
+ component Sizes: JsonObject {
+ property real heightMult: 0.7
+ property real ratio: 16 / 9
+ property int centerWidth: 600
+ }
+}
diff --git a/.config/quickshell/caelestia/config/NotifsConfig.qml b/.config/quickshell/caelestia/config/NotifsConfig.qml
new file mode 100644
index 0000000..fa2db49
--- /dev/null
+++ b/.config/quickshell/caelestia/config/NotifsConfig.qml
@@ -0,0 +1,18 @@
+import Quickshell.Io
+
+JsonObject {
+ property bool expire: true
+ property int defaultExpireTimeout: 5000
+ property real clearThreshold: 0.3
+ property int expandThreshold: 20
+ property bool actionOnClick: false
+ property int groupPreviewNum: 3
+ property bool openExpanded: false // Show the notifichation in expanded state when opening
+ property Sizes sizes: Sizes {}
+
+ component Sizes: JsonObject {
+ property int width: 400
+ property int image: 41
+ property int badge: 20
+ }
+}
diff --git a/.config/quickshell/caelestia/config/OsdConfig.qml b/.config/quickshell/caelestia/config/OsdConfig.qml
new file mode 100644
index 0000000..543fc41
--- /dev/null
+++ b/.config/quickshell/caelestia/config/OsdConfig.qml
@@ -0,0 +1,14 @@
+import Quickshell.Io
+
+JsonObject {
+ property bool enabled: true
+ property int hideDelay: 2000
+ property bool enableBrightness: true
+ property bool enableMicrophone: false
+ property Sizes sizes: Sizes {}
+
+ component Sizes: JsonObject {
+ property int sliderWidth: 30
+ property int sliderHeight: 150
+ }
+}
diff --git a/.config/quickshell/caelestia/config/ServiceConfig.qml b/.config/quickshell/caelestia/config/ServiceConfig.qml
new file mode 100644
index 0000000..29600cc
--- /dev/null
+++ b/.config/quickshell/caelestia/config/ServiceConfig.qml
@@ -0,0 +1,22 @@
+import Quickshell.Io
+import QtQuick
+
+JsonObject {
+ property string weatherLocation: "" // A lat,long pair or empty for autodetection, e.g. "37.8267,-122.4233"
+ property bool useFahrenheit: [Locale.ImperialUSSystem, Locale.ImperialSystem].includes(Qt.locale().measurementSystem)
+ property bool useFahrenheitPerformance: [Locale.ImperialUSSystem, Locale.ImperialSystem].includes(Qt.locale().measurementSystem)
+ property bool useTwelveHourClock: Qt.locale().timeFormat(Locale.ShortFormat).toLowerCase().includes("a")
+ property string gpuType: ""
+ property int visualiserBars: 45
+ property real audioIncrement: 0.1
+ property real brightnessIncrement: 0.1
+ property real maxVolume: 1.0
+ property bool smartScheme: true
+ property string defaultPlayer: "Spotify"
+ property list playerAliases: [
+ {
+ "from": "com.github.th_ch.youtube_music",
+ "to": "YT Music"
+ }
+ ]
+}
diff --git a/.config/quickshell/caelestia/config/SessionConfig.qml b/.config/quickshell/caelestia/config/SessionConfig.qml
new file mode 100644
index 0000000..414f821
--- /dev/null
+++ b/.config/quickshell/caelestia/config/SessionConfig.qml
@@ -0,0 +1,29 @@
+import Quickshell.Io
+
+JsonObject {
+ property bool enabled: true
+ property int dragThreshold: 30
+ property bool vimKeybinds: false
+ property Icons icons: Icons {}
+ property Commands commands: Commands {}
+
+ property Sizes sizes: Sizes {}
+
+ component Icons: JsonObject {
+ property string logout: "logout"
+ property string shutdown: "power_settings_new"
+ property string hibernate: "downloading"
+ property string reboot: "cached"
+ }
+
+ component Commands: JsonObject {
+ property list logout: ["loginctl", "terminate-user", ""]
+ property list shutdown: ["systemctl", "poweroff"]
+ property list hibernate: ["systemctl", "hibernate"]
+ property list reboot: ["systemctl", "reboot"]
+ }
+
+ component Sizes: JsonObject {
+ property int button: 80
+ }
+}
diff --git a/.config/quickshell/caelestia/config/SidebarConfig.qml b/.config/quickshell/caelestia/config/SidebarConfig.qml
new file mode 100644
index 0000000..a871562
--- /dev/null
+++ b/.config/quickshell/caelestia/config/SidebarConfig.qml
@@ -0,0 +1,11 @@
+import Quickshell.Io
+
+JsonObject {
+ property bool enabled: true
+ property int dragThreshold: 80
+ property Sizes sizes: Sizes {}
+
+ component Sizes: JsonObject {
+ property int width: 430
+ }
+}
diff --git a/.config/quickshell/caelestia/config/UserPaths.qml b/.config/quickshell/caelestia/config/UserPaths.qml
new file mode 100644
index 0000000..f8de267
--- /dev/null
+++ b/.config/quickshell/caelestia/config/UserPaths.qml
@@ -0,0 +1,8 @@
+import qs.utils
+import Quickshell.Io
+
+JsonObject {
+ property string wallpaperDir: `${Paths.pictures}/Wallpapers`
+ property string sessionGif: "root:/assets/kurukuru.gif"
+ property string mediaGif: "root:/assets/bongocat.gif"
+}
diff --git a/.config/quickshell/caelestia/config/UtilitiesConfig.qml b/.config/quickshell/caelestia/config/UtilitiesConfig.qml
new file mode 100644
index 0000000..cf46446
--- /dev/null
+++ b/.config/quickshell/caelestia/config/UtilitiesConfig.qml
@@ -0,0 +1,35 @@
+import Quickshell.Io
+
+JsonObject {
+ property bool enabled: true
+ property int maxToasts: 4
+
+ property Sizes sizes: Sizes {}
+ property Toasts toasts: Toasts {}
+ property Vpn vpn: Vpn {}
+
+ component Sizes: JsonObject {
+ property int width: 430
+ property int toastWidth: 430
+ }
+
+ component Toasts: JsonObject {
+ property bool configLoaded: true
+ property bool chargingChanged: true
+ property bool gameModeChanged: true
+ property bool dndChanged: true
+ property bool audioOutputChanged: true
+ property bool audioInputChanged: true
+ property bool capsLockChanged: true
+ property bool numLockChanged: true
+ property bool kbLayoutChanged: true
+ property bool kbLimit: true
+ property bool vpnChanged: true
+ property bool nowPlaying: false
+ }
+
+ component Vpn: JsonObject {
+ property bool enabled: false
+ property list provider: ["netbird"]
+ }
+}
diff --git a/.config/quickshell/caelestia/config/WInfoConfig.qml b/.config/quickshell/caelestia/config/WInfoConfig.qml
new file mode 100644
index 0000000..5025780
--- /dev/null
+++ b/.config/quickshell/caelestia/config/WInfoConfig.qml
@@ -0,0 +1,10 @@
+import Quickshell.Io
+
+JsonObject {
+ property Sizes sizes: Sizes {}
+
+ component Sizes: JsonObject {
+ property real heightMult: 0.7
+ property real detailsWidth: 500
+ }
+}
diff --git a/.config/quickshell/caelestia/extras/CMakeLists.txt b/.config/quickshell/caelestia/extras/CMakeLists.txt
new file mode 100644
index 0000000..52fe17c
--- /dev/null
+++ b/.config/quickshell/caelestia/extras/CMakeLists.txt
@@ -0,0 +1,9 @@
+# Version
+add_executable(version version.cpp)
+target_compile_definitions(version PRIVATE
+ PROJECT_NAME="${PROJECT_NAME}"
+ VERSION="${VERSION}"
+ GIT_REVISION="${GIT_REVISION}"
+ DISTRIBUTOR="${DISTRIBUTOR}"
+)
+install(TARGETS version DESTINATION ${INSTALL_LIBDIR})
diff --git a/.config/quickshell/caelestia/extras/version.cpp b/.config/quickshell/caelestia/extras/version.cpp
new file mode 100644
index 0000000..e1a0cf3
--- /dev/null
+++ b/.config/quickshell/caelestia/extras/version.cpp
@@ -0,0 +1,26 @@
+#include
+
+int main(int argc, char* argv[]) {
+ if (argc > 1) {
+ std::string arg = argv[1];
+
+ if (arg == "-t" || arg == "--terse") {
+ std::cout << PROJECT_NAME << std::endl;
+ std::cout << VERSION << std::endl;
+ std::cout << GIT_REVISION << std::endl;
+ std::cout << DISTRIBUTOR << std::endl;
+ } else if (arg == "-s" || arg == "--short") {
+ std::cout << PROJECT_NAME << " " << VERSION << ", revision " << GIT_REVISION << ", distrubuted by: " << DISTRIBUTOR << std::endl;
+ } else {
+ std::cout << "Usage: " << argv[0] << " [-t | --terse] [-s | --short]" << std::endl;
+ return arg != "-h" && arg != "--help";
+ }
+ } else {
+ std::cout << "Project: " << PROJECT_NAME << std::endl;
+ std::cout << "Version: " << VERSION << std::endl;
+ std::cout << "Git revision: " << GIT_REVISION << std::endl;
+ std::cout << "Distributor: " << DISTRIBUTOR << std::endl;
+ }
+
+ return 0;
+}
diff --git a/.config/quickshell/caelestia/flake.lock b/.config/quickshell/caelestia/flake.lock
new file mode 100644
index 0000000..9f1b2ea
--- /dev/null
+++ b/.config/quickshell/caelestia/flake.lock
@@ -0,0 +1,70 @@
+{
+ "nodes": {
+ "caelestia-cli": {
+ "inputs": {
+ "caelestia-shell": [],
+ "nixpkgs": [
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1772764582,
+ "narHash": "sha256-hSwjmpXHFqzSXrndVekA0IheKrbC7wi0IbfZTYwlmXw=",
+ "owner": "caelestia-dots",
+ "repo": "cli",
+ "rev": "4bcd42f482d038b98145b0b03388244b68b7d35d",
+ "type": "github"
+ },
+ "original": {
+ "owner": "caelestia-dots",
+ "repo": "cli",
+ "type": "github"
+ }
+ },
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1772773019,
+ "narHash": "sha256-E1bxHxNKfDoQUuvriG71+f+s/NT0qWkImXsYZNFFfCs=",
+ "owner": "nixos",
+ "repo": "nixpkgs",
+ "rev": "aca4d95fce4914b3892661bcb80b8087293536c6",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nixos",
+ "ref": "nixos-unstable",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "quickshell": {
+ "inputs": {
+ "nixpkgs": [
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1772925576,
+ "narHash": "sha256-mMoiXABDtkSJxCYDrkhJ/TrrJf5M46oUfIlJvv2gkZ0=",
+ "ref": "refs/heads/master",
+ "rev": "15a84097653593dd15fad59a56befc2b7bdc270d",
+ "revCount": 750,
+ "type": "git",
+ "url": "https://git.outfoxxed.me/outfoxxed/quickshell"
+ },
+ "original": {
+ "type": "git",
+ "url": "https://git.outfoxxed.me/outfoxxed/quickshell"
+ }
+ },
+ "root": {
+ "inputs": {
+ "caelestia-cli": "caelestia-cli",
+ "nixpkgs": "nixpkgs",
+ "quickshell": "quickshell"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/.config/quickshell/caelestia/flake.nix b/.config/quickshell/caelestia/flake.nix
new file mode 100644
index 0000000..5c88411
--- /dev/null
+++ b/.config/quickshell/caelestia/flake.nix
@@ -0,0 +1,60 @@
+{
+ description = "Desktop shell for Caelestia dots";
+
+ inputs = {
+ nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
+
+ quickshell = {
+ url = "git+https://git.outfoxxed.me/outfoxxed/quickshell";
+ inputs.nixpkgs.follows = "nixpkgs";
+ };
+
+ caelestia-cli = {
+ url = "github:caelestia-dots/cli";
+ inputs.nixpkgs.follows = "nixpkgs";
+ inputs.caelestia-shell.follows = "";
+ };
+ };
+
+ outputs = {
+ self,
+ nixpkgs,
+ ...
+ } @ inputs: let
+ forAllSystems = fn:
+ nixpkgs.lib.genAttrs nixpkgs.lib.platforms.linux (
+ system: fn nixpkgs.legacyPackages.${system}
+ );
+ in {
+ formatter = forAllSystems (pkgs: pkgs.alejandra);
+
+ packages = forAllSystems (pkgs: rec {
+ caelestia-shell = pkgs.callPackage ./nix {
+ rev = self.rev or self.dirtyRev;
+ stdenv = pkgs.clangStdenv;
+ quickshell = inputs.quickshell.packages.${pkgs.stdenv.hostPlatform.system}.default.override {
+ withX11 = false;
+ withI3 = false;
+ };
+ app2unit = pkgs.callPackage ./nix/app2unit.nix {inherit pkgs;};
+ caelestia-cli = inputs.caelestia-cli.packages.${pkgs.stdenv.hostPlatform.system}.default;
+ };
+ with-cli = caelestia-shell.override {withCli = true;};
+ debug = caelestia-shell.override {debug = true;};
+ default = caelestia-shell;
+ });
+
+ devShells = forAllSystems (pkgs: {
+ default = let
+ shell = self.packages.${pkgs.stdenv.hostPlatform.system}.caelestia-shell;
+ in
+ pkgs.mkShell.override {stdenv = shell.stdenv;} {
+ inputsFrom = [shell shell.plugin shell.extras];
+ packages = with pkgs; [clazy material-symbols rubik nerd-fonts.caskaydia-cove];
+ CAELESTIA_XKB_RULES_PATH = "${pkgs.xkeyboard-config}/share/xkeyboard-config-2/rules/base.lst";
+ };
+ });
+
+ homeManagerModules.default = import ./nix/hm-module.nix self;
+ };
+}
diff --git a/.config/quickshell/caelestia/modules/BatteryMonitor.qml b/.config/quickshell/caelestia/modules/BatteryMonitor.qml
new file mode 100644
index 0000000..d24cff2
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/BatteryMonitor.qml
@@ -0,0 +1,56 @@
+import qs.config
+import Caelestia
+import Quickshell
+import Quickshell.Services.UPower
+import QtQuick
+
+Scope {
+ id: root
+
+ readonly property list warnLevels: [...Config.general.battery.warnLevels].sort((a, b) => b.level - a.level)
+
+ Connections {
+ target: UPower
+
+ function onOnBatteryChanged(): void {
+ if (UPower.onBattery) {
+ if (Config.utilities.toasts.chargingChanged)
+ Toaster.toast(qsTr("Charger unplugged"), qsTr("Battery is discharging"), "power_off");
+ } else {
+ if (Config.utilities.toasts.chargingChanged)
+ Toaster.toast(qsTr("Charger plugged in"), qsTr("Battery is charging"), "power");
+ for (const level of root.warnLevels)
+ level.warned = false;
+ }
+ }
+ }
+
+ Connections {
+ target: UPower.displayDevice
+
+ function onPercentageChanged(): void {
+ if (!UPower.onBattery)
+ return;
+
+ const p = UPower.displayDevice.percentage * 100;
+ for (const level of root.warnLevels) {
+ if (p <= level.level && !level.warned) {
+ level.warned = true;
+ Toaster.toast(level.title ?? qsTr("Battery warning"), level.message ?? qsTr("Battery level is low"), level.icon ?? "battery_android_alert", level.critical ? Toast.Error : Toast.Warning);
+ }
+ }
+
+ if (!hibernateTimer.running && p <= Config.general.battery.criticalLevel) {
+ Toaster.toast(qsTr("Hibernating in 5 seconds"), qsTr("Hibernating to prevent data loss"), "battery_android_alert", Toast.Error);
+ hibernateTimer.start();
+ }
+ }
+ }
+
+ Timer {
+ id: hibernateTimer
+
+ interval: 5000
+ onTriggered: Quickshell.execDetached(["systemctl", "hibernate"])
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/IdleMonitors.qml b/.config/quickshell/caelestia/modules/IdleMonitors.qml
new file mode 100644
index 0000000..b7ce058
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/IdleMonitors.qml
@@ -0,0 +1,51 @@
+pragma ComponentBehavior: Bound
+
+import "lock"
+import qs.config
+import qs.services
+import Caelestia.Internal
+import Quickshell
+import Quickshell.Wayland
+
+Scope {
+ id: root
+
+ required property Lock lock
+ readonly property bool enabled: !Config.general.idle.inhibitWhenAudio || !Players.list.some(p => p.isPlaying)
+
+ function handleIdleAction(action: var): void {
+ if (!action)
+ return;
+
+ if (action === "lock")
+ lock.lock.locked = true;
+ else if (action === "unlock")
+ lock.lock.locked = false;
+ else if (typeof action === "string")
+ Hypr.dispatch(action);
+ else
+ Quickshell.execDetached(action);
+ }
+
+ LogindManager {
+ onAboutToSleep: {
+ if (Config.general.idle.lockBeforeSleep)
+ root.lock.lock.locked = true;
+ }
+ onLockRequested: root.lock.lock.locked = true
+ onUnlockRequested: root.lock.lock.unlock()
+ }
+
+ Variants {
+ model: Config.general.idle.timeouts
+
+ IdleMonitor {
+ required property var modelData
+
+ enabled: root.enabled && (modelData.enabled ?? true)
+ timeout: modelData.timeout
+ respectInhibitors: modelData.respectInhibitors ?? true
+ onIsIdleChanged: root.handleIdleAction(isIdle ? modelData.idleAction : modelData.returnAction)
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/Shortcuts.qml b/.config/quickshell/caelestia/modules/Shortcuts.qml
new file mode 100644
index 0000000..3bf20a4
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/Shortcuts.qml
@@ -0,0 +1,142 @@
+import qs.components.misc
+import qs.modules.controlcenter
+import qs.services
+import Caelestia
+import Quickshell
+import Quickshell.Io
+
+Scope {
+ id: root
+
+ property bool launcherInterrupted
+ readonly property bool hasFullscreen: Hypr.focusedWorkspace?.toplevels.values.some(t => t.lastIpcObject.fullscreen === 2) ?? false
+
+ CustomShortcut {
+ name: "controlCenter"
+ description: "Open control center"
+ onPressed: WindowFactory.create()
+ }
+
+ CustomShortcut {
+ name: "showall"
+ description: "Toggle launcher, dashboard and osd"
+ onPressed: {
+ if (root.hasFullscreen)
+ return;
+ const v = Visibilities.getForActive();
+ v.launcher = v.dashboard = v.osd = v.utilities = !(v.launcher || v.dashboard || v.osd || v.utilities);
+ }
+ }
+
+ CustomShortcut {
+ name: "dashboard"
+ description: "Toggle dashboard"
+ onPressed: {
+ if (root.hasFullscreen)
+ return;
+ const visibilities = Visibilities.getForActive();
+ visibilities.dashboard = !visibilities.dashboard;
+ }
+ }
+
+ CustomShortcut {
+ name: "session"
+ description: "Toggle session menu"
+ onPressed: {
+ if (root.hasFullscreen)
+ return;
+ const visibilities = Visibilities.getForActive();
+ visibilities.session = !visibilities.session;
+ }
+ }
+
+ CustomShortcut {
+ name: "launcher"
+ description: "Toggle launcher"
+ onPressed: root.launcherInterrupted = false
+ onReleased: {
+ if (!root.launcherInterrupted && !root.hasFullscreen) {
+ const visibilities = Visibilities.getForActive();
+ visibilities.launcher = !visibilities.launcher;
+ }
+ root.launcherInterrupted = false;
+ }
+ }
+
+ CustomShortcut {
+ name: "launcherInterrupt"
+ description: "Interrupt launcher keybind"
+ onPressed: root.launcherInterrupted = true
+ }
+
+
+ CustomShortcut {
+ name: "sidebar"
+ description: "Toggle sidebar"
+ onPressed: {
+ if (root.hasFullscreen)
+ return;
+ const visibilities = Visibilities.getForActive();
+ visibilities.sidebar = !visibilities.sidebar;
+ }
+ }
+
+ CustomShortcut {
+ name: "utilities"
+ description: "Toggle utilities"
+ onPressed: {
+ if (root.hasFullscreen)
+ return;
+ const visibilities = Visibilities.getForActive();
+ visibilities.utilities = !visibilities.utilities;
+ }
+ }
+
+ IpcHandler {
+ target: "drawers"
+
+ function toggle(drawer: string): void {
+ if (list().split("\n").includes(drawer)) {
+ if (root.hasFullscreen && ["launcher", "session", "dashboard"].includes(drawer))
+ return;
+ const visibilities = Visibilities.getForActive();
+ visibilities[drawer] = !visibilities[drawer];
+ } else {
+ console.warn(`[IPC] Drawer "${drawer}" does not exist`);
+ }
+ }
+
+ function list(): string {
+ const visibilities = Visibilities.getForActive();
+ return Object.keys(visibilities).filter(k => typeof visibilities[k] === "boolean").join("\n");
+ }
+ }
+
+ IpcHandler {
+ target: "controlCenter"
+
+ function open(): void {
+ WindowFactory.create();
+ }
+ }
+
+ IpcHandler {
+ target: "toaster"
+
+ function info(title: string, message: string, icon: string): void {
+ Toaster.toast(title, message, icon, Toast.Info);
+ }
+
+ function success(title: string, message: string, icon: string): void {
+ Toaster.toast(title, message, icon, Toast.Success);
+ }
+
+ function warn(title: string, message: string, icon: string): void {
+ Toaster.toast(title, message, icon, Toast.Warning);
+ }
+
+ function error(title: string, message: string, icon: string): void {
+ Toaster.toast(title, message, icon, Toast.Error);
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/areapicker/AreaPicker.qml b/.config/quickshell/caelestia/modules/areapicker/AreaPicker.qml
new file mode 100644
index 0000000..0d8b2fe
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/areapicker/AreaPicker.qml
@@ -0,0 +1,124 @@
+pragma ComponentBehavior: Bound
+
+import qs.components.containers
+import qs.components.misc
+import Quickshell
+import Quickshell.Wayland
+import Quickshell.Io
+
+Scope {
+ LazyLoader {
+ id: root
+
+ property bool freeze
+ property bool closing
+ property bool clipboardOnly
+
+ Variants {
+ model: Quickshell.screens
+
+ StyledWindow {
+ id: win
+
+ required property ShellScreen modelData
+
+ screen: modelData
+ name: "area-picker"
+ WlrLayershell.exclusionMode: ExclusionMode.Ignore
+ WlrLayershell.layer: WlrLayer.Overlay
+ WlrLayershell.keyboardFocus: root.closing ? WlrKeyboardFocus.None : WlrKeyboardFocus.Exclusive
+ mask: root.closing ? empty : null
+
+ anchors.top: true
+ anchors.bottom: true
+ anchors.left: true
+ anchors.right: true
+
+ Region {
+ id: empty
+ }
+
+ Picker {
+ loader: root
+ screen: win.modelData
+ }
+ }
+ }
+ }
+
+ IpcHandler {
+ target: "picker"
+
+ function open(): void {
+ root.freeze = false;
+ root.closing = false;
+ root.clipboardOnly = false;
+ root.activeAsync = true;
+ }
+
+ function openFreeze(): void {
+ root.freeze = true;
+ root.closing = false;
+ root.clipboardOnly = false;
+ root.activeAsync = true;
+ }
+
+ function openClip(): void {
+ root.freeze = false;
+ root.closing = false;
+ root.clipboardOnly = true;
+ root.activeAsync = true;
+ }
+
+ function openFreezeClip(): void {
+ root.freeze = true;
+ root.closing = false;
+ root.clipboardOnly = true;
+ root.activeAsync = true;
+ }
+ }
+
+ CustomShortcut {
+ name: "screenshot"
+ description: "Open screenshot tool"
+ onPressed: {
+ root.freeze = false;
+ root.closing = false;
+ root.clipboardOnly = false;
+ root.activeAsync = true;
+ }
+ }
+
+ CustomShortcut {
+ name: "screenshotFreeze"
+ description: "Open screenshot tool (freeze mode)"
+ onPressed: {
+ root.freeze = true;
+ root.closing = false;
+ root.clipboardOnly = false;
+ root.activeAsync = true;
+ }
+ }
+
+ CustomShortcut {
+ name: "screenshotClip"
+ description: "Open screenshot tool (clipboard)"
+ onPressed: {
+ root.freeze = false;
+ root.closing = false;
+ root.clipboardOnly = true;
+ root.activeAsync = true;
+ }
+ }
+
+ CustomShortcut {
+ name: "screenshotFreezeClip"
+ description: "Open screenshot tool (freeze mode, clipboard)"
+ onPressed: {
+ root.freeze = true;
+ root.closing = false;
+ root.clipboardOnly = true;
+ root.activeAsync = true;
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/areapicker/Picker.qml b/.config/quickshell/caelestia/modules/areapicker/Picker.qml
new file mode 100644
index 0000000..08f46df
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/areapicker/Picker.qml
@@ -0,0 +1,300 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.services
+import qs.config
+import Caelestia
+import Quickshell
+import Quickshell.Wayland
+import QtQuick
+import QtQuick.Effects
+
+MouseArea {
+ id: root
+
+ required property LazyLoader loader
+ required property ShellScreen screen
+
+ property bool onClient
+
+ property real realBorderWidth: onClient ? (Hypr.options["general:border_size"] ?? 1) : 2
+ property real realRounding: onClient ? (Hypr.options["decoration:rounding"] ?? 0) : 0
+
+ property real ssx
+ property real ssy
+
+ property real sx: 0
+ property real sy: 0
+ property real ex: screen.width
+ property real ey: screen.height
+
+ property real rsx: Math.min(sx, ex)
+ property real rsy: Math.min(sy, ey)
+ property real sw: Math.abs(sx - ex)
+ property real sh: Math.abs(sy - ey)
+
+ property list clients: {
+ const mon = Hypr.monitorFor(screen);
+ if (!mon)
+ return [];
+
+ const special = mon.lastIpcObject.specialWorkspace;
+ const wsId = special.name ? special.id : mon.activeWorkspace.id;
+
+ return Hypr.toplevels.values.filter(c => c.workspace?.id === wsId).sort((a, b) => {
+ // Pinned first, then fullscreen, then floating, then any other
+ const ac = a.lastIpcObject;
+ const bc = b.lastIpcObject;
+ return (bc.pinned - ac.pinned) || ((bc.fullscreen !== 0) - (ac.fullscreen !== 0)) || (bc.floating - ac.floating);
+ });
+ }
+
+ function checkClientRects(x: real, y: real): void {
+ for (const client of clients) {
+ if (!client)
+ continue;
+
+ let {
+ at: [cx, cy],
+ size: [cw, ch]
+ } = client.lastIpcObject;
+ cx -= screen.x;
+ cy -= screen.y;
+ if (cx <= x && cy <= y && cx + cw >= x && cy + ch >= y) {
+ onClient = true;
+ sx = cx;
+ sy = cy;
+ ex = cx + cw;
+ ey = cy + ch;
+ break;
+ }
+ }
+ }
+
+ function save(): void {
+ const tmpfile = Qt.resolvedUrl(`/tmp/caelestia-picker-${Quickshell.processId}-${Date.now()}.png`);
+ CUtils.saveItem(screencopy, tmpfile, Qt.rect(Math.ceil(rsx), Math.ceil(rsy), Math.floor(sw), Math.floor(sh)), path => {
+ if (root.loader.clipboardOnly) {
+ Quickshell.execDetached(["sh", "-c", "wl-copy --type image/png < " + path]);
+ Quickshell.execDetached(["notify-send", "-a", "caelestia-cli", "-i", path, "Screenshot taken", "Screenshot copied to clipboard"]);
+ } else {
+ Quickshell.execDetached(["swappy", "-f", path]);
+ }
+ closeAnim.start();
+ });
+ }
+
+ onClientsChanged: checkClientRects(mouseX, mouseY)
+
+ anchors.fill: parent
+ opacity: 0
+ hoverEnabled: true
+ cursorShape: Qt.CrossCursor
+
+ Component.onCompleted: {
+ Hypr.extras.refreshOptions();
+
+ // Break binding if frozen
+ if (loader.freeze)
+ clients = clients;
+
+ opacity = 1;
+
+ const c = clients[0];
+ if (c) {
+ const cx = c.lastIpcObject.at[0] - screen.x;
+ const cy = c.lastIpcObject.at[1] - screen.y;
+ onClient = true;
+ sx = cx;
+ sy = cy;
+ ex = cx + c.lastIpcObject.size[0];
+ ey = cy + c.lastIpcObject.size[1];
+ } else {
+ sx = screen.width / 2 - 100;
+ sy = screen.height / 2 - 100;
+ ex = screen.width / 2 + 100;
+ ey = screen.height / 2 + 100;
+ }
+ }
+
+ onPressed: event => {
+ ssx = event.x;
+ ssy = event.y;
+ }
+
+ onReleased: {
+ if (closeAnim.running)
+ return;
+
+ if (root.loader.freeze) {
+ save();
+ } else {
+ overlay.visible = border.visible = false;
+ screencopy.visible = false;
+ screencopy.active = true;
+ }
+ }
+
+ onPositionChanged: event => {
+ const x = event.x;
+ const y = event.y;
+
+ if (pressed) {
+ onClient = false;
+ sx = ssx;
+ sy = ssy;
+ ex = x;
+ ey = y;
+ } else {
+ checkClientRects(x, y);
+ }
+ }
+
+ focus: true
+ Keys.onEscapePressed: closeAnim.start()
+
+ SequentialAnimation {
+ id: closeAnim
+
+ PropertyAction {
+ target: root.loader
+ property: "closing"
+ value: true
+ }
+ ParallelAnimation {
+ Anim {
+ target: root
+ property: "opacity"
+ to: 0
+ duration: Appearance.anim.durations.large
+ }
+ ExAnim {
+ target: root
+ properties: "rsx,rsy"
+ to: 0
+ }
+ ExAnim {
+ target: root
+ property: "sw"
+ to: root.screen.width
+ }
+ ExAnim {
+ target: root
+ property: "sh"
+ to: root.screen.height
+ }
+ }
+ PropertyAction {
+ target: root.loader
+ property: "activeAsync"
+ value: false
+ }
+ }
+
+ Loader {
+ id: screencopy
+
+ anchors.fill: parent
+
+ active: root.loader.freeze
+
+ sourceComponent: ScreencopyView {
+ captureSource: root.screen
+
+ onHasContentChanged: {
+ if (hasContent && !root.loader.freeze) {
+ overlay.visible = border.visible = true;
+ root.save();
+ }
+ }
+ }
+ }
+
+ StyledRect {
+ id: overlay
+
+ anchors.fill: parent
+ color: Colours.palette.m3secondaryContainer
+ opacity: 0.3
+
+ layer.enabled: true
+ layer.effect: MultiEffect {
+ maskSource: selectionWrapper
+ maskEnabled: true
+ maskInverted: true
+ maskSpreadAtMin: 1
+ maskThresholdMin: 0.5
+ }
+ }
+
+ Item {
+ id: selectionWrapper
+
+ anchors.fill: parent
+ layer.enabled: true
+ visible: false
+
+ Rectangle {
+ id: selectionRect
+
+ radius: root.realRounding
+ x: root.rsx
+ y: root.rsy
+ implicitWidth: root.sw
+ implicitHeight: root.sh
+ }
+ }
+
+ Rectangle {
+ id: border
+
+ color: "transparent"
+ radius: root.realRounding > 0 ? root.realRounding + root.realBorderWidth : 0
+ border.width: root.realBorderWidth
+ border.color: Colours.palette.m3primary
+
+ x: selectionRect.x - root.realBorderWidth
+ y: selectionRect.y - root.realBorderWidth
+ implicitWidth: selectionRect.implicitWidth + root.realBorderWidth * 2
+ implicitHeight: selectionRect.implicitHeight + root.realBorderWidth * 2
+
+ Behavior on border.color {
+ CAnim {}
+ }
+ }
+
+ Behavior on opacity {
+ Anim {
+ duration: Appearance.anim.durations.large
+ }
+ }
+
+ Behavior on rsx {
+ enabled: !root.pressed
+
+ ExAnim {}
+ }
+
+ Behavior on rsy {
+ enabled: !root.pressed
+
+ ExAnim {}
+ }
+
+ Behavior on sw {
+ enabled: !root.pressed
+
+ ExAnim {}
+ }
+
+ Behavior on sh {
+ enabled: !root.pressed
+
+ ExAnim {}
+ }
+
+ component ExAnim: Anim {
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/background/Background.qml b/.config/quickshell/caelestia/modules/background/Background.qml
new file mode 100644
index 0000000..682da62
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/background/Background.qml
@@ -0,0 +1,153 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.components.containers
+import qs.services
+import qs.config
+import Quickshell
+import Quickshell.Wayland
+import QtQuick
+
+Loader {
+ active: Config.background.enabled
+
+ sourceComponent: Variants {
+ model: Quickshell.screens
+
+ StyledWindow {
+ id: win
+
+ required property ShellScreen modelData
+
+ screen: modelData
+ name: "background"
+ WlrLayershell.exclusionMode: ExclusionMode.Ignore
+ WlrLayershell.layer: Config.background.wallpaperEnabled ? WlrLayer.Background : WlrLayer.Bottom
+ color: Config.background.wallpaperEnabled ? "black" : "transparent"
+ surfaceFormat.opaque: false
+
+ anchors.top: true
+ anchors.bottom: true
+ anchors.left: true
+ anchors.right: true
+
+ Item {
+ id: behindClock
+
+ anchors.fill: parent
+
+ Loader {
+ id: wallpaper
+
+ anchors.fill: parent
+ active: Config.background.wallpaperEnabled
+
+ sourceComponent: Wallpaper {}
+ }
+
+ Visualiser {
+ anchors.fill: parent
+ screen: win.modelData
+ wallpaper: wallpaper
+ }
+ }
+
+ Loader {
+ id: clockLoader
+ active: Config.background.desktopClock.enabled
+
+ anchors.margins: Appearance.padding.large * 2
+ anchors.leftMargin: Appearance.padding.large * 2 + Config.bar.sizes.innerWidth + Math.max(Appearance.padding.smaller, Config.border.thickness)
+
+ state: Config.background.desktopClock.position
+ states: [
+ State {
+ name: "top-left"
+ AnchorChanges {
+ target: clockLoader
+ anchors.top: parent.top
+ anchors.left: parent.left
+ }
+ },
+ State {
+ name: "top-center"
+ AnchorChanges {
+ target: clockLoader
+ anchors.top: parent.top
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+ },
+ State {
+ name: "top-right"
+ AnchorChanges {
+ target: clockLoader
+ anchors.top: parent.top
+ anchors.right: parent.right
+ }
+ },
+ State {
+ name: "middle-left"
+ AnchorChanges {
+ target: clockLoader
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: parent.left
+ }
+ },
+ State {
+ name: "middle-center"
+ AnchorChanges {
+ target: clockLoader
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+ },
+ State {
+ name: "middle-right"
+ AnchorChanges {
+ target: clockLoader
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.right: parent.right
+ }
+ },
+ State {
+ name: "bottom-left"
+ AnchorChanges {
+ target: clockLoader
+ anchors.bottom: parent.bottom
+ anchors.left: parent.left
+ }
+ },
+ State {
+ name: "bottom-center"
+ AnchorChanges {
+ target: clockLoader
+ anchors.bottom: parent.bottom
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+ },
+ State {
+ name: "bottom-right"
+ AnchorChanges {
+ target: clockLoader
+ anchors.bottom: parent.bottom
+ anchors.right: parent.right
+ }
+ }
+ ]
+
+ transitions: Transition {
+ AnchorAnimation {
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ }
+
+ sourceComponent: DesktopClock {
+ wallpaper: behindClock
+ absX: clockLoader.x
+ absY: clockLoader.y
+ }
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/background/DesktopClock.qml b/.config/quickshell/caelestia/modules/background/DesktopClock.qml
new file mode 100644
index 0000000..f9a06a2
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/background/DesktopClock.qml
@@ -0,0 +1,169 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+import QtQuick.Effects
+
+Item {
+ id: root
+
+ required property Item wallpaper
+ required property real absX
+ required property real absY
+
+ property real scale: Config.background.desktopClock.scale
+ readonly property bool bgEnabled: Config.background.desktopClock.background.enabled
+ readonly property bool blurEnabled: bgEnabled && Config.background.desktopClock.background.blur && !GameMode.enabled
+ readonly property bool invertColors: Config.background.desktopClock.invertColors
+ readonly property bool useLightSet: Colours.light ? !invertColors : invertColors
+ readonly property color safePrimary: useLightSet ? Colours.palette.m3primaryContainer : Colours.palette.m3primary
+ readonly property color safeSecondary: useLightSet ? Colours.palette.m3secondaryContainer : Colours.palette.m3secondary
+ readonly property color safeTertiary: useLightSet ? Colours.palette.m3tertiaryContainer : Colours.palette.m3tertiary
+
+ implicitWidth: layout.implicitWidth + (Appearance.padding.large * 4 * root.scale)
+ implicitHeight: layout.implicitHeight + (Appearance.padding.large * 2 * root.scale)
+
+ Item {
+ id: clockContainer
+
+ anchors.fill: parent
+
+ layer.enabled: Config.background.desktopClock.shadow.enabled
+ layer.effect: MultiEffect {
+ shadowEnabled: true
+ shadowColor: Colours.palette.m3shadow
+ shadowOpacity: Config.background.desktopClock.shadow.opacity
+ shadowBlur: Config.background.desktopClock.shadow.blur
+ }
+
+ Loader {
+ anchors.fill: parent
+ active: root.blurEnabled
+
+ sourceComponent: MultiEffect {
+ source: ShaderEffectSource {
+ sourceItem: root.wallpaper
+ sourceRect: Qt.rect(root.absX, root.absY, root.width, root.height)
+ }
+ maskSource: backgroundPlate
+ maskEnabled: true
+ blurEnabled: true
+ blur: 1
+ blurMax: 64
+ autoPaddingEnabled: false
+ }
+ }
+
+ StyledRect {
+ id: backgroundPlate
+
+ visible: root.bgEnabled
+ anchors.fill: parent
+ radius: Appearance.rounding.large * root.scale
+ opacity: Config.background.desktopClock.background.opacity
+ color: Colours.palette.m3surface
+
+ layer.enabled: root.blurEnabled
+ }
+
+ RowLayout {
+ id: layout
+
+ anchors.centerIn: parent
+ spacing: Appearance.spacing.larger * root.scale
+
+ RowLayout {
+ spacing: Appearance.spacing.small
+
+ StyledText {
+ text: Time.hourStr
+ font.pointSize: Appearance.font.size.extraLarge * 3 * root.scale
+ font.weight: Font.Bold
+ color: root.safePrimary
+ }
+
+ StyledText {
+ text: ":"
+ font.pointSize: Appearance.font.size.extraLarge * 3 * root.scale
+ color: root.safeTertiary
+ opacity: 0.8
+ Layout.topMargin: -Appearance.padding.large * 1.5 * root.scale
+ }
+
+ StyledText {
+ text: Time.minuteStr
+ font.pointSize: Appearance.font.size.extraLarge * 3 * root.scale
+ font.weight: Font.Bold
+ color: root.safeSecondary
+ }
+
+ Loader {
+ Layout.alignment: Qt.AlignTop
+ Layout.topMargin: Appearance.padding.large * 1.4 * root.scale
+
+ active: Config.services.useTwelveHourClock
+ visible: active
+
+ sourceComponent: StyledText {
+ text: Time.amPmStr
+ font.pointSize: Appearance.font.size.large * root.scale
+ color: root.safeSecondary
+ }
+ }
+ }
+
+ StyledRect {
+ Layout.fillHeight: true
+ Layout.preferredWidth: 4 * root.scale
+ Layout.topMargin: Appearance.spacing.larger * root.scale
+ Layout.bottomMargin: Appearance.spacing.larger * root.scale
+ radius: Appearance.rounding.full
+ color: root.safePrimary
+ opacity: 0.8
+ }
+
+ ColumnLayout {
+ spacing: 0
+
+ StyledText {
+ text: Time.format("MMMM").toUpperCase()
+ font.pointSize: Appearance.font.size.large * root.scale
+ font.letterSpacing: 4
+ font.weight: Font.Bold
+ color: root.safeSecondary
+ }
+
+ StyledText {
+ text: Time.format("dd")
+ font.pointSize: Appearance.font.size.extraLarge * root.scale
+ font.letterSpacing: 2
+ font.weight: Font.Medium
+ color: root.safePrimary
+ }
+
+ StyledText {
+ text: Time.format("dddd")
+ font.pointSize: Appearance.font.size.larger * root.scale
+ font.letterSpacing: 2
+ color: root.safeSecondary
+ }
+ }
+ }
+ }
+
+ Behavior on scale {
+ Anim {
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ }
+
+ Behavior on implicitWidth {
+ Anim {
+ duration: Appearance.anim.durations.small
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/background/Visualiser.qml b/.config/quickshell/caelestia/modules/background/Visualiser.qml
new file mode 100644
index 0000000..35a086b
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/background/Visualiser.qml
@@ -0,0 +1,151 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.services
+import qs.config
+import Caelestia.Services
+import Quickshell
+import Quickshell.Widgets
+import QtQuick
+import QtQuick.Effects
+
+Item {
+ id: root
+
+ required property ShellScreen screen
+ required property Item wallpaper
+
+ readonly property bool shouldBeActive: Config.background.visualiser.enabled && (!Config.background.visualiser.autoHide || (Hypr.monitorFor(screen)?.activeWorkspace?.toplevels?.values.every(t => t.lastIpcObject?.floating) ?? true))
+ property real offset: shouldBeActive ? 0 : screen.height * 0.2
+
+ opacity: shouldBeActive ? 1 : 0
+
+ Loader {
+ anchors.fill: parent
+ active: root.opacity > 0 && Config.background.visualiser.blur
+
+ sourceComponent: MultiEffect {
+ source: root.wallpaper
+ maskSource: wrapper
+ maskEnabled: true
+ blurEnabled: true
+ blur: 1
+ blurMax: 32
+ autoPaddingEnabled: false
+ }
+ }
+
+ Item {
+ id: wrapper
+
+ anchors.fill: parent
+ layer.enabled: true
+
+ Loader {
+ anchors.fill: parent
+ anchors.topMargin: root.offset
+ anchors.bottomMargin: -root.offset
+
+ active: root.opacity > 0
+
+ sourceComponent: Item {
+ ServiceRef {
+ service: Audio.cava
+ }
+
+ Item {
+ id: content
+
+ anchors.fill: parent
+ anchors.margins: Config.border.thickness
+ anchors.leftMargin: Visibilities.bars.get(root.screen).exclusiveZone + Appearance.spacing.small * Config.background.visualiser.spacing
+
+ Side {
+ content: content
+ }
+ Side {
+ content: content
+ isRight: true
+ }
+
+ Behavior on anchors.leftMargin {
+ Anim {}
+ }
+ }
+ }
+ }
+ }
+
+ Behavior on offset {
+ Anim {}
+ }
+
+ Behavior on opacity {
+ Anim {}
+ }
+
+ component Side: Repeater {
+ id: side
+
+ required property Item content
+ property bool isRight
+
+ model: Config.services.visualiserBars
+
+ ClippingRectangle {
+ id: bar
+
+ required property int modelData
+ property real value: Math.max(0, Math.min(1, Audio.cava.values[side.isRight ? modelData : side.count - modelData - 1]))
+
+ clip: true
+
+ x: modelData * ((side.content.width * 0.4) / Config.services.visualiserBars) + (side.isRight ? side.content.width * 0.6 : 0)
+ implicitWidth: (side.content.width * 0.4) / Config.services.visualiserBars - Appearance.spacing.small * Config.background.visualiser.spacing
+
+ y: side.content.height - height
+ implicitHeight: bar.value * side.content.height * 0.4
+
+ color: "transparent"
+ topLeftRadius: Appearance.rounding.small * Config.background.visualiser.rounding
+ topRightRadius: Appearance.rounding.small * Config.background.visualiser.rounding
+
+ Rectangle {
+ topLeftRadius: parent.topLeftRadius
+ topRightRadius: parent.topRightRadius
+
+ gradient: Gradient {
+ orientation: Gradient.Vertical
+
+ GradientStop {
+ position: 0
+ color: Qt.alpha(Colours.palette.m3primary, 0.7)
+
+ Behavior on color {
+ CAnim {}
+ }
+ }
+ GradientStop {
+ position: 1
+ color: Qt.alpha(Colours.palette.m3inversePrimary, 0.7)
+
+ Behavior on color {
+ CAnim {}
+ }
+ }
+ }
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ y: parent.height - height
+ implicitHeight: side.content.height * 0.4
+ }
+
+ Behavior on value {
+ Anim {
+ duration: Appearance.anim.durations.small
+ }
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/background/Wallpaper.qml b/.config/quickshell/caelestia/modules/background/Wallpaper.qml
new file mode 100644
index 0000000..39a48fc
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/background/Wallpaper.qml
@@ -0,0 +1,145 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.components.images
+import qs.components.filedialog
+import qs.services
+import qs.config
+import qs.utils
+import QtQuick
+
+Item {
+ id: root
+
+ property string source: Wallpapers.current
+ property Image current: one
+
+ onSourceChanged: {
+ if (!source)
+ current = null;
+ else if (current === one)
+ two.update();
+ else
+ one.update();
+ }
+
+ Component.onCompleted: {
+ if (source)
+ Qt.callLater(() => one.update());
+ }
+
+ Loader {
+ anchors.fill: parent
+
+ active: !root.source
+
+ sourceComponent: StyledRect {
+ color: Colours.palette.m3surfaceContainer
+
+ Row {
+ anchors.centerIn: parent
+ spacing: Appearance.spacing.large
+
+ MaterialIcon {
+ text: "sentiment_stressed"
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.extraLarge * 5
+ }
+
+ Column {
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Appearance.spacing.small
+
+ StyledText {
+ text: qsTr("Wallpaper missing?")
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.extraLarge * 2
+ font.bold: true
+ }
+
+ StyledRect {
+ implicitWidth: selectWallText.implicitWidth + Appearance.padding.large * 2
+ implicitHeight: selectWallText.implicitHeight + Appearance.padding.small * 2
+
+ radius: Appearance.rounding.full
+ color: Colours.palette.m3primary
+
+ FileDialog {
+ id: dialog
+
+ title: qsTr("Select a wallpaper")
+ filterLabel: qsTr("Image files")
+ filters: Images.validImageExtensions
+ onAccepted: path => Wallpapers.setWallpaper(path)
+ }
+
+ StateLayer {
+ radius: parent.radius
+ color: Colours.palette.m3onPrimary
+
+ function onClicked(): void {
+ dialog.open();
+ }
+ }
+
+ StyledText {
+ id: selectWallText
+
+ anchors.centerIn: parent
+
+ text: qsTr("Set it now!")
+ color: Colours.palette.m3onPrimary
+ font.pointSize: Appearance.font.size.large
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Img {
+ id: one
+ }
+
+ Img {
+ id: two
+ }
+
+ component Img: CachingImage {
+ id: img
+
+ function update(): void {
+ if (path === root.source)
+ root.current = this;
+ else
+ path = root.source;
+ }
+
+ anchors.fill: parent
+
+ 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 {
+ Anim {
+ target: img
+ properties: "opacity,scale"
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/bar/Bar.qml b/.config/quickshell/caelestia/modules/bar/Bar.qml
new file mode 100644
index 0000000..cb384e3
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/bar/Bar.qml
@@ -0,0 +1,205 @@
+pragma ComponentBehavior: Bound
+
+import qs.services
+import qs.config
+import "popouts" as BarPopouts
+import "components"
+import "components/workspaces"
+import Quickshell
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ required property ShellScreen screen
+ required property PersistentProperties visibilities
+ required property BarPopouts.Wrapper popouts
+ readonly property int vPadding: Appearance.padding.large
+
+ function closeTray(): void {
+ if (!Config.bar.tray.compact)
+ return;
+
+ for (let i = 0; i < repeater.count; i++) {
+ const item = repeater.itemAt(i);
+ if (item?.enabled && item.id === "tray") {
+ item.item.expanded = false;
+ }
+ }
+ }
+
+ function checkPopout(y: real): void {
+ const ch = childAt(width / 2, y) as WrappedLoader;
+
+ if (ch?.id !== "tray")
+ closeTray();
+
+ if (!ch) {
+ popouts.hasCurrent = false;
+ return;
+ }
+
+ const id = ch.id;
+ const top = ch.y;
+ const item = ch.item;
+ const itemHeight = item.implicitHeight;
+
+ if (id === "statusIcons" && Config.bar.popouts.statusIcons) {
+ const items = item.items;
+ const icon = items.childAt(items.width / 2, mapToItem(items, 0, y).y);
+ if (icon) {
+ popouts.currentName = icon.name;
+ popouts.currentCenter = Qt.binding(() => icon.mapToItem(root, 0, icon.implicitHeight / 2).y);
+ popouts.hasCurrent = true;
+ }
+ } else if (id === "tray" && Config.bar.popouts.tray) {
+ if (!Config.bar.tray.compact || (item.expanded && !item.expandIcon.contains(mapToItem(item.expandIcon, item.implicitWidth / 2, y)))) {
+ const index = Math.floor(((y - top - item.padding * 2 + item.spacing) / item.layout.implicitHeight) * item.items.count);
+ const trayItem = item.items.itemAt(index);
+ if (trayItem) {
+ popouts.currentName = `traymenu${index}`;
+ popouts.currentCenter = Qt.binding(() => trayItem.mapToItem(root, 0, trayItem.implicitHeight / 2).y);
+ popouts.hasCurrent = true;
+ } else {
+ popouts.hasCurrent = false;
+ }
+ } else {
+ popouts.hasCurrent = false;
+ item.expanded = true;
+ }
+ } else if (id === "activeWindow" && Config.bar.popouts.activeWindow) {
+ popouts.currentName = id.toLowerCase();
+ popouts.currentCenter = item.mapToItem(root, 0, itemHeight / 2).y;
+ popouts.hasCurrent = true;
+ }
+ }
+
+ function handleWheel(y: real, angleDelta: point): void {
+ const ch = childAt(width / 2, y) as WrappedLoader;
+ if (ch?.id === "workspaces" && Config.bar.scrollActions.workspaces) {
+ // Workspace scroll
+ const mon = (Config.bar.workspaces.perMonitorWorkspaces ? Hypr.monitorFor(screen) : Hypr.focusedMonitor);
+ const specialWs = mon?.lastIpcObject.specialWorkspace.name;
+ if (specialWs?.length > 0)
+ Hypr.dispatch(`togglespecialworkspace ${specialWs.slice(8)}`);
+ else if (angleDelta.y < 0 || (Config.bar.workspaces.perMonitorWorkspaces ? mon.activeWorkspace?.id : Hypr.activeWsId) > 1)
+ Hypr.dispatch(`workspace r${angleDelta.y > 0 ? "-" : "+"}1`);
+ } else if (y < screen.height / 2 && Config.bar.scrollActions.volume) {
+ // Volume scroll on top half
+ if (angleDelta.y > 0)
+ Audio.incrementVolume();
+ else if (angleDelta.y < 0)
+ Audio.decrementVolume();
+ } else if (Config.bar.scrollActions.brightness) {
+ // Brightness scroll on bottom half
+ const monitor = Brightness.getMonitorForScreen(screen);
+ if (angleDelta.y > 0)
+ monitor.setBrightness(monitor.brightness + Config.services.brightnessIncrement);
+ else if (angleDelta.y < 0)
+ monitor.setBrightness(monitor.brightness - Config.services.brightnessIncrement);
+ }
+ }
+
+ spacing: Appearance.spacing.normal
+
+ Repeater {
+ id: repeater
+
+ model: Config.bar.entries
+
+ DelegateChooser {
+ role: "id"
+
+ DelegateChoice {
+ roleValue: "spacer"
+ delegate: WrappedLoader {
+ Layout.fillHeight: enabled
+ }
+ }
+ DelegateChoice {
+ roleValue: "logo"
+ delegate: WrappedLoader {
+ sourceComponent: OsIcon {}
+ }
+ }
+ DelegateChoice {
+ roleValue: "workspaces"
+ delegate: WrappedLoader {
+ sourceComponent: Workspaces {
+ screen: root.screen
+ }
+ }
+ }
+ DelegateChoice {
+ roleValue: "activeWindow"
+ delegate: WrappedLoader {
+ sourceComponent: ActiveWindow {
+ bar: root
+ monitor: Brightness.getMonitorForScreen(root.screen)
+ }
+ }
+ }
+ DelegateChoice {
+ roleValue: "tray"
+ delegate: WrappedLoader {
+ sourceComponent: Tray {}
+ }
+ }
+ DelegateChoice {
+ roleValue: "clock"
+ delegate: WrappedLoader {
+ sourceComponent: Clock {}
+ }
+ }
+ DelegateChoice {
+ roleValue: "statusIcons"
+ delegate: WrappedLoader {
+ sourceComponent: StatusIcons {}
+ }
+ }
+ DelegateChoice {
+ roleValue: "power"
+ delegate: WrappedLoader {
+ sourceComponent: Power {
+ visibilities: root.visibilities
+ }
+ }
+ }
+ }
+ }
+
+ component WrappedLoader: Loader {
+ required property bool enabled
+ required property string id
+ required property int index
+
+ function findFirstEnabled(): Item {
+ const count = repeater.count;
+ for (let i = 0; i < count; i++) {
+ const item = repeater.itemAt(i);
+ if (item?.enabled)
+ return item;
+ }
+ return null;
+ }
+
+ function findLastEnabled(): Item {
+ for (let i = repeater.count - 1; i >= 0; i--) {
+ const item = repeater.itemAt(i);
+ if (item?.enabled)
+ return item;
+ }
+ return null;
+ }
+
+ Layout.alignment: Qt.AlignHCenter
+
+ // Cursed ahh thing to add padding to first and last enabled components
+ Layout.topMargin: findFirstEnabled() === this ? root.vPadding : 0
+ Layout.bottomMargin: findLastEnabled() === this ? root.vPadding : 0
+
+ visible: enabled
+ active: enabled
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/bar/BarWrapper.qml b/.config/quickshell/caelestia/modules/bar/BarWrapper.qml
new file mode 100644
index 0000000..29961b6
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/bar/BarWrapper.qml
@@ -0,0 +1,87 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.config
+import "popouts" as BarPopouts
+import Quickshell
+import QtQuick
+
+Item {
+ id: root
+
+ required property ShellScreen screen
+ required property PersistentProperties visibilities
+ required property BarPopouts.Wrapper popouts
+ required property bool disabled
+
+ readonly property int padding: Math.max(Appearance.padding.smaller, Config.border.thickness)
+ readonly property int contentWidth: Config.bar.sizes.innerWidth + padding * 2
+ readonly property int exclusiveZone: !disabled && (Config.bar.persistent || visibilities.bar) ? contentWidth : Config.border.thickness
+ readonly property bool shouldBeVisible: !disabled && (Config.bar.persistent || visibilities.bar || isHovered)
+ property bool isHovered
+
+ function closeTray(): void {
+ content.item?.closeTray();
+ }
+
+ function checkPopout(y: real): void {
+ content.item?.checkPopout(y);
+ }
+
+ function handleWheel(y: real, angleDelta: point): void {
+ content.item?.handleWheel(y, angleDelta);
+ }
+
+ visible: width > Config.border.thickness
+ implicitWidth: Config.border.thickness
+
+ states: State {
+ name: "visible"
+ when: root.shouldBeVisible
+
+ PropertyChanges {
+ root.implicitWidth: root.contentWidth
+ }
+ }
+
+ transitions: [
+ Transition {
+ from: ""
+ to: "visible"
+
+ Anim {
+ target: root
+ property: "implicitWidth"
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ },
+ Transition {
+ from: "visible"
+ to: ""
+
+ Anim {
+ target: root
+ property: "implicitWidth"
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+ ]
+
+ Loader {
+ id: content
+
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ anchors.right: parent.right
+
+ active: root.shouldBeVisible || root.visible
+
+ sourceComponent: Bar {
+ width: root.contentWidth
+ screen: root.screen
+ visibilities: root.visibilities
+ popouts: root.popouts
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/bar/components/ActiveWindow.qml b/.config/quickshell/caelestia/modules/bar/components/ActiveWindow.qml
new file mode 100644
index 0000000..0c9b21e
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/bar/components/ActiveWindow.qml
@@ -0,0 +1,100 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.services
+import qs.utils
+import qs.config
+import QtQuick
+
+Item {
+ id: root
+
+ required property var bar
+ required property Brightness.Monitor monitor
+ property color colour: Colours.palette.m3primary
+
+ readonly property int maxHeight: {
+ const otherModules = bar.children.filter(c => c.id && c.item !== this && c.id !== "spacer");
+ const otherHeight = otherModules.reduce((acc, curr) => acc + (curr.item.nonAnimHeight ?? curr.height), 0);
+ // Length - 2 cause repeater counts as a child
+ return bar.height - otherHeight - bar.spacing * (bar.children.length - 1) - bar.vPadding * 2;
+ }
+ property Title current: text1
+
+ clip: true
+ implicitWidth: Math.max(icon.implicitWidth, current.implicitHeight)
+ implicitHeight: icon.implicitHeight + current.implicitWidth + current.anchors.topMargin
+
+ MaterialIcon {
+ id: icon
+
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ animate: true
+ text: Icons.getAppCategoryIcon(Hypr.activeToplevel?.lastIpcObject.class, "desktop_windows")
+ color: root.colour
+ }
+
+ Title {
+ id: text1
+ }
+
+ Title {
+ id: text2
+ }
+
+ TextMetrics {
+ id: metrics
+
+ text: Hypr.activeToplevel?.title ?? qsTr("Desktop")
+ font.pointSize: Appearance.font.size.smaller
+ font.family: Appearance.font.family.mono
+ elide: Qt.ElideRight
+ elideWidth: root.maxHeight - icon.height
+
+ onTextChanged: {
+ const next = root.current === text1 ? text2 : text1;
+ next.text = elidedText;
+ root.current = next;
+ }
+ onElideWidthChanged: root.current.text = elidedText
+ }
+
+ Behavior on implicitHeight {
+ Anim {
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ }
+
+ 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: root.current === this ? 1 : 0
+
+ transform: [
+ Translate {
+ x: Config.bar.activeWindow.inverted ? -implicitWidth + text.implicitHeight : 0
+ },
+ Rotation {
+ angle: Config.bar.activeWindow.inverted ? 270 : 90
+ origin.x: text.implicitHeight / 2
+ origin.y: text.implicitHeight / 2
+ }
+ ]
+
+ width: implicitHeight
+ height: implicitWidth
+
+ Behavior on opacity {
+ Anim {}
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/bar/components/Clock.qml b/.config/quickshell/caelestia/modules/bar/components/Clock.qml
new file mode 100644
index 0000000..801e93d
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/bar/components/Clock.qml
@@ -0,0 +1,38 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.services
+import qs.config
+import QtQuick
+
+Column {
+ id: root
+
+ property color colour: Colours.palette.m3tertiary
+
+ spacing: Appearance.spacing.small
+
+ Loader {
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ active: Config.bar.clock.showIcon
+ visible: active
+
+ sourceComponent: MaterialIcon {
+ text: "calendar_month"
+ color: root.colour
+ }
+ }
+
+ StyledText {
+ id: text
+
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ horizontalAlignment: StyledText.AlignHCenter
+ text: Time.format(Config.services.useTwelveHourClock ? "hh\nmm\nA" : "hh\nmm")
+ font.pointSize: Appearance.font.size.smaller
+ font.family: Appearance.font.family.mono
+ color: root.colour
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/bar/components/OsIcon.qml b/.config/quickshell/caelestia/modules/bar/components/OsIcon.qml
new file mode 100644
index 0000000..a61500a
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/bar/components/OsIcon.qml
@@ -0,0 +1,46 @@
+import qs.components.effects
+import qs.services
+import qs.config
+import qs.utils
+import QtQuick
+import qs.components
+
+Item {
+ id: root
+
+ implicitWidth: Appearance.font.size.large * 1.2
+ implicitHeight: Appearance.font.size.large * 1.2
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ const visibilities = Visibilities.getForActive();
+ visibilities.launcher = !visibilities.launcher;
+ }
+ }
+
+ Loader {
+ anchors.centerIn: parent
+ sourceComponent: SysInfo.isDefaultLogo ? caelestiaLogo : distroIcon
+ }
+
+ Component {
+ id: caelestiaLogo
+
+ Logo {
+ implicitWidth: Appearance.font.size.large * 1.8
+ implicitHeight: Appearance.font.size.large * 1.8
+ }
+ }
+
+ Component {
+ id: distroIcon
+
+ ColouredIcon {
+ source: SysInfo.osLogo
+ implicitSize: Appearance.font.size.large * 1.2
+ colour: Colours.palette.m3tertiary
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/bar/components/Power.qml b/.config/quickshell/caelestia/modules/bar/components/Power.qml
new file mode 100644
index 0000000..917bdf7
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/bar/components/Power.qml
@@ -0,0 +1,40 @@
+import qs.components
+import qs.services
+import qs.config
+import Quickshell
+import QtQuick
+
+Item {
+ id: root
+
+ required property PersistentProperties visibilities
+
+ implicitWidth: icon.implicitHeight + Appearance.padding.small * 2
+ implicitHeight: icon.implicitHeight
+
+ StateLayer {
+ // Cursed workaround to make the height larger than the parent
+ anchors.fill: undefined
+ anchors.centerIn: parent
+ implicitWidth: implicitHeight
+ implicitHeight: icon.implicitHeight + Appearance.padding.small * 2
+
+ radius: Appearance.rounding.full
+
+ function onClicked(): void {
+ root.visibilities.session = !root.visibilities.session;
+ }
+ }
+
+ MaterialIcon {
+ id: icon
+
+ anchors.centerIn: parent
+ anchors.horizontalCenterOffset: -1
+
+ text: "power_settings_new"
+ color: Colours.palette.m3error
+ font.bold: true
+ font.pointSize: Appearance.font.size.normal
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/bar/components/Settings.qml b/.config/quickshell/caelestia/modules/bar/components/Settings.qml
new file mode 100644
index 0000000..5d562ce
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/bar/components/Settings.qml
@@ -0,0 +1,41 @@
+import qs.components
+import qs.modules.controlcenter
+import qs.services
+import qs.config
+import Quickshell
+import QtQuick
+
+Item {
+ id: root
+
+ implicitWidth: icon.implicitHeight + Appearance.padding.small * 2
+ implicitHeight: icon.implicitHeight
+
+ StateLayer {
+ // Cursed workaround to make the height larger than the parent
+ anchors.fill: undefined
+ anchors.centerIn: parent
+ implicitWidth: implicitHeight
+ implicitHeight: icon.implicitHeight + Appearance.padding.small * 2
+
+ radius: Appearance.rounding.full
+
+ function onClicked(): void {
+ WindowFactory.create(null, {
+ active: "network"
+ });
+ }
+ }
+
+ MaterialIcon {
+ id: icon
+
+ anchors.centerIn: parent
+ anchors.horizontalCenterOffset: -1
+
+ text: "settings"
+ color: Colours.palette.m3onSurface
+ font.bold: true
+ font.pointSize: Appearance.font.size.normal
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/bar/components/SettingsIcon.qml b/.config/quickshell/caelestia/modules/bar/components/SettingsIcon.qml
new file mode 100644
index 0000000..5d562ce
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/bar/components/SettingsIcon.qml
@@ -0,0 +1,41 @@
+import qs.components
+import qs.modules.controlcenter
+import qs.services
+import qs.config
+import Quickshell
+import QtQuick
+
+Item {
+ id: root
+
+ implicitWidth: icon.implicitHeight + Appearance.padding.small * 2
+ implicitHeight: icon.implicitHeight
+
+ StateLayer {
+ // Cursed workaround to make the height larger than the parent
+ anchors.fill: undefined
+ anchors.centerIn: parent
+ implicitWidth: implicitHeight
+ implicitHeight: icon.implicitHeight + Appearance.padding.small * 2
+
+ radius: Appearance.rounding.full
+
+ function onClicked(): void {
+ WindowFactory.create(null, {
+ active: "network"
+ });
+ }
+ }
+
+ MaterialIcon {
+ id: icon
+
+ anchors.centerIn: parent
+ anchors.horizontalCenterOffset: -1
+
+ text: "settings"
+ color: Colours.palette.m3onSurface
+ font.bold: true
+ font.pointSize: Appearance.font.size.normal
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/bar/components/StatusIcons.qml b/.config/quickshell/caelestia/modules/bar/components/StatusIcons.qml
new file mode 100644
index 0000000..ca7dc2e
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/bar/components/StatusIcons.qml
@@ -0,0 +1,270 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.services
+import qs.utils
+import qs.config
+import Quickshell
+import Quickshell.Bluetooth
+import Quickshell.Services.UPower
+import QtQuick
+import QtQuick.Layouts
+
+StyledRect {
+ id: root
+
+ property color colour: Colours.palette.m3secondary
+ readonly property alias items: iconColumn
+
+ color: Colours.tPalette.m3surfaceContainer
+ radius: Appearance.rounding.full
+
+ clip: true
+ implicitWidth: Config.bar.sizes.innerWidth
+ implicitHeight: iconColumn.implicitHeight + Appearance.padding.normal * 2 - (Config.bar.status.showLockStatus && !Hypr.capsLock && !Hypr.numLock ? iconColumn.spacing : 0)
+
+ ColumnLayout {
+ id: iconColumn
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ anchors.bottomMargin: Appearance.padding.normal
+
+ spacing: Appearance.spacing.smaller / 2
+
+ // Lock keys status
+ WrappedLoader {
+ name: "lockstatus"
+ active: Config.bar.status.showLockStatus
+
+ sourceComponent: ColumnLayout {
+ spacing: 0
+
+ Item {
+ implicitWidth: capslockIcon.implicitWidth
+ implicitHeight: Hypr.capsLock ? capslockIcon.implicitHeight : 0
+
+ MaterialIcon {
+ id: capslockIcon
+
+ anchors.centerIn: parent
+
+ scale: Hypr.capsLock ? 1 : 0.5
+ opacity: Hypr.capsLock ? 1 : 0
+
+ text: "keyboard_capslock_badge"
+ color: root.colour
+
+ Behavior on opacity {
+ Anim {}
+ }
+
+ Behavior on scale {
+ Anim {}
+ }
+ }
+
+ Behavior on implicitHeight {
+ Anim {}
+ }
+ }
+
+ Item {
+ Layout.topMargin: Hypr.capsLock && Hypr.numLock ? iconColumn.spacing : 0
+
+ implicitWidth: numlockIcon.implicitWidth
+ implicitHeight: Hypr.numLock ? numlockIcon.implicitHeight : 0
+
+ MaterialIcon {
+ id: numlockIcon
+
+ anchors.centerIn: parent
+
+ scale: Hypr.numLock ? 1 : 0.5
+ opacity: Hypr.numLock ? 1 : 0
+
+ text: "looks_one"
+ color: root.colour
+
+ Behavior on opacity {
+ Anim {}
+ }
+
+ Behavior on scale {
+ Anim {}
+ }
+ }
+
+ Behavior on implicitHeight {
+ Anim {}
+ }
+ }
+ }
+ }
+
+ // Audio icon
+ WrappedLoader {
+ name: "audio"
+ active: Config.bar.status.showAudio
+
+ sourceComponent: MaterialIcon {
+ animate: true
+ text: Icons.getVolumeIcon(Audio.volume, Audio.muted)
+ color: root.colour
+ }
+ }
+
+ // Microphone icon
+ WrappedLoader {
+ name: "audio"
+ active: Config.bar.status.showMicrophone
+
+ sourceComponent: MaterialIcon {
+ animate: true
+ text: Icons.getMicVolumeIcon(Audio.sourceVolume, Audio.sourceMuted)
+ color: root.colour
+ }
+ }
+
+ // Keyboard layout icon
+ WrappedLoader {
+ name: "kblayout"
+ active: Config.bar.status.showKbLayout
+
+ sourceComponent: StyledText {
+ animate: true
+ text: Hypr.kbLayout
+ color: root.colour
+ font.family: Appearance.font.family.mono
+ }
+ }
+
+ // Network icon
+ WrappedLoader {
+ name: "network"
+ active: Config.bar.status.showNetwork && (!Nmcli.activeEthernet || Config.bar.status.showWifi)
+
+ sourceComponent: MaterialIcon {
+ animate: true
+ text: Nmcli.active ? Icons.getNetworkIcon(Nmcli.active.strength ?? 0) : "wifi_off"
+ color: root.colour
+ }
+ }
+
+ // Ethernet icon
+ WrappedLoader {
+ name: "ethernet"
+ active: Config.bar.status.showNetwork && Nmcli.activeEthernet
+
+ sourceComponent: MaterialIcon {
+ animate: true
+ text: "cable"
+ color: root.colour
+ }
+ }
+
+ // Bluetooth section
+ WrappedLoader {
+ Layout.preferredHeight: implicitHeight
+
+ name: "bluetooth"
+ active: Config.bar.status.showBluetooth
+
+ sourceComponent: ColumnLayout {
+ spacing: Appearance.spacing.smaller / 2
+
+ // Bluetooth icon
+ MaterialIcon {
+ animate: true
+ text: {
+ if (!Bluetooth.defaultAdapter?.enabled)
+ return "bluetooth_disabled";
+ if (Bluetooth.devices.values.some(d => d.connected))
+ return "bluetooth_connected";
+ return "bluetooth";
+ }
+ color: root.colour
+ }
+
+ // Connected bluetooth devices
+ Repeater {
+ model: ScriptModel {
+ values: Bluetooth.devices.values.filter(d => d.state !== BluetoothDeviceState.Disconnected)
+ }
+
+ MaterialIcon {
+ id: device
+
+ required property BluetoothDevice modelData
+
+ animate: true
+ text: Icons.getBluetoothIcon(modelData?.icon)
+ color: root.colour
+ fill: 1
+
+ SequentialAnimation on opacity {
+ running: device.modelData?.state !== BluetoothDeviceState.Connected
+ alwaysRunToEnd: true
+ loops: Animation.Infinite
+
+ Anim {
+ from: 1
+ to: 0
+ duration: Appearance.anim.durations.large
+ easing.bezierCurve: Appearance.anim.curves.standardAccel
+ }
+ Anim {
+ from: 0
+ to: 1
+ duration: Appearance.anim.durations.large
+ easing.bezierCurve: Appearance.anim.curves.standardDecel
+ }
+ }
+ }
+ }
+ }
+
+ Behavior on Layout.preferredHeight {
+ Anim {}
+ }
+ }
+
+ // Battery icon
+ WrappedLoader {
+ name: "battery"
+ active: Config.bar.status.showBattery
+
+ sourceComponent: MaterialIcon {
+ 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 = [UPowerDeviceState.Charging, UPowerDeviceState.FullyCharged, UPowerDeviceState.PendingCharge].includes(UPower.displayDevice.state);
+ 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
+ }
+ }
+ }
+
+ component WrappedLoader: Loader {
+ required property string name
+
+ Layout.alignment: Qt.AlignHCenter
+ visible: active
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/bar/components/Tray.qml b/.config/quickshell/caelestia/modules/bar/components/Tray.qml
new file mode 100644
index 0000000..7bafda1
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/bar/components/Tray.qml
@@ -0,0 +1,121 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.services
+import qs.config
+import Quickshell
+import Quickshell.Services.SystemTray
+import QtQuick
+
+StyledRect {
+ id: root
+
+ readonly property alias layout: layout
+ readonly property alias items: items
+ readonly property alias expandIcon: expandIcon
+
+ readonly property int padding: Config.bar.tray.background ? Appearance.padding.normal : Appearance.padding.small
+ readonly property int spacing: Config.bar.tray.background ? Appearance.spacing.small : 0
+
+ property bool expanded
+
+ readonly property real nonAnimHeight: {
+ if (!Config.bar.tray.compact)
+ return layout.implicitHeight + padding * 2;
+ return (expanded ? expandIcon.implicitHeight + layout.implicitHeight + spacing : expandIcon.implicitHeight) + padding * 2;
+ }
+
+ clip: true
+ visible: height > 0
+
+ implicitWidth: Config.bar.sizes.innerWidth
+ implicitHeight: nonAnimHeight
+
+ color: Qt.alpha(Colours.tPalette.m3surfaceContainer, (Config.bar.tray.background && items.count > 0) ? Colours.tPalette.m3surfaceContainer.a : 0)
+ radius: Appearance.rounding.full
+
+ Column {
+ id: layout
+
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.top: parent.top
+ anchors.topMargin: root.padding
+ spacing: Appearance.spacing.small
+
+ opacity: root.expanded || !Config.bar.tray.compact ? 1 : 0
+
+ add: Transition {
+ Anim {
+ properties: "scale"
+ from: 0
+ to: 1
+ easing.bezierCurve: Appearance.anim.curves.standardDecel
+ }
+ }
+
+ move: Transition {
+ Anim {
+ properties: "scale"
+ to: 1
+ easing.bezierCurve: Appearance.anim.curves.standardDecel
+ }
+ Anim {
+ properties: "x,y"
+ }
+ }
+
+ Repeater {
+ id: items
+
+ model: ScriptModel {
+ values: SystemTray.items.values.filter(i => !Config.bar.tray.hiddenIcons.includes(i.id))
+ }
+
+ TrayItem {}
+ }
+
+ Behavior on opacity {
+ Anim {}
+ }
+ }
+
+ Loader {
+ id: expandIcon
+
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.bottom: parent.bottom
+
+ active: Config.bar.tray.compact && items.count > 0
+
+ sourceComponent: Item {
+ implicitWidth: expandIconInner.implicitWidth
+ implicitHeight: expandIconInner.implicitHeight - Appearance.padding.small * 2
+
+ MaterialIcon {
+ id: expandIconInner
+
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.bottom: parent.bottom
+ anchors.bottomMargin: Config.bar.tray.background ? Appearance.padding.small : -Appearance.padding.small
+ text: "expand_less"
+ font.pointSize: Appearance.font.size.large
+ rotation: root.expanded ? 180 : 0
+
+ Behavior on rotation {
+ Anim {}
+ }
+
+ Behavior on anchors.bottomMargin {
+ Anim {}
+ }
+ }
+ }
+ }
+
+ Behavior on implicitHeight {
+ Anim {
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/bar/components/TrayItem.qml b/.config/quickshell/caelestia/modules/bar/components/TrayItem.qml
new file mode 100644
index 0000000..9911907
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/bar/components/TrayItem.qml
@@ -0,0 +1,34 @@
+pragma ComponentBehavior: Bound
+
+import qs.components.effects
+import qs.services
+import qs.config
+import qs.utils
+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
+ modelData.secondaryActivate();
+ }
+
+ ColouredIcon {
+ id: icon
+
+ anchors.fill: parent
+ source: Icons.getTrayIcon(root.modelData.id, root.modelData.icon)
+ colour: Colours.palette.m3secondary
+ layer.enabled: Config.bar.tray.recolour
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/bar/components/workspaces/ActiveIndicator.qml b/.config/quickshell/caelestia/modules/bar/components/workspaces/ActiveIndicator.qml
new file mode 100644
index 0000000..dae54b3
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/bar/components/workspaces/ActiveIndicator.qml
@@ -0,0 +1,98 @@
+import qs.components
+import qs.components.effects
+import qs.services
+import qs.config
+import QtQuick
+
+StyledRect {
+ id: root
+
+ required property int activeWsId
+ required property Repeater workspaces
+ required property Item mask
+
+ readonly property int currentWsIdx: {
+ let i = activeWsId - 1;
+ while (i < 0)
+ i += Config.bar.workspaces.shown;
+ return i % Config.bar.workspaces.shown;
+ }
+
+ property real leading: workspaces.count > 0 ? workspaces.itemAt(currentWsIdx)?.y ?? 0 : 0
+ property real trailing: workspaces.count > 0 ? workspaces.itemAt(currentWsIdx)?.y ?? 0 : 0
+ property real currentSize: workspaces.count > 0 ? workspaces.itemAt(currentWsIdx)?.size ?? 0 : 0
+ property real offset: Math.min(leading, trailing)
+ property real size: {
+ const s = Math.abs(leading - trailing) + currentSize;
+ if (Config.bar.workspaces.activeTrail && lastWs > currentWsIdx) {
+ const ws = workspaces.itemAt(lastWs);
+ // console.log(ws, lastWs);
+ return ws ? Math.min(ws.y + ws.size - offset, s) : 0;
+ }
+ return s;
+ }
+
+ property int cWs
+ property int lastWs
+
+ onCurrentWsIdxChanged: {
+ lastWs = cWs;
+ cWs = currentWsIdx;
+ }
+
+ clip: true
+ y: offset + mask.y
+ implicitWidth: Config.bar.sizes.innerWidth - Appearance.padding.small * 2
+ implicitHeight: size
+ radius: Appearance.rounding.full
+ color: Colours.palette.m3primary
+
+ Colouriser {
+ source: root.mask
+ sourceColor: Colours.palette.m3onSurface
+ colorizationColor: Colours.palette.m3onPrimary
+
+ x: 0
+ y: -parent.offset
+ implicitWidth: root.mask.implicitWidth
+ implicitHeight: root.mask.implicitHeight
+
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+
+ Behavior on leading {
+ enabled: Config.bar.workspaces.activeTrail
+
+ EAnim {}
+ }
+
+ Behavior on trailing {
+ enabled: Config.bar.workspaces.activeTrail
+
+ EAnim {
+ duration: Appearance.anim.durations.normal * 2
+ }
+ }
+
+ Behavior on currentSize {
+ enabled: Config.bar.workspaces.activeTrail
+
+ EAnim {}
+ }
+
+ Behavior on offset {
+ enabled: !Config.bar.workspaces.activeTrail
+
+ EAnim {}
+ }
+
+ Behavior on size {
+ enabled: !Config.bar.workspaces.activeTrail
+
+ EAnim {}
+ }
+
+ component EAnim: Anim {
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/bar/components/workspaces/OccupiedBg.qml b/.config/quickshell/caelestia/modules/bar/components/workspaces/OccupiedBg.qml
new file mode 100644
index 0000000..56b215e
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/bar/components/workspaces/OccupiedBg.qml
@@ -0,0 +1,103 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.services
+import qs.config
+import Quickshell
+import QtQuick
+
+Item {
+ id: root
+
+ required property Repeater workspaces
+ required property var occupied
+ required property int groupOffset
+
+ property list pills: []
+
+ onOccupiedChanged: {
+ if (!occupied)
+ return;
+ let count = 0;
+ const start = groupOffset;
+ const end = start + Config.bar.workspaces.shown;
+ for (const [ws, occ] of Object.entries(occupied)) {
+ if (ws > start && ws <= end && occ) {
+ const isFirstInGroup = Number(ws) === start + 1;
+ const isLastInGroup = Number(ws) === end;
+ if (isFirstInGroup || !occupied[ws - 1]) {
+ if (pills[count])
+ pills[count].start = ws;
+ else
+ pills.push(pillComp.createObject(root, {
+ start: ws
+ }));
+ count++;
+ }
+ if ((isLastInGroup || !occupied[ws + 1]) && pills[count - 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.count > 0 ? root.workspaces.itemAt(getWsIdx(modelData.start)) ?? null : null
+ readonly property Workspace end: root.workspaces.count > 0 ? root.workspaces.itemAt(getWsIdx(modelData.end)) ?? null : null
+
+ function getWsIdx(ws: int): int {
+ let i = ws - 1;
+ while (i < 0)
+ i += Config.bar.workspaces.shown;
+ return i % Config.bar.workspaces.shown;
+ }
+
+ anchors.horizontalCenter: root.horizontalCenter
+
+ y: (start?.y ?? 0) - 1
+ implicitWidth: Config.bar.sizes.innerWidth - Appearance.padding.small * 2 + 2
+ implicitHeight: start && end ? end.y + end.size - start.y + 2 : 0
+
+ color: Colours.layer(Colours.palette.m3surfaceContainerHigh, 2)
+ radius: Appearance.rounding.full
+
+ scale: 0
+ Component.onCompleted: scale = 1
+
+ Behavior on scale {
+ Anim {
+ easing.bezierCurve: Appearance.anim.curves.standardDecel
+ }
+ }
+
+ Behavior on y {
+ Anim {}
+ }
+
+ Behavior on implicitHeight {
+ Anim {}
+ }
+ }
+ }
+
+ component Pill: QtObject {
+ property int start
+ property int end
+ }
+
+ Component {
+ id: pillComp
+
+ Pill {}
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/bar/components/workspaces/SpecialWorkspaces.qml b/.config/quickshell/caelestia/modules/bar/components/workspaces/SpecialWorkspaces.qml
new file mode 100644
index 0000000..ad85af8
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/bar/components/workspaces/SpecialWorkspaces.qml
@@ -0,0 +1,359 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.components.effects
+import qs.services
+import qs.utils
+import qs.config
+import Quickshell
+import Quickshell.Hyprland
+import QtQuick
+import QtQuick.Layouts
+
+Item {
+ id: root
+
+ required property ShellScreen screen
+ readonly property HyprlandMonitor monitor: Hypr.monitorFor(screen)
+ readonly property string activeSpecial: (Config.bar.workspaces.perMonitorWorkspaces ? monitor : Hypr.focusedMonitor)?.lastIpcObject?.specialWorkspace?.name ?? ""
+
+ layer.enabled: true
+ layer.effect: OpacityMask {
+ maskSource: mask
+ }
+
+ Item {
+ id: mask
+
+ anchors.fill: parent
+ layer.enabled: true
+ visible: false
+
+ Rectangle {
+ anchors.fill: parent
+ radius: Appearance.rounding.full
+
+ gradient: Gradient {
+ orientation: Gradient.Vertical
+
+ GradientStop {
+ position: 0
+ color: Qt.rgba(0, 0, 0, 0)
+ }
+ GradientStop {
+ position: 0.3
+ color: Qt.rgba(0, 0, 0, 1)
+ }
+ GradientStop {
+ position: 0.7
+ color: Qt.rgba(0, 0, 0, 1)
+ }
+ GradientStop {
+ position: 1
+ color: Qt.rgba(0, 0, 0, 0)
+ }
+ }
+ }
+
+ Rectangle {
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.right: parent.right
+
+ radius: Appearance.rounding.full
+ implicitHeight: parent.height / 2
+ opacity: view.contentY > 0 ? 0 : 1
+
+ Behavior on opacity {
+ Anim {}
+ }
+ }
+
+ Rectangle {
+ anchors.bottom: parent.bottom
+ anchors.left: parent.left
+ anchors.right: parent.right
+
+ radius: Appearance.rounding.full
+ implicitHeight: parent.height / 2
+ opacity: view.contentY < view.contentHeight - parent.height + Appearance.padding.small ? 0 : 1
+
+ Behavior on opacity {
+ Anim {}
+ }
+ }
+ }
+
+ ListView {
+ id: view
+
+ anchors.fill: parent
+ spacing: Appearance.spacing.normal
+ interactive: false
+
+ currentIndex: model.values.findIndex(w => w.name === root.activeSpecial)
+ onCurrentIndexChanged: currentIndex = Qt.binding(() => model.values.findIndex(w => w.name === root.activeSpecial))
+
+ model: ScriptModel {
+ values: Hypr.workspaces.values.filter(w => w.name.startsWith("special:") && (!Config.bar.workspaces.perMonitorWorkspaces || w.monitor === root.monitor))
+ }
+
+ preferredHighlightBegin: 0
+ preferredHighlightEnd: height
+ highlightRangeMode: ListView.StrictlyEnforceRange
+
+ highlightFollowsCurrentItem: false
+ highlight: Item {
+ y: view.currentItem?.y ?? 0
+ implicitHeight: view.currentItem?.size ?? 0
+
+ Behavior on y {
+ Anim {}
+ }
+ }
+
+ delegate: ColumnLayout {
+ id: ws
+
+ required property HyprlandWorkspace modelData
+ readonly property int size: label.Layout.preferredHeight + (hasWindows ? windows.implicitHeight + Appearance.padding.small : 0)
+ property int wsId
+ property string icon
+ property bool hasWindows
+
+ anchors.left: view.contentItem.left
+ anchors.right: view.contentItem.right
+
+ spacing: 0
+
+ Component.onCompleted: {
+ wsId = modelData.id;
+ icon = Icons.getSpecialWsIcon(modelData.name);
+ hasWindows = Config.bar.workspaces.showWindowsOnSpecialWorkspaces && modelData.lastIpcObject.windows > 0;
+ }
+
+ // Hacky thing cause modelData gets destroyed before the remove anim finishes
+ Connections {
+ target: ws.modelData
+
+ function onIdChanged(): void {
+ if (ws.modelData)
+ ws.wsId = ws.modelData.id;
+ }
+
+ function onNameChanged(): void {
+ if (ws.modelData)
+ ws.icon = Icons.getSpecialWsIcon(ws.modelData.name);
+ }
+
+ function onLastIpcObjectChanged(): void {
+ if (ws.modelData)
+ ws.hasWindows = Config.bar.workspaces.showWindowsOnSpecialWorkspaces && ws.modelData.lastIpcObject.windows > 0;
+ }
+ }
+
+ Connections {
+ target: Config.bar.workspaces
+
+ function onShowWindowsOnSpecialWorkspacesChanged(): void {
+ if (ws.modelData)
+ ws.hasWindows = Config.bar.workspaces.showWindowsOnSpecialWorkspaces && ws.modelData.lastIpcObject.windows > 0;
+ }
+ }
+
+ Loader {
+ id: label
+
+ Layout.alignment: Qt.AlignHCenter | Qt.AlignTop
+ Layout.preferredHeight: Config.bar.sizes.innerWidth - Appearance.padding.small * 2
+
+ sourceComponent: ws.icon.length === 1 ? letterComp : iconComp
+
+ Component {
+ id: iconComp
+
+ MaterialIcon {
+ fill: 1
+ text: ws.icon
+ verticalAlignment: Qt.AlignVCenter
+ }
+ }
+
+ Component {
+ id: letterComp
+
+ StyledText {
+ text: ws.icon
+ verticalAlignment: Qt.AlignVCenter
+ }
+ }
+ }
+
+ Loader {
+ id: windows
+
+ Layout.alignment: Qt.AlignHCenter
+ Layout.fillHeight: true
+ Layout.preferredHeight: implicitHeight
+
+ visible: active
+ active: ws.hasWindows
+
+ sourceComponent: Column {
+ spacing: 0
+
+ add: Transition {
+ Anim {
+ properties: "scale"
+ from: 0
+ to: 1
+ easing.bezierCurve: Appearance.anim.curves.standardDecel
+ }
+ }
+
+ move: Transition {
+ Anim {
+ properties: "scale"
+ to: 1
+ easing.bezierCurve: Appearance.anim.curves.standardDecel
+ }
+ Anim {
+ properties: "x,y"
+ }
+ }
+
+ Repeater {
+ model: ScriptModel {
+ values: Hypr.toplevels.values.filter(c => c.workspace?.id === ws.wsId)
+ }
+
+ MaterialIcon {
+ required property var modelData
+
+ grade: 0
+ text: Icons.getAppCategoryIcon(modelData.lastIpcObject.class, "terminal")
+ color: Colours.palette.m3onSurfaceVariant
+ }
+ }
+ }
+
+ Behavior on Layout.preferredHeight {
+ Anim {}
+ }
+ }
+ }
+
+ add: Transition {
+ Anim {
+ properties: "scale"
+ from: 0
+ to: 1
+ easing.bezierCurve: Appearance.anim.curves.standardDecel
+ }
+ }
+
+ remove: Transition {
+ Anim {
+ property: "scale"
+ to: 0.5
+ duration: Appearance.anim.durations.small
+ }
+ Anim {
+ property: "opacity"
+ to: 0
+ duration: Appearance.anim.durations.small
+ }
+ }
+
+ move: Transition {
+ Anim {
+ properties: "scale"
+ to: 1
+ easing.bezierCurve: Appearance.anim.curves.standardDecel
+ }
+ Anim {
+ properties: "x,y"
+ }
+ }
+
+ displaced: Transition {
+ Anim {
+ properties: "scale"
+ to: 1
+ easing.bezierCurve: Appearance.anim.curves.standardDecel
+ }
+ Anim {
+ properties: "x,y"
+ }
+ }
+ }
+
+ Loader {
+ active: Config.bar.workspaces.activeIndicator
+ anchors.fill: parent
+
+ sourceComponent: Item {
+ StyledClippingRect {
+ id: indicator
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+
+ y: (view.currentItem?.y ?? 0) - view.contentY
+ implicitHeight: view.currentItem?.size ?? 0
+
+ color: Colours.palette.m3tertiary
+ radius: Appearance.rounding.full
+
+ Colouriser {
+ source: view
+ sourceColor: Colours.palette.m3onSurface
+ colorizationColor: Colours.palette.m3onTertiary
+
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ x: 0
+ y: -indicator.y
+ implicitWidth: view.width
+ implicitHeight: view.height
+ }
+
+ Behavior on y {
+ Anim {
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+
+ Behavior on implicitHeight {
+ Anim {
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+ }
+ }
+ }
+
+ MouseArea {
+ property real startY
+
+ anchors.fill: view
+
+ drag.target: view.contentItem
+ drag.axis: Drag.YAxis
+ drag.maximumY: 0
+ drag.minimumY: Math.min(0, view.height - view.contentHeight - Appearance.padding.small)
+
+ onPressed: event => startY = event.y
+
+ onClicked: event => {
+ if (Math.abs(event.y - startY) > drag.threshold)
+ return;
+
+ const ws = view.itemAt(event.x, event.y);
+ if (ws?.modelData)
+ Hypr.dispatch(`togglespecialworkspace ${ws.modelData.name.slice(8)}`);
+ else
+ Hypr.dispatch("togglespecialworkspace special");
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/bar/components/workspaces/Workspace.qml b/.config/quickshell/caelestia/modules/bar/components/workspaces/Workspace.qml
new file mode 100644
index 0000000..3c8238b
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/bar/components/workspaces/Workspace.qml
@@ -0,0 +1,107 @@
+import qs.components
+import qs.services
+import qs.utils
+import qs.config
+import Quickshell
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ required property int index
+ required property int activeWsId
+ 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 int size: implicitHeight + (hasWindows ? Appearance.padding.small : 0)
+
+ readonly property int ws: groupOffset + index + 1
+ readonly property bool isOccupied: occupied[ws] ?? false
+ readonly property bool hasWindows: isOccupied && Config.bar.workspaces.showWindows
+
+ Layout.alignment: Qt.AlignHCenter
+ Layout.preferredHeight: size
+
+ spacing: 0
+
+ StyledText {
+ id: indicator
+
+ Layout.alignment: Qt.AlignHCenter | Qt.AlignTop
+ Layout.preferredHeight: Config.bar.sizes.innerWidth - Appearance.padding.small * 2
+
+ animate: true
+ text: {
+ const ws = Hypr.workspaces.values.find(w => w.id === root.ws);
+ const wsName = !ws || ws.name == root.ws ? root.ws : ws.name[0];
+ let displayName = wsName.toString();
+ if (Config.bar.workspaces.capitalisation.toLowerCase() === "upper") {
+ displayName = displayName.toUpperCase();
+ } else if (Config.bar.workspaces.capitalisation.toLowerCase() === "lower") {
+ displayName = displayName.toLowerCase();
+ }
+ const label = Config.bar.workspaces.label || displayName;
+ const occupiedLabel = Config.bar.workspaces.occupiedLabel || label;
+ const activeLabel = Config.bar.workspaces.activeLabel || (root.isOccupied ? occupiedLabel : label);
+ return root.activeWsId === root.ws ? activeLabel : root.isOccupied ? occupiedLabel : label;
+ }
+ color: Config.bar.workspaces.occupiedBg || root.isOccupied || root.activeWsId === root.ws ? Colours.palette.m3onSurface : Colours.layer(Colours.palette.m3outlineVariant, 2)
+ verticalAlignment: Qt.AlignVCenter
+ }
+
+ Loader {
+ id: windows
+
+ Layout.alignment: Qt.AlignHCenter
+ Layout.fillHeight: true
+ Layout.topMargin: -Config.bar.sizes.innerWidth / 10
+
+ visible: active
+ active: root.hasWindows
+
+ sourceComponent: Column {
+ spacing: 0
+
+ add: Transition {
+ Anim {
+ properties: "scale"
+ from: 0
+ to: 1
+ easing.bezierCurve: Appearance.anim.curves.standardDecel
+ }
+ }
+
+ move: Transition {
+ Anim {
+ properties: "scale"
+ to: 1
+ easing.bezierCurve: Appearance.anim.curves.standardDecel
+ }
+ Anim {
+ properties: "x,y"
+ }
+ }
+
+ Repeater {
+ model: ScriptModel {
+ values: Hypr.toplevels.values.filter(c => c.workspace?.id === root.ws)
+ }
+
+ MaterialIcon {
+ required property var modelData
+
+ grade: 0
+ text: Icons.getAppCategoryIcon(modelData.lastIpcObject.class, "terminal")
+ color: Colours.palette.m3onSurfaceVariant
+ }
+ }
+ }
+ }
+
+ Behavior on Layout.preferredHeight {
+ Anim {}
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/bar/components/workspaces/Workspaces.qml b/.config/quickshell/caelestia/modules/bar/components/workspaces/Workspaces.qml
new file mode 100644
index 0000000..bfa80ab
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/bar/components/workspaces/Workspaces.qml
@@ -0,0 +1,137 @@
+pragma ComponentBehavior: Bound
+
+import qs.services
+import qs.config
+import qs.components
+import Quickshell
+import QtQuick
+import QtQuick.Layouts
+import QtQuick.Effects
+
+StyledClippingRect {
+ id: root
+
+ required property ShellScreen screen
+
+ readonly property bool onSpecial: (Config.bar.workspaces.perMonitorWorkspaces ? Hypr.monitorFor(screen) : Hypr.focusedMonitor)?.lastIpcObject?.specialWorkspace?.name !== ""
+ readonly property int activeWsId: Config.bar.workspaces.perMonitorWorkspaces ? (Hypr.monitorFor(screen).activeWorkspace?.id ?? 1) : Hypr.activeWsId
+
+ readonly property var occupied: Hypr.workspaces.values.reduce((acc, curr) => {
+ acc[curr.id] = curr.lastIpcObject.windows > 0;
+ return acc;
+ }, {})
+ readonly property int groupOffset: Math.floor((activeWsId - 1) / Config.bar.workspaces.shown) * Config.bar.workspaces.shown
+
+ property real blur: onSpecial ? 1 : 0
+
+ implicitWidth: Config.bar.sizes.innerWidth
+ implicitHeight: layout.implicitHeight + Appearance.padding.small * 2
+
+ color: Colours.tPalette.m3surfaceContainer
+ radius: Appearance.rounding.full
+
+ Item {
+ anchors.fill: parent
+ scale: root.onSpecial ? 0.8 : 1
+ opacity: root.onSpecial ? 0.5 : 1
+
+ layer.enabled: root.blur > 0
+ layer.effect: MultiEffect {
+ blurEnabled: true
+ blur: root.blur
+ blurMax: 32
+ }
+
+ Loader {
+ active: Config.bar.workspaces.occupiedBg
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.small
+
+ sourceComponent: OccupiedBg {
+ workspaces: workspaces
+ occupied: root.occupied
+ groupOffset: root.groupOffset
+ }
+ }
+
+ ColumnLayout {
+ id: layout
+
+ anchors.centerIn: parent
+ spacing: Math.floor(Appearance.spacing.small / 2)
+
+ Repeater {
+ id: workspaces
+
+ model: Config.bar.workspaces.shown
+
+ Workspace {
+ activeWsId: root.activeWsId
+ occupied: root.occupied
+ groupOffset: root.groupOffset
+ }
+ }
+ }
+
+ Loader {
+ anchors.horizontalCenter: parent.horizontalCenter
+ active: Config.bar.workspaces.activeIndicator
+
+ sourceComponent: ActiveIndicator {
+ activeWsId: root.activeWsId
+ workspaces: workspaces
+ mask: layout
+ }
+ }
+
+ MouseArea {
+ anchors.fill: layout
+ onClicked: event => {
+ const ws = layout.childAt(event.x, event.y).ws;
+ if (Hypr.activeWsId !== ws)
+ Hypr.dispatch(`workspace ${ws}`);
+ else
+ Hypr.dispatch("togglespecialworkspace special");
+ }
+ }
+
+ Behavior on scale {
+ Anim {}
+ }
+
+ Behavior on opacity {
+ Anim {}
+ }
+ }
+
+ Loader {
+ id: specialWs
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.small
+
+ active: opacity > 0
+
+ scale: root.onSpecial ? 1 : 0.5
+ opacity: root.onSpecial ? 1 : 0
+
+ sourceComponent: SpecialWorkspaces {
+ screen: root.screen
+ }
+
+ Behavior on scale {
+ Anim {}
+ }
+
+ Behavior on opacity {
+ Anim {}
+ }
+ }
+
+ Behavior on blur {
+ Anim {
+ duration: Appearance.anim.durations.small
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/bar/popouts/ActiveWindow.qml b/.config/quickshell/caelestia/modules/bar/popouts/ActiveWindow.qml
new file mode 100644
index 0000000..adf7b77
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/bar/popouts/ActiveWindow.qml
@@ -0,0 +1,102 @@
+import qs.components
+import qs.services
+import qs.utils
+import qs.config
+import Quickshell.Widgets
+import Quickshell.Wayland
+import QtQuick
+import QtQuick.Layouts
+
+Item {
+ id: root
+
+ required property Item wrapper
+
+ implicitWidth: Hypr.activeToplevel ? child.implicitWidth : -Appearance.padding.large * 2
+ implicitHeight: child.implicitHeight
+
+ Column {
+ id: child
+
+ anchors.centerIn: parent
+ spacing: Appearance.spacing.normal
+
+ RowLayout {
+ id: detailsRow
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ spacing: Appearance.spacing.normal
+
+ IconImage {
+ id: icon
+
+ Layout.alignment: Qt.AlignVCenter
+ implicitSize: details.implicitHeight
+ source: Icons.getAppIcon(Hypr.activeToplevel?.lastIpcObject.class ?? "", "image-missing")
+ }
+
+ ColumnLayout {
+ id: details
+
+ spacing: 0
+ Layout.fillWidth: true
+
+ StyledText {
+ Layout.fillWidth: true
+ text: Hypr.activeToplevel?.title ?? ""
+ font.pointSize: Appearance.font.size.normal
+ elide: Text.ElideRight
+ }
+
+ StyledText {
+ Layout.fillWidth: true
+ text: Hypr.activeToplevel?.lastIpcObject.class ?? ""
+ color: Colours.palette.m3onSurfaceVariant
+ elide: Text.ElideRight
+ }
+ }
+
+ Item {
+ implicitWidth: expandIcon.implicitHeight + Appearance.padding.small * 2
+ implicitHeight: expandIcon.implicitHeight + Appearance.padding.small * 2
+
+ Layout.alignment: Qt.AlignVCenter
+
+ StateLayer {
+ radius: Appearance.rounding.normal
+
+ function onClicked(): void {
+ root.wrapper.detach("winfo");
+ }
+ }
+
+ MaterialIcon {
+ id: expandIcon
+
+ anchors.centerIn: parent
+ anchors.horizontalCenterOffset: font.pointSize * 0.05
+
+ text: "chevron_right"
+
+ font.pointSize: Appearance.font.size.large
+ }
+ }
+ }
+
+ ClippingWrapperRectangle {
+ color: "transparent"
+ radius: Appearance.rounding.small
+
+ ScreencopyView {
+ id: preview
+
+ captureSource: Hypr.activeToplevel?.wayland ?? null
+ live: visible
+
+ constraintSize.width: Config.bar.sizes.windowPreviewSize
+ constraintSize.height: Config.bar.sizes.windowPreviewSize
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/bar/popouts/Audio.qml b/.config/quickshell/caelestia/modules/bar/popouts/Audio.qml
new file mode 100644
index 0000000..58b29ba
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/bar/popouts/Audio.qml
@@ -0,0 +1,120 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.components.controls
+import qs.services
+import qs.config
+import Quickshell
+import Quickshell.Services.Pipewire
+import QtQuick
+import QtQuick.Layouts
+import QtQuick.Controls
+import "../../controlcenter/network"
+
+Item {
+ id: root
+
+ required property var wrapper
+
+ implicitWidth: layout.implicitWidth + Appearance.padding.normal * 2
+ implicitHeight: layout.implicitHeight + Appearance.padding.normal * 2
+
+ ButtonGroup {
+ id: sinks
+ }
+
+ ButtonGroup {
+ id: sources
+ }
+
+ ColumnLayout {
+ id: layout
+
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ text: qsTr("Output device")
+ font.weight: 500
+ }
+
+ Repeater {
+ model: Audio.sinks
+
+ StyledRadioButton {
+ id: control
+
+ required property PwNode modelData
+
+ ButtonGroup.group: sinks
+ checked: Audio.sink?.id === modelData.id
+ onClicked: Audio.setAudioSink(modelData)
+ text: modelData.description
+ }
+ }
+
+ StyledText {
+ Layout.topMargin: Appearance.spacing.smaller
+ text: qsTr("Input device")
+ font.weight: 500
+ }
+
+ Repeater {
+ model: Audio.sources
+
+ StyledRadioButton {
+ required property PwNode modelData
+
+ ButtonGroup.group: sources
+ checked: Audio.source?.id === modelData.id
+ onClicked: Audio.setAudioSource(modelData)
+ text: modelData.description
+ }
+ }
+
+ StyledText {
+ Layout.topMargin: Appearance.spacing.smaller
+ Layout.bottomMargin: -Appearance.spacing.small / 2
+ text: qsTr("Volume (%1)").arg(Audio.muted ? qsTr("Muted") : `${Math.round(Audio.volume * 100)}%`)
+ font.weight: 500
+ }
+
+ CustomMouseArea {
+ Layout.fillWidth: true
+ implicitHeight: Appearance.padding.normal * 3
+
+ onWheel: event => {
+ if (event.angleDelta.y > 0)
+ Audio.incrementVolume();
+ else if (event.angleDelta.y < 0)
+ Audio.decrementVolume();
+ }
+
+ StyledSlider {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ implicitHeight: parent.implicitHeight
+
+ value: Audio.volume
+ onMoved: Audio.setVolume(value)
+
+ Behavior on value {
+ Anim {}
+ }
+ }
+ }
+
+ IconTextButton {
+ Layout.fillWidth: true
+ Layout.topMargin: Appearance.spacing.normal
+ inactiveColour: Colours.palette.m3primaryContainer
+ inactiveOnColour: Colours.palette.m3onPrimaryContainer
+ verticalPadding: Appearance.padding.small
+ text: qsTr("Open settings")
+ icon: "settings"
+
+ onClicked: root.wrapper.detach("audio")
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/bar/popouts/Background.qml b/.config/quickshell/caelestia/modules/bar/popouts/Background.qml
new file mode 100644
index 0000000..075b698
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/bar/popouts/Background.qml
@@ -0,0 +1,73 @@
+import qs.components
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Shapes
+
+ShapePath {
+ id: root
+
+ required property Wrapper wrapper
+ required property bool invertBottomRounding
+ readonly property real rounding: wrapper.isDetached ? Appearance.rounding.normal : Config.border.rounding
+ readonly property bool flatten: wrapper.width < rounding * 2
+ readonly property real roundingX: flatten ? wrapper.width / 2 : rounding
+ property real ibr: invertBottomRounding ? -1 : 1
+
+ property real sideRounding: startX > 0 ? -1 : 1
+
+ strokeWidth: -1
+ fillColor: Colours.palette.m3surface
+
+ PathArc {
+ relativeX: root.roundingX
+ relativeY: root.rounding * root.sideRounding
+ radiusX: Math.min(root.rounding, root.wrapper.width)
+ radiusY: root.rounding
+ direction: root.sideRounding < 0 ? PathArc.Clockwise : 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 * root.sideRounding
+ radiusX: Math.min(root.rounding, root.wrapper.width)
+ radiusY: root.rounding
+ direction: root.sideRounding < 0 ? PathArc.Clockwise : PathArc.Counterclockwise
+ }
+
+ Behavior on fillColor {
+ CAnim {}
+ }
+
+ Behavior on ibr {
+ Anim {}
+ }
+
+ Behavior on sideRounding {
+ Anim {}
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/bar/popouts/Battery.qml b/.config/quickshell/caelestia/modules/bar/popouts/Battery.qml
new file mode 100644
index 0000000..ac975e1
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/bar/popouts/Battery.qml
@@ -0,0 +1,230 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.services
+import qs.config
+import Quickshell.Services.UPower
+import QtQuick
+
+Column {
+ id: root
+
+ spacing: Appearance.spacing.normal
+ width: Config.bar.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
+
+ 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.tPalette.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 {
+ Anim {}
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/bar/popouts/Bluetooth.qml b/.config/quickshell/caelestia/modules/bar/popouts/Bluetooth.qml
new file mode 100644
index 0000000..676da82
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/bar/popouts/Bluetooth.qml
@@ -0,0 +1,197 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.components.controls
+import qs.services
+import qs.config
+import qs.utils
+import Quickshell
+import Quickshell.Bluetooth
+import QtQuick
+import QtQuick.Layouts
+import "../../controlcenter/network"
+
+ColumnLayout {
+ id: root
+
+ required property Item wrapper
+
+ spacing: Appearance.spacing.small
+
+ StyledText {
+ Layout.topMargin: Appearance.padding.normal
+ Layout.rightMargin: Appearance.padding.small
+ text: qsTr("Bluetooth")
+ font.weight: 500
+ }
+
+ Toggle {
+ label: qsTr("Enabled")
+ checked: Bluetooth.defaultAdapter?.enabled ?? false
+ toggle.onToggled: {
+ const adapter = Bluetooth.defaultAdapter;
+ if (adapter)
+ adapter.enabled = checked;
+ }
+ }
+
+ Toggle {
+ label: qsTr("Discovering")
+ checked: Bluetooth.defaultAdapter?.discovering ?? false
+ toggle.onToggled: {
+ const adapter = Bluetooth.defaultAdapter;
+ if (adapter)
+ adapter.discovering = checked;
+ }
+ }
+
+ StyledText {
+ Layout.topMargin: Appearance.spacing.small
+ Layout.rightMargin: Appearance.padding.small
+ text: {
+ const devices = Bluetooth.devices.values;
+ let available = qsTr("%1 device%2 available").arg(devices.length).arg(devices.length === 1 ? "" : "s");
+ const connected = devices.filter(d => d.connected).length;
+ if (connected > 0)
+ available += qsTr(" (%1 connected)").arg(connected);
+ return available;
+ }
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.small
+ }
+
+ Repeater {
+ model: ScriptModel {
+ values: [...Bluetooth.devices.values].sort((a, b) => (b.connected - a.connected) || (b.paired - a.paired) || a.name.localeCompare(b.name)).slice(0, 5)
+ }
+
+ RowLayout {
+ id: device
+
+ required property BluetoothDevice modelData
+ readonly property bool loading: modelData.state === BluetoothDeviceState.Connecting || modelData.state === BluetoothDeviceState.Disconnecting
+
+ Layout.fillWidth: true
+ Layout.rightMargin: Appearance.padding.small
+ spacing: Appearance.spacing.small
+
+ opacity: 0
+ scale: 0.7
+
+ Component.onCompleted: {
+ opacity = 1;
+ scale = 1;
+ }
+
+ Behavior on opacity {
+ Anim {}
+ }
+
+ Behavior on scale {
+ Anim {}
+ }
+
+ MaterialIcon {
+ text: Icons.getBluetoothIcon(device.modelData.icon)
+ }
+
+ StyledText {
+ Layout.leftMargin: Appearance.spacing.small / 2
+ Layout.rightMargin: Appearance.spacing.small / 2
+ Layout.fillWidth: true
+ text: device.modelData.name
+ }
+
+ StyledRect {
+ id: connectBtn
+
+ implicitWidth: implicitHeight
+ implicitHeight: connectIcon.implicitHeight + Appearance.padding.small
+
+ radius: Appearance.rounding.full
+ color: Qt.alpha(Colours.palette.m3primary, device.modelData.state === BluetoothDeviceState.Connected ? 1 : 0)
+
+ CircularIndicator {
+ anchors.fill: parent
+ running: device.loading
+ }
+
+ StateLayer {
+ color: device.modelData.state === BluetoothDeviceState.Connected ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface
+ disabled: device.loading
+
+ function onClicked(): void {
+ device.modelData.connected = !device.modelData.connected;
+ }
+ }
+
+ MaterialIcon {
+ id: connectIcon
+
+ anchors.centerIn: parent
+ animate: true
+ text: device.modelData.connected ? "link_off" : "link"
+ color: device.modelData.state === BluetoothDeviceState.Connected ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface
+
+ opacity: device.loading ? 0 : 1
+
+ Behavior on opacity {
+ Anim {}
+ }
+ }
+ }
+
+ Loader {
+ active: device.modelData.bonded
+ sourceComponent: Item {
+ implicitWidth: connectBtn.implicitWidth
+ implicitHeight: connectBtn.implicitHeight
+
+ StateLayer {
+ radius: Appearance.rounding.full
+
+ function onClicked(): void {
+ device.modelData.forget();
+ }
+ }
+
+ MaterialIcon {
+ anchors.centerIn: parent
+ text: "delete"
+ }
+ }
+ }
+ }
+ }
+
+ IconTextButton {
+ Layout.fillWidth: true
+ Layout.topMargin: Appearance.spacing.normal
+ inactiveColour: Colours.palette.m3primaryContainer
+ inactiveOnColour: Colours.palette.m3onPrimaryContainer
+ verticalPadding: Appearance.padding.small
+ text: qsTr("Open settings")
+ icon: "settings"
+
+ onClicked: root.wrapper.detach("bluetooth")
+ }
+
+ component Toggle: RowLayout {
+ required property string label
+ property alias checked: toggle.checked
+ property alias toggle: toggle
+
+ Layout.fillWidth: true
+ Layout.rightMargin: Appearance.padding.small
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ Layout.fillWidth: true
+ text: parent.label
+ }
+
+ StyledSwitch {
+ id: toggle
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/bar/popouts/Content.qml b/.config/quickshell/caelestia/modules/bar/popouts/Content.qml
new file mode 100644
index 0000000..6543e58
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/bar/popouts/Content.qml
@@ -0,0 +1,222 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.config
+import Quickshell
+import Quickshell.Services.SystemTray
+import QtQuick
+
+import "./kblayout"
+
+Item {
+ id: root
+
+ required property Item wrapper
+ readonly property Popout currentPopout: content.children.find(c => c.shouldBeActive) ?? null
+ readonly property Item current: currentPopout?.item ?? null
+
+ anchors.centerIn: parent
+
+ implicitWidth: (currentPopout?.implicitWidth ?? 0) + Appearance.padding.large * 2
+ implicitHeight: (currentPopout?.implicitHeight ?? 0) + Appearance.padding.large * 2
+
+ Item {
+ id: content
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.large
+
+ Popout {
+ name: "activewindow"
+ sourceComponent: ActiveWindow {
+ wrapper: root.wrapper
+ }
+ }
+
+ Popout {
+ id: networkPopout
+ name: "network"
+ sourceComponent: Network {
+ wrapper: root.wrapper
+ view: "wireless"
+ }
+ }
+
+ Popout {
+ name: "ethernet"
+ sourceComponent: Network {
+ wrapper: root.wrapper
+ view: "ethernet"
+ }
+ }
+
+ Popout {
+ id: passwordPopout
+ name: "wirelesspassword"
+ sourceComponent: WirelessPassword {
+ id: passwordComponent
+ wrapper: root.wrapper
+ network: networkPopout.item?.passwordNetwork ?? null
+ }
+
+ Connections {
+ target: root.wrapper
+ function onCurrentNameChanged() {
+ // Update network immediately when password popout becomes active
+ if (root.wrapper.currentName === "wirelesspassword") {
+ // Set network immediately if available
+ if (networkPopout.item && networkPopout.item.passwordNetwork) {
+ if (passwordPopout.item) {
+ passwordPopout.item.network = networkPopout.item.passwordNetwork;
+ }
+ }
+ // Also try after a short delay in case networkPopout.item wasn't ready
+ Qt.callLater(() => {
+ if (passwordPopout.item && networkPopout.item && networkPopout.item.passwordNetwork) {
+ passwordPopout.item.network = networkPopout.item.passwordNetwork;
+ }
+ }, 100);
+ }
+ }
+ }
+
+ Connections {
+ target: networkPopout
+ function onItemChanged() {
+ // When network popout loads, update password popout if it's active
+ if (root.wrapper.currentName === "wirelesspassword" && passwordPopout.item) {
+ Qt.callLater(() => {
+ if (networkPopout.item && networkPopout.item.passwordNetwork) {
+ passwordPopout.item.network = networkPopout.item.passwordNetwork;
+ }
+ });
+ }
+ }
+ }
+ }
+
+ Popout {
+ name: "bluetooth"
+ sourceComponent: Bluetooth {
+ wrapper: root.wrapper
+ }
+ }
+
+ Popout {
+ name: "battery"
+ sourceComponent: Battery {}
+ }
+
+ Popout {
+ name: "audio"
+ sourceComponent: Audio {
+ wrapper: root.wrapper
+ }
+ }
+
+ Popout {
+ name: "kblayout"
+ sourceComponent: KbLayout {
+ wrapper: root.wrapper
+ }
+ }
+
+ Popout {
+ name: "lockstatus"
+ sourceComponent: LockStatus {}
+ }
+
+ Repeater {
+ model: ScriptModel {
+ values: SystemTray.items.values.filter(i => !Config.bar.tray.hiddenIcons.includes(i.id))
+ }
+
+ Popout {
+ id: trayMenu
+
+ required property SystemTrayItem modelData
+ required property int index
+
+ name: `traymenu${index}`
+ sourceComponent: trayMenuComp
+
+ Connections {
+ target: root.wrapper
+
+ function onHasCurrentChanged(): void {
+ if (root.wrapper.hasCurrent && trayMenu.shouldBeActive) {
+ trayMenu.sourceComponent = null;
+ trayMenu.sourceComponent = trayMenuComp;
+ }
+ }
+ }
+
+ Component {
+ id: trayMenuComp
+
+ TrayMenu {
+ popouts: root.wrapper
+ trayItem: trayMenu.modelData.menu
+ }
+ }
+ }
+ }
+ }
+
+ component Popout: Loader {
+ id: popout
+
+ required property string name
+ readonly property bool shouldBeActive: root.wrapper.currentName === name
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.right: parent.right
+
+ opacity: 0
+ scale: 0.8
+ active: false
+
+ 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"
+ }
+ }
+ }
+ ]
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/bar/popouts/LockStatus.qml b/.config/quickshell/caelestia/modules/bar/popouts/LockStatus.qml
new file mode 100644
index 0000000..7d74530
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/bar/popouts/LockStatus.qml
@@ -0,0 +1,16 @@
+import qs.components
+import qs.services
+import qs.config
+import QtQuick.Layouts
+
+ColumnLayout {
+ spacing: Appearance.spacing.small
+
+ StyledText {
+ text: qsTr("Capslock: %1").arg(Hypr.capsLock ? "Enabled" : "Disabled")
+ }
+
+ StyledText {
+ text: qsTr("Numlock: %1").arg(Hypr.numLock ? "Enabled" : "Disabled")
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/bar/popouts/Network.qml b/.config/quickshell/caelestia/modules/bar/popouts/Network.qml
new file mode 100644
index 0000000..5b32e4a
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/bar/popouts/Network.qml
@@ -0,0 +1,388 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.components.controls
+import qs.services
+import qs.config
+import qs.utils
+import Quickshell
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ required property Item wrapper
+
+ property string connectingToSsid: ""
+ property string view: "wireless" // "wireless" or "ethernet"
+ property var passwordNetwork: null
+ property bool showPasswordDialog: false
+
+ spacing: Appearance.spacing.small
+ width: Config.bar.sizes.networkWidth
+
+ // Wireless section
+ StyledText {
+ visible: root.view === "wireless"
+ Layout.preferredHeight: visible ? implicitHeight : 0
+ Layout.topMargin: visible ? Appearance.padding.normal : 0
+ Layout.rightMargin: Appearance.padding.small
+ text: qsTr("Wireless")
+ font.weight: 500
+ }
+
+ Toggle {
+ visible: root.view === "wireless"
+ Layout.preferredHeight: visible ? implicitHeight : 0
+ label: qsTr("Enabled")
+ checked: Nmcli.wifiEnabled
+ toggle.onToggled: Nmcli.enableWifi(checked)
+ }
+
+ StyledText {
+ visible: root.view === "wireless"
+ Layout.preferredHeight: visible ? implicitHeight : 0
+ Layout.topMargin: visible ? Appearance.spacing.small : 0
+ Layout.rightMargin: Appearance.padding.small
+ text: qsTr("%1 networks available").arg(Nmcli.networks.length)
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.small
+ }
+
+ Repeater {
+ visible: root.view === "wireless"
+ model: ScriptModel {
+ values: [...Nmcli.networks].sort((a, b) => {
+ if (a.active !== b.active)
+ return b.active - a.active;
+ return b.strength - a.strength;
+ }).slice(0, 8)
+ }
+
+ RowLayout {
+ id: networkItem
+
+ required property Nmcli.AccessPoint modelData
+ readonly property bool isConnecting: root.connectingToSsid === modelData.ssid
+ readonly property bool loading: networkItem.isConnecting
+
+ visible: root.view === "wireless"
+ Layout.preferredHeight: visible ? implicitHeight : 0
+ Layout.fillWidth: true
+ Layout.rightMargin: Appearance.padding.small
+ spacing: Appearance.spacing.small
+
+ opacity: 0
+ scale: 0.7
+
+ Component.onCompleted: {
+ opacity = 1;
+ scale = 1;
+ }
+
+ Behavior on opacity {
+ Anim {}
+ }
+
+ Behavior on scale {
+ Anim {}
+ }
+
+ MaterialIcon {
+ text: Icons.getNetworkIcon(networkItem.modelData.strength)
+ color: networkItem.modelData.active ? Colours.palette.m3primary : Colours.palette.m3onSurfaceVariant
+ }
+
+ MaterialIcon {
+ visible: networkItem.modelData.isSecure
+ text: "lock"
+ font.pointSize: Appearance.font.size.small
+ }
+
+ StyledText {
+ Layout.leftMargin: Appearance.spacing.small / 2
+ Layout.rightMargin: Appearance.spacing.small / 2
+ Layout.fillWidth: true
+ text: networkItem.modelData.ssid
+ elide: Text.ElideRight
+ font.weight: networkItem.modelData.active ? 500 : 400
+ color: networkItem.modelData.active ? Colours.palette.m3primary : Colours.palette.m3onSurface
+ }
+
+ StyledRect {
+ implicitWidth: implicitHeight
+ implicitHeight: wirelessConnectIcon.implicitHeight + Appearance.padding.small
+
+ radius: Appearance.rounding.full
+ color: Qt.alpha(Colours.palette.m3primary, networkItem.modelData.active ? 1 : 0)
+
+ CircularIndicator {
+ anchors.fill: parent
+ running: networkItem.loading
+ }
+
+ StateLayer {
+ color: networkItem.modelData.active ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface
+ disabled: networkItem.loading || !Nmcli.wifiEnabled
+
+ function onClicked(): void {
+ if (networkItem.modelData.active) {
+ Nmcli.disconnectFromNetwork();
+ } else {
+ root.connectingToSsid = networkItem.modelData.ssid;
+ NetworkConnection.handleConnect(networkItem.modelData, null, network => {
+ // Password is required - show password dialog
+ root.passwordNetwork = network;
+ root.showPasswordDialog = true;
+ root.wrapper.currentName = "wirelesspassword";
+ });
+
+ // Clear connecting state if connection succeeds immediately (saved profile)
+ // This is handled by the onActiveChanged connection below
+ }
+ }
+ }
+
+ MaterialIcon {
+ id: wirelessConnectIcon
+
+ anchors.centerIn: parent
+ animate: true
+ text: networkItem.modelData.active ? "link_off" : "link"
+ color: networkItem.modelData.active ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface
+
+ opacity: networkItem.loading ? 0 : 1
+
+ Behavior on opacity {
+ Anim {}
+ }
+ }
+ }
+ }
+ }
+
+ StyledRect {
+ visible: root.view === "wireless"
+ Layout.preferredHeight: visible ? implicitHeight : 0
+ Layout.topMargin: visible ? Appearance.spacing.small : 0
+ Layout.fillWidth: true
+ implicitHeight: rescanBtn.implicitHeight + Appearance.padding.small * 2
+
+ radius: Appearance.rounding.full
+ color: Colours.palette.m3primaryContainer
+
+ StateLayer {
+ color: Colours.palette.m3onPrimaryContainer
+ disabled: Nmcli.scanning || !Nmcli.wifiEnabled
+
+ function onClicked(): void {
+ Nmcli.rescanWifi();
+ }
+ }
+
+ RowLayout {
+ id: rescanBtn
+
+ anchors.centerIn: parent
+ spacing: Appearance.spacing.small
+ opacity: Nmcli.scanning ? 0 : 1
+
+ MaterialIcon {
+ id: scanIcon
+
+ Layout.topMargin: Math.round(fontInfo.pointSize * 0.0575)
+ animate: true
+ text: "wifi_find"
+ color: Colours.palette.m3onPrimaryContainer
+ }
+
+ StyledText {
+ Layout.topMargin: -Math.round(scanIcon.fontInfo.pointSize * 0.0575)
+ text: qsTr("Rescan networks")
+ color: Colours.palette.m3onPrimaryContainer
+ }
+
+ Behavior on opacity {
+ Anim {}
+ }
+ }
+
+ CircularIndicator {
+ anchors.centerIn: parent
+ strokeWidth: Appearance.padding.small / 2
+ bgColour: "transparent"
+ implicitSize: parent.implicitHeight - Appearance.padding.smaller * 2
+ running: Nmcli.scanning
+ }
+ }
+
+ // Ethernet section
+ StyledText {
+ visible: root.view === "ethernet"
+ Layout.preferredHeight: visible ? implicitHeight : 0
+ Layout.topMargin: visible ? Appearance.padding.normal : 0
+ Layout.rightMargin: Appearance.padding.small
+ text: qsTr("Ethernet")
+ font.weight: 500
+ }
+
+ StyledText {
+ visible: root.view === "ethernet"
+ Layout.preferredHeight: visible ? implicitHeight : 0
+ Layout.topMargin: visible ? Appearance.spacing.small : 0
+ Layout.rightMargin: Appearance.padding.small
+ text: qsTr("%1 devices available").arg(Nmcli.ethernetDevices.length)
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.small
+ }
+
+ Repeater {
+ visible: root.view === "ethernet"
+ model: ScriptModel {
+ values: [...Nmcli.ethernetDevices].sort((a, b) => {
+ if (a.connected !== b.connected)
+ return b.connected - a.connected;
+ return (a.interface || "").localeCompare(b.interface || "");
+ }).slice(0, 8)
+ }
+
+ RowLayout {
+ id: ethernetItem
+
+ required property var modelData
+ readonly property bool loading: false
+
+ visible: root.view === "ethernet"
+ Layout.preferredHeight: visible ? implicitHeight : 0
+ Layout.fillWidth: true
+ Layout.rightMargin: Appearance.padding.small
+ spacing: Appearance.spacing.small
+
+ opacity: 0
+ scale: 0.7
+
+ Component.onCompleted: {
+ opacity = 1;
+ scale = 1;
+ }
+
+ Behavior on opacity {
+ Anim {}
+ }
+
+ Behavior on scale {
+ Anim {}
+ }
+
+ MaterialIcon {
+ text: "cable"
+ color: ethernetItem.modelData.connected ? Colours.palette.m3primary : Colours.palette.m3onSurfaceVariant
+ }
+
+ StyledText {
+ Layout.leftMargin: Appearance.spacing.small / 2
+ Layout.rightMargin: Appearance.spacing.small / 2
+ Layout.fillWidth: true
+ text: ethernetItem.modelData.interface || qsTr("Unknown")
+ elide: Text.ElideRight
+ font.weight: ethernetItem.modelData.connected ? 500 : 400
+ color: ethernetItem.modelData.connected ? Colours.palette.m3primary : Colours.palette.m3onSurface
+ }
+
+ StyledRect {
+ implicitWidth: implicitHeight
+ implicitHeight: connectIcon.implicitHeight + Appearance.padding.small
+
+ radius: Appearance.rounding.full
+ color: Qt.alpha(Colours.palette.m3primary, ethernetItem.modelData.connected ? 1 : 0)
+
+ CircularIndicator {
+ anchors.fill: parent
+ running: ethernetItem.loading
+ }
+
+ StateLayer {
+ color: ethernetItem.modelData.connected ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface
+ disabled: ethernetItem.loading
+
+ function onClicked(): void {
+ if (ethernetItem.modelData.connected && ethernetItem.modelData.connection) {
+ Nmcli.disconnectEthernet(ethernetItem.modelData.connection, () => {});
+ } else {
+ Nmcli.connectEthernet(ethernetItem.modelData.connection || "", ethernetItem.modelData.interface || "", () => {});
+ }
+ }
+ }
+
+ MaterialIcon {
+ id: connectIcon
+
+ anchors.centerIn: parent
+ animate: true
+ text: ethernetItem.modelData.connected ? "link_off" : "link"
+ color: ethernetItem.modelData.connected ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface
+
+ opacity: ethernetItem.loading ? 0 : 1
+
+ Behavior on opacity {
+ Anim {}
+ }
+ }
+ }
+ }
+ }
+
+ Connections {
+ target: Nmcli
+
+ function onActiveChanged(): void {
+ if (Nmcli.active && root.connectingToSsid === Nmcli.active.ssid) {
+ root.connectingToSsid = "";
+ // Close password dialog if we successfully connected
+ if (root.showPasswordDialog && root.passwordNetwork && Nmcli.active.ssid === root.passwordNetwork.ssid) {
+ root.showPasswordDialog = false;
+ root.passwordNetwork = null;
+ if (root.wrapper.currentName === "wirelesspassword") {
+ root.wrapper.currentName = "network";
+ }
+ }
+ }
+ }
+
+ function onScanningChanged(): void {
+ if (!Nmcli.scanning)
+ scanIcon.rotation = 0;
+ }
+ }
+
+ Connections {
+ target: root.wrapper
+ function onCurrentNameChanged(): void {
+ // Clear password network when leaving password dialog
+ if (root.wrapper.currentName !== "wirelesspassword" && root.showPasswordDialog) {
+ root.showPasswordDialog = false;
+ root.passwordNetwork = null;
+ }
+ }
+ }
+
+ component Toggle: RowLayout {
+ required property string label
+ property alias checked: toggle.checked
+ property alias toggle: toggle
+
+ Layout.fillWidth: true
+ Layout.rightMargin: Appearance.padding.small
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ Layout.fillWidth: true
+ text: parent.label
+ }
+
+ StyledSwitch {
+ id: toggle
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/bar/popouts/TrayMenu.qml b/.config/quickshell/caelestia/modules/bar/popouts/TrayMenu.qml
new file mode 100644
index 0000000..9b743db
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/bar/popouts/TrayMenu.qml
@@ -0,0 +1,225 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.services
+import qs.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: NoAnim {}
+ pushExit: NoAnim {}
+ popEnter: NoAnim {}
+ popExit: NoAnim {}
+
+ component NoAnim: 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 {
+ Anim {}
+ }
+
+ Behavior on scale {
+ Anim {}
+ }
+
+ QsMenuOpener {
+ id: menuOpener
+
+ menu: menu.handle
+ }
+
+ Repeater {
+ model: menuOpener.children
+
+ StyledRect {
+ id: item
+
+ required property QsMenuEntry modelData
+
+ implicitWidth: Config.bar.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
+
+ 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 !== ""
+
+ 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: Config.bar.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
+
+ sourceComponent: MaterialIcon {
+ text: "chevron_right"
+ color: item.modelData.enabled ? Colours.palette.m3onSurface : Colours.palette.m3outline
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Loader {
+ active: menu.isSubMenu
+
+ 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/.config/quickshell/caelestia/modules/bar/popouts/WirelessPassword.qml b/.config/quickshell/caelestia/modules/bar/popouts/WirelessPassword.qml
new file mode 100644
index 0000000..96639e7
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/bar/popouts/WirelessPassword.qml
@@ -0,0 +1,605 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.components.controls
+import qs.services
+import qs.config
+import qs.utils
+import Quickshell
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ required property Item wrapper
+ property var network: null
+ property bool isClosing: false
+
+ readonly property bool shouldBeVisible: root.wrapper.currentName === "wirelesspassword"
+
+ Connections {
+ target: root.wrapper
+ function onCurrentNameChanged() {
+ if (root.wrapper.currentName === "wirelesspassword") {
+ // Update network when popout becomes active
+ Qt.callLater(() => {
+ // Try to get network from parent Content's networkPopout
+ const content = root.parent?.parent?.parent;
+ if (content) {
+ const networkPopout = content.children.find(c => c.name === "network");
+ if (networkPopout && networkPopout.item) {
+ root.network = networkPopout.item.passwordNetwork;
+ }
+ }
+ // Force focus to password container when popout becomes active
+ // Use Timer for actual delay to ensure dialog is fully rendered
+ focusTimer.start();
+ });
+ }
+ }
+ }
+
+ Timer {
+ id: focusTimer
+ interval: 150
+ onTriggered: {
+ root.forceActiveFocus();
+ passwordContainer.forceActiveFocus();
+ }
+ }
+
+ spacing: Appearance.spacing.normal
+
+ implicitWidth: 400
+ implicitHeight: content.implicitHeight + Appearance.padding.large * 2
+
+ visible: shouldBeVisible || isClosing
+ enabled: shouldBeVisible && !isClosing
+ focus: enabled
+
+ Component.onCompleted: {
+ if (shouldBeVisible) {
+ // Use Timer for actual delay to ensure dialog is fully rendered
+ focusTimer.start();
+ }
+ }
+
+ onShouldBeVisibleChanged: {
+ if (shouldBeVisible) {
+ // Use Timer for actual delay to ensure dialog is fully rendered
+ focusTimer.start();
+ }
+ }
+
+ Keys.onEscapePressed: closeDialog()
+
+ StyledRect {
+ Layout.fillWidth: true
+ Layout.preferredWidth: 400
+ implicitHeight: content.implicitHeight + Appearance.padding.large * 2
+
+ radius: Appearance.rounding.normal
+ color: Colours.tPalette.m3surfaceContainer
+ visible: root.shouldBeVisible || root.isClosing
+ opacity: root.shouldBeVisible && !root.isClosing ? 1 : 0
+ scale: root.shouldBeVisible && !root.isClosing ? 1 : 0.7
+
+ Behavior on opacity {
+ Anim {}
+ }
+
+ Behavior on scale {
+ Anim {}
+ }
+
+ ParallelAnimation {
+ running: root.isClosing
+ onFinished: {
+ if (root.isClosing) {
+ root.isClosing = false;
+ }
+ }
+
+ Anim {
+ target: parent
+ property: "opacity"
+ to: 0
+ }
+ Anim {
+ target: parent
+ property: "scale"
+ to: 0.7
+ }
+ }
+
+ Keys.onEscapePressed: root.closeDialog()
+
+ ColumnLayout {
+ id: content
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.large
+
+ spacing: Appearance.spacing.normal
+
+ MaterialIcon {
+ Layout.alignment: Qt.AlignHCenter
+ text: "lock"
+ font.pointSize: Appearance.font.size.extraLarge * 2
+ }
+
+ StyledText {
+ Layout.alignment: Qt.AlignHCenter
+ text: qsTr("Enter password")
+ font.pointSize: Appearance.font.size.large
+ font.weight: 500
+ }
+
+ StyledText {
+ id: networkNameText
+ Layout.alignment: Qt.AlignHCenter
+ text: {
+ if (root.network) {
+ const ssid = root.network.ssid;
+ if (ssid && ssid.length > 0) {
+ return qsTr("Network: %1").arg(ssid);
+ }
+ }
+ return qsTr("Network: Unknown");
+ }
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ }
+
+ Timer {
+ interval: 50
+ running: root.shouldBeVisible && (!root.network || !root.network.ssid)
+ repeat: true
+ property int attempts: 0
+ onTriggered: {
+ attempts++;
+ // Keep trying to get network from Network component
+ const content = root.parent?.parent?.parent;
+ if (content) {
+ const networkPopout = content.children.find(c => c.name === "network");
+ if (networkPopout && networkPopout.item && networkPopout.item.passwordNetwork) {
+ root.network = networkPopout.item.passwordNetwork;
+ }
+ }
+ // Stop if we got it or after 20 attempts (1 second)
+ if ((root.network && root.network.ssid) || attempts >= 20) {
+ stop();
+ attempts = 0;
+ }
+ }
+ onRunningChanged: {
+ if (!running) {
+ attempts = 0;
+ }
+ }
+ }
+
+ StyledText {
+ id: statusText
+
+ Layout.alignment: Qt.AlignHCenter
+ Layout.topMargin: Appearance.spacing.small
+ visible: connectButton.connecting || connectButton.hasError
+ text: {
+ if (connectButton.hasError) {
+ return qsTr("Connection failed. Please check your password and try again.");
+ }
+ if (connectButton.connecting) {
+ return qsTr("Connecting...");
+ }
+ return "";
+ }
+ color: connectButton.hasError ? Colours.palette.m3error : Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.small
+ font.weight: 400
+ wrapMode: Text.WordWrap
+ Layout.maximumWidth: parent.width - Appearance.padding.large * 2
+ }
+
+ FocusScope {
+ id: passwordContainer
+ objectName: "passwordContainer"
+ Layout.topMargin: Appearance.spacing.large
+ Layout.fillWidth: true
+ implicitHeight: Math.max(48, charList.implicitHeight + Appearance.padding.normal * 2)
+
+ focus: true
+ activeFocusOnTab: true
+
+ property string passwordBuffer: ""
+
+ Keys.onPressed: event => {
+ // Ensure we have focus when receiving keyboard input
+ if (!activeFocus) {
+ forceActiveFocus();
+ }
+
+ // Clear error when user starts typing
+ if (connectButton.hasError && event.text && event.text.length > 0) {
+ connectButton.hasError = false;
+ }
+
+ if (event.key === Qt.Key_Enter || event.key === Qt.Key_Return) {
+ if (connectButton.enabled) {
+ connectButton.clicked();
+ }
+ event.accepted = true;
+ } else if (event.key === Qt.Key_Backspace) {
+ if (event.modifiers & Qt.ControlModifier) {
+ passwordBuffer = "";
+ } else {
+ passwordBuffer = passwordBuffer.slice(0, -1);
+ }
+ event.accepted = true;
+ } else if (event.text && event.text.length > 0) {
+ passwordBuffer += event.text;
+ event.accepted = true;
+ }
+ }
+
+ Connections {
+ target: root
+ function onShouldBeVisibleChanged(): void {
+ if (root.shouldBeVisible) {
+ // Use Timer for actual delay to ensure focus works correctly
+ passwordFocusTimer.start();
+ passwordContainer.passwordBuffer = "";
+ connectButton.hasError = false;
+ }
+ }
+ }
+
+ Timer {
+ id: passwordFocusTimer
+ interval: 50
+ onTriggered: {
+ passwordContainer.forceActiveFocus();
+ }
+ }
+
+ Component.onCompleted: {
+ if (root.shouldBeVisible) {
+ // Use Timer for actual delay to ensure focus works correctly
+ passwordFocusTimer.start();
+ }
+ }
+
+ StyledRect {
+ anchors.fill: parent
+ radius: Appearance.rounding.normal
+ color: passwordContainer.activeFocus ? Qt.lighter(Colours.tPalette.m3surfaceContainer, 1.05) : Colours.tPalette.m3surfaceContainer
+ border.width: passwordContainer.activeFocus || connectButton.hasError ? 4 : (root.shouldBeVisible ? 1 : 0)
+ border.color: {
+ if (connectButton.hasError) {
+ return Colours.palette.m3error;
+ }
+ if (passwordContainer.activeFocus) {
+ return Colours.palette.m3primary;
+ }
+ return root.shouldBeVisible ? Colours.palette.m3outline : "transparent";
+ }
+
+ Behavior on border.color {
+ CAnim {}
+ }
+
+ Behavior on border.width {
+ CAnim {}
+ }
+
+ Behavior on color {
+ CAnim {}
+ }
+ }
+
+ StateLayer {
+ hoverEnabled: false
+ cursorShape: Qt.IBeamCursor
+ radius: Appearance.rounding.normal
+
+ function onClicked(): void {
+ passwordContainer.forceActiveFocus();
+ }
+ }
+
+ StyledText {
+ id: placeholder
+
+ anchors.centerIn: parent
+ text: qsTr("Password")
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.normal
+ font.family: Appearance.font.family.mono
+ opacity: passwordContainer.passwordBuffer ? 0 : 1
+
+ Behavior on opacity {
+ Anim {}
+ }
+ }
+
+ ListView {
+ id: charList
+
+ readonly property int fullWidth: count * (implicitHeight + spacing) - spacing
+
+ anchors.centerIn: parent
+ implicitWidth: fullWidth
+ implicitHeight: Appearance.font.size.normal
+
+ orientation: Qt.Horizontal
+ spacing: Appearance.spacing.small / 2
+ interactive: false
+
+ model: ScriptModel {
+ values: passwordContainer.passwordBuffer.split("")
+ }
+
+ delegate: StyledRect {
+ id: ch
+
+ implicitWidth: implicitHeight
+ implicitHeight: charList.implicitHeight
+
+ color: Colours.palette.m3onSurface
+ radius: Appearance.rounding.small / 2
+
+ opacity: 0
+ scale: 0
+ Component.onCompleted: {
+ opacity = 1;
+ scale = 1;
+ }
+ ListView.onRemove: removeAnim.start()
+
+ SequentialAnimation {
+ id: removeAnim
+
+ PropertyAction {
+ target: ch
+ property: "ListView.delayRemove"
+ value: true
+ }
+ ParallelAnimation {
+ Anim {
+ target: ch
+ property: "opacity"
+ to: 0
+ }
+ Anim {
+ target: ch
+ property: "scale"
+ to: 0.5
+ }
+ }
+ PropertyAction {
+ target: ch
+ property: "ListView.delayRemove"
+ value: false
+ }
+ }
+
+ Behavior on opacity {
+ Anim {}
+ }
+
+ Behavior on scale {
+ Anim {
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
+ }
+ }
+ }
+
+ Behavior on implicitWidth {
+ Anim {}
+ }
+ }
+ }
+
+ RowLayout {
+ Layout.topMargin: Appearance.spacing.normal
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+
+ TextButton {
+ id: cancelButton
+
+ Layout.fillWidth: true
+ Layout.minimumHeight: Appearance.font.size.normal + Appearance.padding.normal * 2
+ inactiveColour: Colours.palette.m3secondaryContainer
+ inactiveOnColour: Colours.palette.m3onSecondaryContainer
+ text: qsTr("Cancel")
+
+ onClicked: root.closeDialog()
+ }
+
+ TextButton {
+ id: connectButton
+
+ property bool connecting: false
+ property bool hasError: false
+
+ Layout.fillWidth: true
+ Layout.minimumHeight: Appearance.font.size.normal + Appearance.padding.normal * 2
+ inactiveColour: Colours.palette.m3primary
+ inactiveOnColour: Colours.palette.m3onPrimary
+ text: qsTr("Connect")
+ enabled: passwordContainer.passwordBuffer.length > 0 && !connecting
+
+ onClicked: {
+ if (!root.network || connecting) {
+ return;
+ }
+
+ const password = passwordContainer.passwordBuffer;
+ if (!password || password.length === 0) {
+ return;
+ }
+
+ // Clear any previous error
+ hasError = false;
+
+ // Set connecting state
+ connecting = true;
+ enabled = false;
+ text = qsTr("Connecting...");
+
+ // Connect to network
+ NetworkConnection.connectWithPassword(root.network, password, result => {
+ if (result && result.success)
+ // Connection successful, monitor will handle the rest
+ {} else if (result && result.needsPassword) {
+ // Shouldn't happen since we provided password
+ connectionMonitor.stop();
+ connecting = false;
+ hasError = true;
+ enabled = true;
+ text = qsTr("Connect");
+ passwordContainer.passwordBuffer = "";
+ // Delete the failed connection
+ if (root.network && root.network.ssid) {
+ Nmcli.forgetNetwork(root.network.ssid);
+ }
+ } else {
+ // Connection failed immediately - show error
+ connectionMonitor.stop();
+ connecting = false;
+ hasError = true;
+ enabled = true;
+ text = qsTr("Connect");
+ passwordContainer.passwordBuffer = "";
+ // Delete the failed connection
+ if (root.network && root.network.ssid) {
+ Nmcli.forgetNetwork(root.network.ssid);
+ }
+ }
+ });
+
+ // Start monitoring connection
+ connectionMonitor.start();
+ }
+ }
+ }
+ }
+ }
+
+ function checkConnectionStatus(): void {
+ if (!root.shouldBeVisible || !connectButton.connecting) {
+ return;
+ }
+
+ // Check if we're connected to the target network (case-insensitive SSID comparison)
+ const isConnected = root.network && Nmcli.active && Nmcli.active.ssid && Nmcli.active.ssid.toLowerCase().trim() === root.network.ssid.toLowerCase().trim();
+
+ if (isConnected) {
+ // Successfully connected - give it a moment for network list to update
+ // Use Timer for actual delay
+ connectionSuccessTimer.start();
+ return;
+ }
+
+ // Check for connection failures - if pending connection was cleared but we're not connected
+ if (Nmcli.pendingConnection === null && connectButton.connecting) {
+ // Wait a bit more before giving up (allow time for connection to establish)
+ if (connectionMonitor.repeatCount > 10) {
+ connectionMonitor.stop();
+ connectButton.connecting = false;
+ connectButton.hasError = true;
+ connectButton.enabled = true;
+ connectButton.text = qsTr("Connect");
+ passwordContainer.passwordBuffer = "";
+ // Delete the failed connection
+ if (root.network && root.network.ssid) {
+ Nmcli.forgetNetwork(root.network.ssid);
+ }
+ }
+ }
+ }
+
+ Timer {
+ id: connectionMonitor
+ interval: 1000
+ repeat: true
+ triggeredOnStart: false
+ property int repeatCount: 0
+
+ onTriggered: {
+ repeatCount++;
+ root.checkConnectionStatus();
+ }
+
+ onRunningChanged: {
+ if (!running) {
+ repeatCount = 0;
+ }
+ }
+ }
+
+ Timer {
+ id: connectionSuccessTimer
+ interval: 500
+ onTriggered: {
+ // Double-check connection is still active
+ if (root.shouldBeVisible && Nmcli.active && Nmcli.active.ssid) {
+ const stillConnected = Nmcli.active.ssid.toLowerCase().trim() === root.network.ssid.toLowerCase().trim();
+ if (stillConnected) {
+ connectionMonitor.stop();
+ connectButton.connecting = false;
+ connectButton.text = qsTr("Connect");
+ // Return to network popout on successful connection
+ if (root.wrapper.currentName === "wirelesspassword") {
+ root.wrapper.currentName = "network";
+ }
+ closeDialog();
+ }
+ }
+ }
+ }
+
+ Connections {
+ target: Nmcli
+ function onActiveChanged() {
+ if (root.shouldBeVisible) {
+ root.checkConnectionStatus();
+ }
+ }
+ function onConnectionFailed(ssid: string) {
+ if (root.shouldBeVisible && root.network && root.network.ssid === ssid && connectButton.connecting) {
+ connectionMonitor.stop();
+ connectButton.connecting = false;
+ connectButton.hasError = true;
+ connectButton.enabled = true;
+ connectButton.text = qsTr("Connect");
+ passwordContainer.passwordBuffer = "";
+ // Delete the failed connection
+ Nmcli.forgetNetwork(ssid);
+ }
+ }
+ }
+
+ function closeDialog(): void {
+ if (isClosing) {
+ return;
+ }
+
+ isClosing = true;
+ passwordContainer.passwordBuffer = "";
+ connectButton.connecting = false;
+ connectButton.hasError = false;
+ connectButton.text = qsTr("Connect");
+ connectionMonitor.stop();
+
+ // Return to network popout
+ if (root.wrapper.currentName === "wirelesspassword") {
+ root.wrapper.currentName = "network";
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/bar/popouts/Wrapper.qml b/.config/quickshell/caelestia/modules/bar/popouts/Wrapper.qml
new file mode 100644
index 0000000..05a1d3c
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/bar/popouts/Wrapper.qml
@@ -0,0 +1,215 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.services
+import qs.config
+import qs.modules.windowinfo
+import qs.modules.controlcenter
+import Quickshell
+import Quickshell.Wayland
+import Quickshell.Hyprland
+import QtQuick
+
+Item {
+ id: root
+
+ required property ShellScreen screen
+
+ readonly property real nonAnimWidth: x > 0 || hasCurrent ? children.find(c => c.shouldBeActive)?.implicitWidth ?? content.implicitWidth : 0
+ readonly property real nonAnimHeight: children.find(c => c.shouldBeActive)?.implicitHeight ?? content.implicitHeight
+ readonly property Item current: content.item?.current ?? null
+
+ property string currentName
+ property real currentCenter
+ property bool hasCurrent
+
+ property string detachedMode
+ property string queuedMode
+ readonly property bool isDetached: detachedMode.length > 0
+
+ property int animLength: Appearance.anim.durations.normal
+ property list animCurve: Appearance.anim.curves.emphasized
+
+ function detach(mode: string): void {
+ animLength = Appearance.anim.durations.large;
+ if (mode === "winfo") {
+ detachedMode = mode;
+ } else {
+ queuedMode = mode;
+ detachedMode = "any";
+ }
+ focus = true;
+ }
+
+ function close(): void {
+ hasCurrent = false;
+ animCurve = Appearance.anim.curves.emphasizedAccel;
+ animLength = Appearance.anim.durations.normal;
+ detachedMode = "";
+ animCurve = Appearance.anim.curves.emphasized;
+ }
+
+ visible: width > 0 && height > 0
+ clip: true
+
+ implicitWidth: nonAnimWidth
+ implicitHeight: nonAnimHeight
+
+ focus: hasCurrent
+ Keys.onEscapePressed: {
+ // Forward escape to password popout if active, otherwise close
+ if (currentName === "wirelesspassword" && content.item) {
+ const passwordPopout = content.item.children.find(c => c.name === "wirelesspassword");
+ if (passwordPopout && passwordPopout.item) {
+ passwordPopout.item.closeDialog();
+ return;
+ }
+ }
+ close();
+ }
+
+ Keys.onPressed: event => {
+ // Don't intercept keys when password popout is active - let it handle them
+ if (currentName === "wirelesspassword") {
+ event.accepted = false;
+ }
+ }
+
+ HyprlandFocusGrab {
+ active: root.isDetached
+ windows: [QsWindow.window]
+ onCleared: root.close()
+ }
+
+ Binding {
+ when: root.isDetached
+
+ target: QsWindow.window
+ property: "WlrLayershell.keyboardFocus"
+ value: WlrKeyboardFocus.OnDemand
+ }
+
+ Binding {
+ when: root.hasCurrent && root.currentName === "wirelesspassword"
+
+ target: QsWindow.window
+ property: "WlrLayershell.keyboardFocus"
+ value: WlrKeyboardFocus.OnDemand
+ }
+
+ Comp {
+ id: content
+
+ shouldBeActive: root.hasCurrent && !root.detachedMode
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+
+ sourceComponent: Content {
+ wrapper: root
+ }
+ }
+
+ Comp {
+ shouldBeActive: root.detachedMode === "winfo"
+ anchors.centerIn: parent
+
+ sourceComponent: WindowInfo {
+ screen: root.screen
+ client: Hypr.activeToplevel
+ }
+ }
+
+ Comp {
+ shouldBeActive: root.detachedMode === "any"
+ anchors.centerIn: parent
+
+ sourceComponent: ControlCenter {
+ screen: root.screen
+ active: root.queuedMode
+
+ function close(): void {
+ root.close();
+ }
+ }
+ }
+
+ Behavior on x {
+ Anim {
+ duration: root.animLength
+ easing.bezierCurve: root.animCurve
+ }
+ }
+
+ Behavior on y {
+ enabled: root.implicitWidth > 0
+
+ Anim {
+ duration: root.animLength
+ easing.bezierCurve: root.animCurve
+ }
+ }
+
+ Behavior on implicitWidth {
+ Anim {
+ duration: root.animLength
+ easing.bezierCurve: root.animCurve
+ }
+ }
+
+ Behavior on implicitHeight {
+ enabled: root.implicitWidth > 0
+
+ Anim {
+ duration: root.animLength
+ easing.bezierCurve: root.animCurve
+ }
+ }
+
+ component Comp: Loader {
+ id: comp
+
+ property bool shouldBeActive
+
+ active: false
+ opacity: 0
+
+ states: State {
+ name: "active"
+ when: comp.shouldBeActive
+
+ PropertyChanges {
+ comp.opacity: 1
+ comp.active: true
+ }
+ }
+
+ transitions: [
+ Transition {
+ from: ""
+ to: "active"
+
+ SequentialAnimation {
+ PropertyAction {
+ property: "active"
+ }
+ Anim {
+ property: "opacity"
+ }
+ }
+ },
+ Transition {
+ from: "active"
+ to: ""
+
+ SequentialAnimation {
+ Anim {
+ property: "opacity"
+ }
+ PropertyAction {
+ property: "active"
+ }
+ }
+ }
+ ]
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/bar/popouts/kblayout/KbLayout.qml b/.config/quickshell/caelestia/modules/bar/popouts/kblayout/KbLayout.qml
new file mode 100644
index 0000000..94b6f7e
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/bar/popouts/kblayout/KbLayout.qml
@@ -0,0 +1,211 @@
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import qs.components
+import qs.components.controls
+import qs.services
+import qs.config
+import qs.utils
+
+import "."
+
+ColumnLayout {
+ id: root
+
+ required property Item wrapper
+
+ spacing: Appearance.spacing.small
+ width: Config.bar.sizes.kbLayoutWidth
+
+ KbLayoutModel {
+ id: kb
+ }
+
+ function refresh() {
+ kb.refresh();
+ }
+ Component.onCompleted: kb.start()
+
+ StyledText {
+ Layout.topMargin: Appearance.padding.normal
+ Layout.rightMargin: Appearance.padding.small
+ text: qsTr("Keyboard Layouts")
+ font.weight: 500
+ }
+
+ ListView {
+ id: list
+ model: kb.visibleModel
+
+ Layout.fillWidth: true
+ Layout.rightMargin: Appearance.padding.small
+ Layout.topMargin: Appearance.spacing.small
+
+ clip: true
+ interactive: true
+ implicitHeight: Math.min(contentHeight, 320)
+ visible: kb.visibleModel.count > 0
+ spacing: Appearance.spacing.small
+
+ add: Transition {
+ NumberAnimation {
+ properties: "opacity"
+ from: 0
+ to: 1
+ duration: 140
+ }
+ NumberAnimation {
+ properties: "y"
+ duration: 180
+ easing.type: Easing.OutCubic
+ }
+ }
+ remove: Transition {
+ NumberAnimation {
+ properties: "opacity"
+ to: 0
+ duration: 100
+ }
+ }
+ move: Transition {
+ NumberAnimation {
+ properties: "y"
+ duration: 180
+ easing.type: Easing.OutCubic
+ }
+ }
+ displaced: Transition {
+ NumberAnimation {
+ properties: "y"
+ duration: 180
+ easing.type: Easing.OutCubic
+ }
+ }
+
+ delegate: Item {
+ required property int layoutIndex
+ required property string label
+
+ width: list.width
+ height: Math.max(36, rowText.implicitHeight + Appearance.padding.small * 2)
+
+ readonly property bool isDisabled: layoutIndex > 3
+
+ StateLayer {
+ id: layer
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ implicitHeight: parent.height - 4
+
+ radius: Appearance.rounding.full
+ enabled: !isDisabled
+
+ function onClicked(): void {
+ if (!isDisabled)
+ kb.switchTo(layoutIndex);
+ }
+ }
+
+ StyledText {
+ id: rowText
+ anchors.verticalCenter: layer.verticalCenter
+ anchors.left: layer.left
+ anchors.right: layer.right
+ anchors.leftMargin: Appearance.padding.small
+ anchors.rightMargin: Appearance.padding.small
+ text: label
+ elide: Text.ElideRight
+ opacity: isDisabled ? 0.4 : 1.0
+ }
+
+ ToolTip.visible: isDisabled && layer.containsMouse
+ ToolTip.text: "XKB limitation: maximum 4 layouts allowed"
+ }
+ }
+
+ Rectangle {
+ visible: kb.activeLabel.length > 0
+ Layout.fillWidth: true
+ Layout.rightMargin: Appearance.padding.small
+ Layout.topMargin: Appearance.spacing.small
+
+ height: 1
+ color: Colours.palette.m3onSurfaceVariant
+ opacity: 0.35
+ }
+
+ RowLayout {
+ id: activeRow
+
+ visible: kb.activeLabel.length > 0
+ Layout.fillWidth: true
+ Layout.rightMargin: Appearance.padding.small
+ Layout.topMargin: Appearance.spacing.small
+ spacing: Appearance.spacing.small
+
+ opacity: 1
+ scale: 1
+
+ MaterialIcon {
+ text: "keyboard"
+ color: Colours.palette.m3primary
+ }
+
+ StyledText {
+ Layout.fillWidth: true
+ text: kb.activeLabel
+ elide: Text.ElideRight
+ font.weight: 500
+ color: Colours.palette.m3primary
+ }
+
+ Connections {
+ target: kb
+ function onActiveLabelChanged() {
+ if (!activeRow.visible)
+ return;
+ popIn.restart();
+ }
+ }
+
+ SequentialAnimation {
+ id: popIn
+ running: false
+
+ ParallelAnimation {
+ NumberAnimation {
+ target: activeRow
+ property: "opacity"
+ to: 0.0
+ duration: 70
+ }
+ NumberAnimation {
+ target: activeRow
+ property: "scale"
+ to: 0.92
+ duration: 70
+ }
+ }
+
+ ParallelAnimation {
+ NumberAnimation {
+ target: activeRow
+ property: "opacity"
+ to: 1.0
+ duration: 160
+ easing.type: Easing.OutCubic
+ }
+ NumberAnimation {
+ target: activeRow
+ property: "scale"
+ to: 1.0
+ duration: 220
+ easing.type: Easing.OutBack
+ }
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/bar/popouts/kblayout/KbLayoutModel.qml b/.config/quickshell/caelestia/modules/bar/popouts/kblayout/KbLayoutModel.qml
new file mode 100644
index 0000000..4371095
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/bar/popouts/kblayout/KbLayoutModel.qml
@@ -0,0 +1,216 @@
+pragma ComponentBehavior: Bound
+
+import QtQuick
+
+import Quickshell
+import Quickshell.Io
+
+import qs.config
+import Caelestia
+
+Item {
+ id: model
+ visible: false
+
+ ListModel {
+ id: _visibleModel
+ }
+ property alias visibleModel: _visibleModel
+
+ property string activeLabel: ""
+ property int activeIndex: -1
+
+ function start() {
+ _xkbXmlBase.running = true;
+ _getKbLayoutOpt.running = true;
+ }
+
+ function refresh() {
+ _notifiedLimit = false;
+ _getKbLayoutOpt.running = true;
+ }
+
+ function switchTo(idx) {
+ _switchProc.command = ["hyprctl", "switchxkblayout", "all", String(idx)];
+ _switchProc.running = true;
+ }
+
+ ListModel {
+ id: _layoutsModel
+ }
+
+ property var _xkbMap: ({})
+ property bool _notifiedLimit: false
+
+ Process {
+ id: _xkbXmlBase
+ command: ["xmllint", "--xpath", "//layout/configItem[name and description]", "/usr/share/X11/xkb/rules/base.xml"]
+ stdout: StdioCollector {
+ onStreamFinished: _buildXmlMap(text)
+ }
+ onRunningChanged: if (!running && (typeof exitCode !== "undefined") && exitCode !== 0)
+ _xkbXmlEvdev.running = true
+ }
+
+ Process {
+ id: _xkbXmlEvdev
+ command: ["xmllint", "--xpath", "//layout/configItem[name and description]", "/usr/share/X11/xkb/rules/evdev.xml"]
+ stdout: StdioCollector {
+ onStreamFinished: _buildXmlMap(text)
+ }
+ }
+
+ function _buildXmlMap(xml) {
+ const map = {};
+
+ const re = /\s*([^<]+?)\s*<\/name>[\s\S]*?\s*([^<]+?)\s*<\/description>/g;
+
+ let m;
+ while ((m = re.exec(xml)) !== null) {
+ const code = (m[1] || "").trim();
+ const desc = (m[2] || "").trim();
+ if (!code || !desc)
+ continue;
+ map[code] = _short(desc);
+ }
+
+ if (Object.keys(map).length === 0)
+ return;
+
+ _xkbMap = map;
+
+ if (_layoutsModel.count > 0) {
+ const tmp = [];
+ for (let i = 0; i < _layoutsModel.count; i++) {
+ const it = _layoutsModel.get(i);
+ tmp.push({
+ layoutIndex: it.layoutIndex,
+ token: it.token,
+ label: _pretty(it.token)
+ });
+ }
+ _layoutsModel.clear();
+ tmp.forEach(t => _layoutsModel.append(t));
+ _fetchActiveLayouts.running = true;
+ }
+ }
+
+ function _short(desc) {
+ const m = desc.match(/^(.*)\((.*)\)$/);
+ if (!m)
+ return desc;
+ const lang = m[1].trim();
+ const region = m[2].trim();
+ const code = (region.split(/[,\s-]/)[0] || region).slice(0, 2).toUpperCase();
+ return `${lang} (${code})`;
+ }
+
+ Process {
+ id: _getKbLayoutOpt
+ command: ["hyprctl", "-j", "getoption", "input:kb_layout"]
+ stdout: StdioCollector {
+ onStreamFinished: {
+ try {
+ const j = JSON.parse(text);
+ const raw = (j?.str || j?.value || "").toString().trim();
+ if (raw.length) {
+ _setLayouts(raw);
+ _fetchActiveLayouts.running = true;
+ return;
+ }
+ } catch (e) {}
+ _fetchLayoutsFromDevices.running = true;
+ }
+ }
+ }
+
+ Process {
+ id: _fetchLayoutsFromDevices
+ command: ["hyprctl", "-j", "devices"]
+ stdout: StdioCollector {
+ onStreamFinished: {
+ try {
+ const dev = JSON.parse(text);
+ const kb = dev?.keyboards?.find(k => k.main) || dev?.keyboards?.[0];
+ const raw = (kb?.layout || "").trim();
+ if (raw.length)
+ _setLayouts(raw);
+ } catch (e) {}
+ _fetchActiveLayouts.running = true;
+ }
+ }
+ }
+
+ Process {
+ id: _fetchActiveLayouts
+ command: ["hyprctl", "-j", "devices"]
+ stdout: StdioCollector {
+ onStreamFinished: {
+ try {
+ const dev = JSON.parse(text);
+ const kb = dev?.keyboards?.find(k => k.main) || dev?.keyboards?.[0];
+ const idx = kb?.active_layout_index ?? -1;
+
+ activeIndex = idx >= 0 ? idx : -1;
+ activeLabel = (idx >= 0 && idx < _layoutsModel.count) ? _layoutsModel.get(idx).label : "";
+ } catch (e) {
+ activeIndex = -1;
+ activeLabel = "";
+ }
+
+ _rebuildVisible();
+ }
+ }
+ }
+
+ Process {
+ id: _switchProc
+ onRunningChanged: if (!running)
+ _fetchActiveLayouts.running = true
+ }
+
+ function _setLayouts(raw) {
+ const parts = raw.split(",").map(s => s.trim()).filter(Boolean);
+ _layoutsModel.clear();
+
+ const seen = new Set();
+ let idx = 0;
+
+ for (const p of parts) {
+ if (seen.has(p))
+ continue;
+ seen.add(p);
+ _layoutsModel.append({
+ layoutIndex: idx,
+ token: p,
+ label: _pretty(p)
+ });
+ idx++;
+ }
+ }
+
+ function _rebuildVisible() {
+ _visibleModel.clear();
+
+ let arr = [];
+ for (let i = 0; i < _layoutsModel.count; i++)
+ arr.push(_layoutsModel.get(i));
+
+ arr = arr.filter(i => i.layoutIndex !== activeIndex);
+ arr.forEach(i => _visibleModel.append(i));
+
+ if (!Config.utilities.toasts.kbLimit)
+ return;
+
+ if (_layoutsModel.count > 4) {
+ Toaster.toast(qsTr("Keyboard layout limit"), qsTr("XKB supports only 4 layouts at a time"), "warning");
+ }
+ }
+
+ function _pretty(token) {
+ const code = token.replace(/\(.*\)$/, "").trim();
+ if (_xkbMap[code])
+ return code.toUpperCase() + " - " + _xkbMap[code];
+ return code.toUpperCase() + " - " + code;
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/ControlCenter.qml b/.config/quickshell/caelestia/modules/controlcenter/ControlCenter.qml
new file mode 100644
index 0000000..4aacfad
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/ControlCenter.qml
@@ -0,0 +1,100 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.components.controls
+import qs.services
+import qs.config
+import Quickshell
+import QtQuick
+import QtQuick.Layouts
+
+Item {
+ id: root
+
+ required property ShellScreen screen
+ readonly property int rounding: floating ? 0 : Appearance.rounding.normal
+
+ property alias floating: session.floating
+ property alias active: session.active
+ property alias navExpanded: session.navExpanded
+
+ readonly property Session session: Session {
+ id: session
+
+ root: root
+ }
+
+ function close(): void {
+ }
+
+ implicitWidth: implicitHeight * Config.controlCenter.sizes.ratio
+ implicitHeight: screen.height * Config.controlCenter.sizes.heightMult
+
+ GridLayout {
+ anchors.fill: parent
+
+ rowSpacing: 0
+ columnSpacing: 0
+ rows: root.floating ? 2 : 1
+ columns: 2
+
+ Loader {
+ Layout.fillWidth: true
+ Layout.columnSpan: 2
+
+ active: root.floating
+ visible: active
+
+ sourceComponent: WindowTitle {
+ screen: root.screen
+ session: root.session
+ }
+ }
+
+ StyledRect {
+ Layout.fillHeight: true
+
+ topLeftRadius: root.rounding
+ bottomLeftRadius: root.rounding
+ implicitWidth: navRail.implicitWidth
+ color: Colours.tPalette.m3surfaceContainer
+
+ CustomMouseArea {
+ anchors.fill: parent
+
+ function onWheel(event: WheelEvent): void {
+ // Prevent tab switching during initial opening animation to avoid blank pages
+ if (!panes.initialOpeningComplete) {
+ return;
+ }
+
+ if (event.angleDelta.y < 0)
+ root.session.activeIndex = Math.min(root.session.activeIndex + 1, root.session.panes.length - 1);
+ else if (event.angleDelta.y > 0)
+ root.session.activeIndex = Math.max(root.session.activeIndex - 1, 0);
+ }
+ }
+
+ NavRail {
+ id: navRail
+
+ screen: root.screen
+ session: root.session
+ initialOpeningComplete: root.initialOpeningComplete
+ }
+ }
+
+ Panes {
+ id: panes
+
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+
+ topRightRadius: root.rounding
+ bottomRightRadius: root.rounding
+ session: root.session
+ }
+ }
+
+ readonly property bool initialOpeningComplete: panes.initialOpeningComplete
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/NavRail.qml b/.config/quickshell/caelestia/modules/controlcenter/NavRail.qml
new file mode 100644
index 0000000..e61a741
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/NavRail.qml
@@ -0,0 +1,231 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.services
+import qs.config
+import qs.modules.controlcenter
+import Quickshell
+import QtQuick
+import QtQuick.Layouts
+
+Item {
+ id: root
+
+ required property ShellScreen screen
+ required property Session session
+ required property bool initialOpeningComplete
+
+ implicitWidth: layout.implicitWidth + Appearance.padding.larger * 4
+ implicitHeight: layout.implicitHeight + Appearance.padding.large * 2
+
+ ColumnLayout {
+ id: layout
+
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.leftMargin: Appearance.padding.larger * 2
+ spacing: Appearance.spacing.normal
+
+ states: State {
+ name: "expanded"
+ when: root.session.navExpanded
+
+ PropertyChanges {
+ layout.spacing: Appearance.spacing.small
+ }
+ }
+
+ transitions: Transition {
+ Anim {
+ properties: "spacing"
+ }
+ }
+
+ Loader {
+ Layout.topMargin: Appearance.spacing.large
+ active: !root.session.floating
+ visible: active
+
+ sourceComponent: StyledRect {
+ readonly property int nonAnimWidth: normalWinIcon.implicitWidth + (root.session.navExpanded ? normalWinLabel.anchors.leftMargin + normalWinLabel.implicitWidth : 0) + normalWinIcon.anchors.leftMargin * 2
+
+ implicitWidth: nonAnimWidth
+ implicitHeight: root.session.navExpanded ? normalWinIcon.implicitHeight + Appearance.padding.normal * 2 : nonAnimWidth
+
+ color: Colours.palette.m3primaryContainer
+ radius: Appearance.rounding.small
+
+ StateLayer {
+ id: normalWinState
+
+ color: Colours.palette.m3onPrimaryContainer
+
+ function onClicked(): void {
+ root.session.root.close();
+ WindowFactory.create(null, {
+ active: root.session.active,
+ navExpanded: root.session.navExpanded
+ });
+ }
+ }
+
+ MaterialIcon {
+ id: normalWinIcon
+
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.leftMargin: Appearance.padding.large
+
+ text: "select_window"
+ color: Colours.palette.m3onPrimaryContainer
+ font.pointSize: Appearance.font.size.large
+ fill: 1
+ }
+
+ StyledText {
+ id: normalWinLabel
+
+ anchors.left: normalWinIcon.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.leftMargin: Appearance.spacing.normal
+
+ text: qsTr("Float window")
+ color: Colours.palette.m3onPrimaryContainer
+ opacity: root.session.navExpanded ? 1 : 0
+
+ Behavior on opacity {
+ Anim {
+ duration: Appearance.anim.durations.small
+ }
+ }
+ }
+
+ Behavior on implicitWidth {
+ Anim {
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ }
+
+ Behavior on implicitHeight {
+ Anim {
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ }
+ }
+ }
+
+ Repeater {
+ model: PaneRegistry.count
+
+ NavItem {
+ required property int index
+ Layout.topMargin: index === 0 ? Appearance.spacing.large * 2 : 0
+ icon: PaneRegistry.getByIndex(index).icon
+ label: PaneRegistry.getByIndex(index).label
+ }
+ }
+ }
+
+ component NavItem: Item {
+ id: item
+
+ required property string icon
+ required property string label
+ readonly property bool active: root.session.active === label
+
+ implicitWidth: background.implicitWidth
+ implicitHeight: background.implicitHeight + smallLabel.implicitHeight + smallLabel.anchors.topMargin
+
+ states: State {
+ name: "expanded"
+ when: root.session.navExpanded
+
+ PropertyChanges {
+ expandedLabel.opacity: 1
+ smallLabel.opacity: 0
+ background.implicitWidth: icon.implicitWidth + icon.anchors.leftMargin * 2 + expandedLabel.anchors.leftMargin + expandedLabel.implicitWidth
+ background.implicitHeight: icon.implicitHeight + Appearance.padding.normal * 2
+ item.implicitHeight: background.implicitHeight
+ }
+ }
+
+ transitions: Transition {
+ Anim {
+ property: "opacity"
+ duration: Appearance.anim.durations.small
+ }
+
+ Anim {
+ properties: "implicitWidth,implicitHeight"
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ }
+
+ StyledRect {
+ id: background
+
+ radius: Appearance.rounding.full
+ color: Qt.alpha(Colours.palette.m3secondaryContainer, item.active ? 1 : 0)
+
+ implicitWidth: icon.implicitWidth + icon.anchors.leftMargin * 2
+ implicitHeight: icon.implicitHeight + Appearance.padding.small
+
+ StateLayer {
+ color: item.active ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface
+
+ function onClicked(): void {
+ // Prevent tab switching during initial opening animation to avoid blank pages
+ if (!root.initialOpeningComplete) {
+ return;
+ }
+ root.session.active = item.label;
+ }
+ }
+
+ MaterialIcon {
+ id: icon
+
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.leftMargin: Appearance.padding.large
+
+ text: item.icon
+ color: item.active ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface
+ font.pointSize: Appearance.font.size.large
+ fill: item.active ? 1 : 0
+
+ Behavior on fill {
+ Anim {}
+ }
+ }
+
+ StyledText {
+ id: expandedLabel
+
+ anchors.left: icon.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.leftMargin: Appearance.spacing.normal
+
+ opacity: 0
+ text: item.label
+ color: item.active ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface
+ font.capitalization: Font.Capitalize
+ }
+
+ StyledText {
+ id: smallLabel
+
+ anchors.horizontalCenter: icon.horizontalCenter
+ anchors.top: icon.bottom
+ anchors.topMargin: Appearance.spacing.small / 2
+
+ text: item.label
+ font.pointSize: Appearance.font.size.small
+ font.capitalization: Font.Capitalize
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/PaneRegistry.qml b/.config/quickshell/caelestia/modules/controlcenter/PaneRegistry.qml
new file mode 100644
index 0000000..ca48551
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/PaneRegistry.qml
@@ -0,0 +1,92 @@
+pragma Singleton
+
+import QtQuick
+
+QtObject {
+ id: root
+
+ readonly property list panes: [
+ QtObject {
+ readonly property string id: "network"
+ readonly property string label: "network"
+ readonly property string icon: "router"
+ readonly property string component: "network/NetworkingPane.qml"
+ },
+ QtObject {
+ readonly property string id: "bluetooth"
+ readonly property string label: "bluetooth"
+ readonly property string icon: "settings_bluetooth"
+ readonly property string component: "bluetooth/BtPane.qml"
+ },
+ QtObject {
+ readonly property string id: "audio"
+ readonly property string label: "audio"
+ readonly property string icon: "volume_up"
+ readonly property string component: "audio/AudioPane.qml"
+ },
+ QtObject {
+ readonly property string id: "appearance"
+ readonly property string label: "appearance"
+ readonly property string icon: "palette"
+ readonly property string component: "appearance/AppearancePane.qml"
+ },
+ QtObject {
+ readonly property string id: "taskbar"
+ readonly property string label: "taskbar"
+ readonly property string icon: "task_alt"
+ readonly property string component: "taskbar/TaskbarPane.qml"
+ },
+ QtObject {
+ readonly property string id: "launcher"
+ readonly property string label: "launcher"
+ readonly property string icon: "apps"
+ readonly property string component: "launcher/LauncherPane.qml"
+ },
+ QtObject {
+ readonly property string id: "dashboard"
+ readonly property string label: "dashboard"
+ readonly property string icon: "dashboard"
+ readonly property string component: "dashboard/DashboardPane.qml"
+ }
+ ]
+
+ readonly property int count: panes.length
+
+ readonly property var labels: {
+ const result = [];
+ for (let i = 0; i < panes.length; i++) {
+ result.push(panes[i].label);
+ }
+ return result;
+ }
+
+ function getByIndex(index: int): QtObject {
+ if (index >= 0 && index < panes.length) {
+ return panes[index];
+ }
+ return null;
+ }
+
+ function getIndexByLabel(label: string): int {
+ for (let i = 0; i < panes.length; i++) {
+ if (panes[i].label === label) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ function getByLabel(label: string): QtObject {
+ const index = getIndexByLabel(label);
+ return getByIndex(index);
+ }
+
+ function getById(id: string): QtObject {
+ for (let i = 0; i < panes.length; i++) {
+ if (panes[i].id === id) {
+ return panes[i];
+ }
+ }
+ return null;
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/Panes.qml b/.config/quickshell/caelestia/modules/controlcenter/Panes.qml
new file mode 100644
index 0000000..ab2f808
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/Panes.qml
@@ -0,0 +1,175 @@
+pragma ComponentBehavior: Bound
+
+import "bluetooth"
+import "network"
+import "audio"
+import "appearance"
+import "taskbar"
+import "launcher"
+import "dashboard"
+import qs.components
+import qs.services
+import qs.config
+import qs.modules.controlcenter
+import Quickshell.Widgets
+import QtQuick
+import QtQuick.Layouts
+
+ClippingRectangle {
+ id: root
+
+ required property Session session
+
+ readonly property bool initialOpeningComplete: layout.initialOpeningComplete
+
+ color: "transparent"
+ clip: true
+ focus: false
+ activeFocusOnTab: false
+
+ MouseArea {
+ anchors.fill: parent
+ z: -1
+ onPressed: function (mouse) {
+ root.focus = true;
+ mouse.accepted = false;
+ }
+ }
+
+ Connections {
+ target: root.session
+
+ function onActiveIndexChanged(): void {
+ root.focus = true;
+ }
+ }
+
+ ColumnLayout {
+ id: layout
+
+ spacing: 0
+ y: -root.session.activeIndex * root.height
+ clip: true
+
+ property bool animationComplete: true
+ property bool initialOpeningComplete: false
+
+ Timer {
+ id: animationDelayTimer
+ interval: Appearance.anim.durations.normal
+ onTriggered: {
+ layout.animationComplete = true;
+ }
+ }
+
+ Timer {
+ id: initialOpeningTimer
+ interval: Appearance.anim.durations.large
+ running: true
+ onTriggered: {
+ layout.initialOpeningComplete = true;
+ }
+ }
+
+ Repeater {
+ model: PaneRegistry.count
+
+ Pane {
+ required property int index
+ paneIndex: index
+ componentPath: PaneRegistry.getByIndex(index).component
+ }
+ }
+
+ Behavior on y {
+ Anim {}
+ }
+
+ Connections {
+ target: root.session
+ function onActiveIndexChanged(): void {
+ layout.animationComplete = false;
+ animationDelayTimer.restart();
+ }
+ }
+ }
+
+ component Pane: Item {
+ id: pane
+
+ required property int paneIndex
+ required property string componentPath
+
+ implicitWidth: root.width
+ implicitHeight: root.height
+
+ property bool hasBeenLoaded: false
+
+ function updateActive(): void {
+ const diff = Math.abs(root.session.activeIndex - pane.paneIndex);
+ const isActivePane = diff === 0;
+ let shouldBeActive = false;
+
+ if (!layout.initialOpeningComplete) {
+ shouldBeActive = isActivePane;
+ } else {
+ if (diff <= 1) {
+ shouldBeActive = true;
+ } else if (pane.hasBeenLoaded) {
+ shouldBeActive = true;
+ } else {
+ shouldBeActive = layout.animationComplete;
+ }
+ }
+
+ loader.active = shouldBeActive;
+ }
+
+ Loader {
+ id: loader
+
+ anchors.fill: parent
+ clip: false
+ active: false
+
+ Component.onCompleted: {
+ Qt.callLater(pane.updateActive);
+ }
+
+ onActiveChanged: {
+ if (active && !pane.hasBeenLoaded) {
+ pane.hasBeenLoaded = true;
+ }
+
+ if (active && !item) {
+ loader.setSource(pane.componentPath, {
+ "session": root.session
+ });
+ }
+ }
+
+ onItemChanged: {
+ if (item) {
+ pane.hasBeenLoaded = true;
+ }
+ }
+ }
+
+ Connections {
+ target: root.session
+ function onActiveIndexChanged(): void {
+ pane.updateActive();
+ }
+ }
+
+ Connections {
+ target: layout
+ function onInitialOpeningCompleteChanged(): void {
+ pane.updateActive();
+ }
+ function onAnimationCompleteChanged(): void {
+ pane.updateActive();
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/Session.qml b/.config/quickshell/caelestia/modules/controlcenter/Session.qml
new file mode 100644
index 0000000..8a8545f
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/Session.qml
@@ -0,0 +1,23 @@
+import QtQuick
+import "./state"
+import qs.modules.controlcenter
+
+QtObject {
+ readonly property list panes: PaneRegistry.labels
+
+ required property var root
+ property bool floating: false
+ property string active: "network"
+ property int activeIndex: 0
+ property bool navExpanded: false
+
+ readonly property BluetoothState bt: BluetoothState {}
+ readonly property NetworkState network: NetworkState {}
+ readonly property EthernetState ethernet: EthernetState {}
+ readonly property LauncherState launcher: LauncherState {}
+ readonly property VpnState vpn: VpnState {}
+
+ onActiveChanged: activeIndex = Math.max(0, panes.indexOf(active))
+ onActiveIndexChanged: if (panes[activeIndex])
+ active = panes[activeIndex]
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/WindowFactory.qml b/.config/quickshell/caelestia/modules/controlcenter/WindowFactory.qml
new file mode 100644
index 0000000..abcf5df
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/WindowFactory.qml
@@ -0,0 +1,62 @@
+pragma Singleton
+
+import qs.components
+import qs.services
+import Quickshell
+import QtQuick
+
+Singleton {
+ id: root
+
+ function create(parent: Item, props: var): void {
+ controlCenter.createObject(parent ?? dummy, props);
+ }
+
+ QtObject {
+ id: dummy
+ }
+
+ Component {
+ id: controlCenter
+
+ FloatingWindow {
+ id: win
+
+ property alias active: cc.active
+ property alias navExpanded: cc.navExpanded
+
+ color: Colours.tPalette.m3surface
+
+ onVisibleChanged: {
+ if (!visible)
+ destroy();
+ }
+
+ implicitWidth: cc.implicitWidth
+ implicitHeight: cc.implicitHeight
+
+ minimumSize.width: implicitWidth
+ minimumSize.height: implicitHeight
+ maximumSize.width: implicitWidth
+ maximumSize.height: implicitHeight
+
+ title: qsTr("Caelestia Settings - %1").arg(cc.active.slice(0, 1).toUpperCase() + cc.active.slice(1))
+
+ ControlCenter {
+ id: cc
+
+ anchors.fill: parent
+ screen: win.screen
+ floating: true
+
+ function close(): void {
+ win.destroy();
+ }
+ }
+
+ Behavior on color {
+ CAnim {}
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/WindowTitle.qml b/.config/quickshell/caelestia/modules/controlcenter/WindowTitle.qml
new file mode 100644
index 0000000..fb71608
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/WindowTitle.qml
@@ -0,0 +1,51 @@
+import qs.components
+import qs.services
+import qs.config
+import Quickshell
+import QtQuick
+
+StyledRect {
+ id: root
+
+ required property ShellScreen screen
+ required property Session session
+
+ implicitHeight: text.implicitHeight + Appearance.padding.normal
+ color: Colours.tPalette.m3surfaceContainer
+
+ StyledText {
+ id: text
+
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.bottom: parent.bottom
+
+ text: qsTr("Caelestia Settings - %1").arg(root.session.active)
+ font.capitalization: Font.Capitalize
+ font.pointSize: Appearance.font.size.larger
+ font.weight: 500
+ }
+
+ Item {
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.margins: Appearance.padding.normal
+
+ implicitWidth: implicitHeight
+ implicitHeight: closeIcon.implicitHeight + Appearance.padding.small
+
+ StateLayer {
+ radius: Appearance.rounding.full
+
+ function onClicked(): void {
+ QsWindow.window.destroy();
+ }
+ }
+
+ MaterialIcon {
+ id: closeIcon
+
+ anchors.centerIn: parent
+ text: "close"
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/appearance/AppearancePane.qml b/.config/quickshell/caelestia/modules/controlcenter/appearance/AppearancePane.qml
new file mode 100644
index 0000000..f29f7ab
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/appearance/AppearancePane.qml
@@ -0,0 +1,254 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../components"
+import "./sections"
+import "../../launcher/services"
+import qs.components
+import qs.components.controls
+import qs.components.effects
+import qs.components.containers
+import qs.components.images
+import qs.services
+import qs.config
+import qs.utils
+import Caelestia.Models
+import Quickshell
+import Quickshell.Widgets
+import QtQuick
+import QtQuick.Layouts
+
+Item {
+ id: root
+
+ required property Session session
+
+ property real animDurationsScale: Config.appearance.anim.durations.scale ?? 1
+ property string fontFamilyMaterial: Config.appearance.font.family.material ?? "Material Symbols Rounded"
+ property string fontFamilyMono: Config.appearance.font.family.mono ?? "CaskaydiaCove NF"
+ property string fontFamilySans: Config.appearance.font.family.sans ?? "Rubik"
+ property real fontSizeScale: Config.appearance.font.size.scale ?? 1
+ property real paddingScale: Config.appearance.padding.scale ?? 1
+ property real roundingScale: Config.appearance.rounding.scale ?? 1
+ property real spacingScale: Config.appearance.spacing.scale ?? 1
+ property bool transparencyEnabled: Config.appearance.transparency.enabled ?? false
+ property real transparencyBase: Config.appearance.transparency.base ?? 0.85
+ property real transparencyLayers: Config.appearance.transparency.layers ?? 0.4
+ property real borderRounding: Config.border.rounding ?? 1
+ property real borderThickness: Config.border.thickness ?? 1
+
+ property bool desktopClockEnabled: Config.background.desktopClock.enabled ?? false
+ property real desktopClockScale: Config.background.desktopClock.scale ?? 1
+ property string desktopClockPosition: Config.background.desktopClock.position ?? "bottom-right"
+ property bool desktopClockShadowEnabled: Config.background.desktopClock.shadow.enabled ?? true
+ property real desktopClockShadowOpacity: Config.background.desktopClock.shadow.opacity ?? 0.7
+ property real desktopClockShadowBlur: Config.background.desktopClock.shadow.blur ?? 0.4
+ property bool desktopClockBackgroundEnabled: Config.background.desktopClock.background.enabled ?? false
+ property real desktopClockBackgroundOpacity: Config.background.desktopClock.background.opacity ?? 0.7
+ property bool desktopClockBackgroundBlur: Config.background.desktopClock.background.blur ?? false
+ property bool desktopClockInvertColors: Config.background.desktopClock.invertColors ?? false
+ property bool backgroundEnabled: Config.background.enabled ?? true
+ property bool wallpaperEnabled: Config.background.wallpaperEnabled ?? true
+ property bool visualiserEnabled: Config.background.visualiser.enabled ?? false
+ property bool visualiserAutoHide: Config.background.visualiser.autoHide ?? true
+ property real visualiserRounding: Config.background.visualiser.rounding ?? 1
+ property real visualiserSpacing: Config.background.visualiser.spacing ?? 1
+
+ anchors.fill: parent
+
+ function saveConfig() {
+ Config.appearance.anim.durations.scale = root.animDurationsScale;
+
+ Config.appearance.font.family.material = root.fontFamilyMaterial;
+ Config.appearance.font.family.mono = root.fontFamilyMono;
+ Config.appearance.font.family.sans = root.fontFamilySans;
+ Config.appearance.font.size.scale = root.fontSizeScale;
+
+ Config.appearance.padding.scale = root.paddingScale;
+ Config.appearance.rounding.scale = root.roundingScale;
+ Config.appearance.spacing.scale = root.spacingScale;
+
+ Config.appearance.transparency.enabled = root.transparencyEnabled;
+ Config.appearance.transparency.base = root.transparencyBase;
+ Config.appearance.transparency.layers = root.transparencyLayers;
+
+ Config.background.desktopClock.enabled = root.desktopClockEnabled;
+ Config.background.enabled = root.backgroundEnabled;
+ Config.background.desktopClock.scale = root.desktopClockScale;
+ Config.background.desktopClock.position = root.desktopClockPosition;
+ Config.background.desktopClock.shadow.enabled = root.desktopClockShadowEnabled;
+ Config.background.desktopClock.shadow.opacity = root.desktopClockShadowOpacity;
+ Config.background.desktopClock.shadow.blur = root.desktopClockShadowBlur;
+ Config.background.desktopClock.background.enabled = root.desktopClockBackgroundEnabled;
+ Config.background.desktopClock.background.opacity = root.desktopClockBackgroundOpacity;
+ Config.background.desktopClock.background.blur = root.desktopClockBackgroundBlur;
+ Config.background.desktopClock.invertColors = root.desktopClockInvertColors;
+
+ Config.background.wallpaperEnabled = root.wallpaperEnabled;
+
+ Config.background.visualiser.enabled = root.visualiserEnabled;
+ Config.background.visualiser.autoHide = root.visualiserAutoHide;
+ Config.background.visualiser.rounding = root.visualiserRounding;
+ Config.background.visualiser.spacing = root.visualiserSpacing;
+
+ Config.border.rounding = root.borderRounding;
+ Config.border.thickness = root.borderThickness;
+
+ Config.save();
+ }
+
+ Component {
+ id: appearanceRightContentComponent
+
+ Item {
+ id: rightAppearanceFlickable
+
+ ColumnLayout {
+ id: contentLayout
+
+ anchors.fill: parent
+ spacing: 0
+
+ StyledText {
+ Layout.alignment: Qt.AlignHCenter
+ Layout.bottomMargin: Appearance.spacing.normal
+ text: qsTr("Wallpaper")
+ font.pointSize: Appearance.font.size.extraLarge
+ font.weight: 600
+ }
+
+ Loader {
+ id: wallpaperLoader
+
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ Layout.bottomMargin: -Appearance.padding.large * 2
+
+ active: {
+ const isActive = root.session.activeIndex === 3;
+ const isAdjacent = Math.abs(root.session.activeIndex - 3) === 1;
+ const splitLayout = root.children[0];
+ const loader = splitLayout && splitLayout.rightLoader ? splitLayout.rightLoader : null;
+ const shouldActivate = loader && loader.item !== null && (isActive || isAdjacent);
+ return shouldActivate;
+ }
+
+ onStatusChanged: {
+ if (status === Loader.Error) {
+ console.error("[AppearancePane] Wallpaper loader error!");
+ }
+ }
+
+ sourceComponent: WallpaperGrid {
+ session: root.session
+ }
+ }
+ }
+ }
+ }
+
+ SplitPaneLayout {
+ anchors.fill: parent
+
+ leftContent: Component {
+
+ StyledFlickable {
+ id: sidebarFlickable
+ readonly property var rootPane: root
+ flickableDirection: Flickable.VerticalFlick
+ contentHeight: sidebarLayout.height
+
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: sidebarFlickable
+ }
+
+ ColumnLayout {
+ id: sidebarLayout
+ anchors.left: parent.left
+ anchors.right: parent.right
+ spacing: Appearance.spacing.small
+
+ readonly property var rootPane: sidebarFlickable.rootPane
+
+ readonly property bool allSectionsExpanded: themeModeSection.expanded && colorVariantSection.expanded && colorSchemeSection.expanded && animationsSection.expanded && fontsSection.expanded && scalesSection.expanded && transparencySection.expanded && borderSection.expanded && backgroundSection.expanded
+
+ RowLayout {
+ spacing: Appearance.spacing.smaller
+
+ StyledText {
+ text: qsTr("Appearance")
+ font.pointSize: Appearance.font.size.large
+ font.weight: 500
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ IconButton {
+ icon: sidebarLayout.allSectionsExpanded ? "unfold_less" : "unfold_more"
+ type: IconButton.Text
+ label.animate: true
+ onClicked: {
+ const shouldExpand = !sidebarLayout.allSectionsExpanded;
+ themeModeSection.expanded = shouldExpand;
+ colorVariantSection.expanded = shouldExpand;
+ colorSchemeSection.expanded = shouldExpand;
+ animationsSection.expanded = shouldExpand;
+ fontsSection.expanded = shouldExpand;
+ scalesSection.expanded = shouldExpand;
+ transparencySection.expanded = shouldExpand;
+ borderSection.expanded = shouldExpand;
+ backgroundSection.expanded = shouldExpand;
+ }
+ }
+ }
+
+ ThemeModeSection {
+ id: themeModeSection
+ }
+
+ ColorVariantSection {
+ id: colorVariantSection
+ }
+
+ ColorSchemeSection {
+ id: colorSchemeSection
+ }
+
+ AnimationsSection {
+ id: animationsSection
+ rootPane: sidebarFlickable.rootPane
+ }
+
+ FontsSection {
+ id: fontsSection
+ rootPane: sidebarFlickable.rootPane
+ }
+
+ ScalesSection {
+ id: scalesSection
+ rootPane: sidebarFlickable.rootPane
+ }
+
+ TransparencySection {
+ id: transparencySection
+ rootPane: sidebarFlickable.rootPane
+ }
+
+ BorderSection {
+ id: borderSection
+ rootPane: sidebarFlickable.rootPane
+ }
+
+ BackgroundSection {
+ id: backgroundSection
+ rootPane: sidebarFlickable.rootPane
+ }
+ }
+ }
+ }
+
+ rightContent: appearanceRightContentComponent
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/AnimationsSection.qml b/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/AnimationsSection.qml
new file mode 100644
index 0000000..0cba5ce
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/AnimationsSection.qml
@@ -0,0 +1,44 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../../components"
+import qs.components
+import qs.components.controls
+import qs.components.containers
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+CollapsibleSection {
+ id: root
+
+ required property var rootPane
+
+ title: qsTr("Animations")
+ showBackground: true
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.normal
+
+ SliderInput {
+ Layout.fillWidth: true
+
+ label: qsTr("Animation duration scale")
+ value: rootPane.animDurationsScale
+ from: 0.1
+ to: 5.0
+ decimals: 1
+ suffix: "×"
+ validator: DoubleValidator {
+ bottom: 0.1
+ top: 5.0
+ }
+
+ onValueModified: newValue => {
+ rootPane.animDurationsScale = newValue;
+ rootPane.saveConfig();
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/BackgroundSection.qml b/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/BackgroundSection.qml
new file mode 100644
index 0000000..9d6bc6e
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/BackgroundSection.qml
@@ -0,0 +1,345 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../../components"
+import qs.components
+import qs.components.controls
+import qs.components.containers
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+CollapsibleSection {
+ id: root
+
+ required property var rootPane
+
+ title: qsTr("Background")
+ showBackground: true
+
+ SwitchRow {
+ label: qsTr("Background enabled")
+ checked: rootPane.backgroundEnabled
+ onToggled: checked => {
+ rootPane.backgroundEnabled = checked;
+ rootPane.saveConfig();
+ }
+ }
+
+ SwitchRow {
+ label: qsTr("Wallpaper enabled")
+ checked: rootPane.wallpaperEnabled
+ onToggled: checked => {
+ rootPane.wallpaperEnabled = checked;
+ rootPane.saveConfig();
+ }
+ }
+
+ StyledText {
+ Layout.topMargin: Appearance.spacing.normal
+ text: qsTr("Desktop Clock")
+ font.pointSize: Appearance.font.size.larger
+ font.weight: 500
+ }
+
+ SwitchRow {
+ label: qsTr("Desktop Clock enabled")
+ checked: rootPane.desktopClockEnabled
+ onToggled: checked => {
+ rootPane.desktopClockEnabled = checked;
+ rootPane.saveConfig();
+ }
+ }
+
+ SectionContainer {
+ id: posContainer
+
+ contentSpacing: Appearance.spacing.small
+ z: 1
+
+ readonly property var pos: (rootPane.desktopClockPosition || "top-left").split('-')
+ readonly property string currentV: pos[0]
+ readonly property string currentH: pos[1]
+
+ function updateClockPos(v, h) {
+ rootPane.desktopClockPosition = v + "-" + h;
+ rootPane.saveConfig();
+ }
+
+ StyledText {
+ text: qsTr("Positioning")
+ font.pointSize: Appearance.font.size.larger
+ font.weight: 500
+ }
+
+ SplitButtonRow {
+ label: qsTr("Vertical Position")
+ enabled: rootPane.desktopClockEnabled
+
+ menuItems: [
+ MenuItem {
+ text: qsTr("Top")
+ icon: "vertical_align_top"
+ property string val: "top"
+ },
+ MenuItem {
+ text: qsTr("Middle")
+ icon: "vertical_align_center"
+ property string val: "middle"
+ },
+ MenuItem {
+ text: qsTr("Bottom")
+ icon: "vertical_align_bottom"
+ property string val: "bottom"
+ }
+ ]
+
+ Component.onCompleted: {
+ for (let i = 0; i < menuItems.length; i++) {
+ if (menuItems[i].val === posContainer.currentV)
+ active = menuItems[i];
+ }
+ }
+
+ // The signal from SplitButtonRow
+ onSelected: item => posContainer.updateClockPos(item.val, posContainer.currentH)
+ }
+
+ SplitButtonRow {
+ label: qsTr("Horizontal Position")
+ enabled: rootPane.desktopClockEnabled
+ expandedZ: 99
+
+ menuItems: [
+ MenuItem {
+ text: qsTr("Left")
+ icon: "align_horizontal_left"
+ property string val: "left"
+ },
+ MenuItem {
+ text: qsTr("Center")
+ icon: "align_horizontal_center"
+ property string val: "center"
+ },
+ MenuItem {
+ text: qsTr("Right")
+ icon: "align_horizontal_right"
+ property string val: "right"
+ }
+ ]
+
+ Component.onCompleted: {
+ for (let i = 0; i < menuItems.length; i++) {
+ if (menuItems[i].val === posContainer.currentH)
+ active = menuItems[i];
+ }
+ }
+
+ onSelected: item => posContainer.updateClockPos(posContainer.currentV, item.val)
+ }
+ }
+
+ SwitchRow {
+ label: qsTr("Invert colors")
+ checked: rootPane.desktopClockInvertColors
+ onToggled: checked => {
+ rootPane.desktopClockInvertColors = checked;
+ rootPane.saveConfig();
+ }
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.small
+
+ StyledText {
+ text: qsTr("Shadow")
+ font.pointSize: Appearance.font.size.larger
+ font.weight: 500
+ }
+
+ SwitchRow {
+ label: qsTr("Enabled")
+ checked: rootPane.desktopClockShadowEnabled
+ onToggled: checked => {
+ rootPane.desktopClockShadowEnabled = checked;
+ rootPane.saveConfig();
+ }
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.normal
+
+ SliderInput {
+ Layout.fillWidth: true
+
+ label: qsTr("Opacity")
+ value: rootPane.desktopClockShadowOpacity * 100
+ from: 0
+ to: 100
+ suffix: "%"
+ validator: IntValidator {
+ bottom: 0
+ top: 100
+ }
+ formatValueFunction: val => Math.round(val).toString()
+ parseValueFunction: text => parseInt(text)
+
+ onValueModified: newValue => {
+ rootPane.desktopClockShadowOpacity = newValue / 100;
+ rootPane.saveConfig();
+ }
+ }
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.normal
+
+ SliderInput {
+ Layout.fillWidth: true
+
+ label: qsTr("Blur")
+ value: rootPane.desktopClockShadowBlur * 100
+ from: 0
+ to: 100
+ suffix: "%"
+ validator: IntValidator {
+ bottom: 0
+ top: 100
+ }
+ formatValueFunction: val => Math.round(val).toString()
+ parseValueFunction: text => parseInt(text)
+
+ onValueModified: newValue => {
+ rootPane.desktopClockShadowBlur = newValue / 100;
+ rootPane.saveConfig();
+ }
+ }
+ }
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.small
+
+ StyledText {
+ text: qsTr("Background")
+ font.pointSize: Appearance.font.size.larger
+ font.weight: 500
+ }
+
+ SwitchRow {
+ label: qsTr("Enabled")
+ checked: rootPane.desktopClockBackgroundEnabled
+ onToggled: checked => {
+ rootPane.desktopClockBackgroundEnabled = checked;
+ rootPane.saveConfig();
+ }
+ }
+
+ SwitchRow {
+ label: qsTr("Blur enabled")
+ checked: rootPane.desktopClockBackgroundBlur
+ onToggled: checked => {
+ rootPane.desktopClockBackgroundBlur = checked;
+ rootPane.saveConfig();
+ }
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.normal
+
+ SliderInput {
+ Layout.fillWidth: true
+
+ label: qsTr("Opacity")
+ value: rootPane.desktopClockBackgroundOpacity * 100
+ from: 0
+ to: 100
+ suffix: "%"
+ validator: IntValidator {
+ bottom: 0
+ top: 100
+ }
+ formatValueFunction: val => Math.round(val).toString()
+ parseValueFunction: text => parseInt(text)
+
+ onValueModified: newValue => {
+ rootPane.desktopClockBackgroundOpacity = newValue / 100;
+ rootPane.saveConfig();
+ }
+ }
+ }
+ }
+
+ StyledText {
+ Layout.topMargin: Appearance.spacing.normal
+ text: qsTr("Visualiser")
+ font.pointSize: Appearance.font.size.larger
+ font.weight: 500
+ }
+
+ SwitchRow {
+ label: qsTr("Visualiser enabled")
+ checked: rootPane.visualiserEnabled
+ onToggled: checked => {
+ rootPane.visualiserEnabled = checked;
+ rootPane.saveConfig();
+ }
+ }
+
+ SwitchRow {
+ label: qsTr("Visualiser auto hide")
+ checked: rootPane.visualiserAutoHide
+ onToggled: checked => {
+ rootPane.visualiserAutoHide = checked;
+ rootPane.saveConfig();
+ }
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.normal
+
+ SliderInput {
+ Layout.fillWidth: true
+
+ label: qsTr("Visualiser rounding")
+ value: rootPane.visualiserRounding
+ from: 0
+ to: 10
+ stepSize: 1
+ validator: IntValidator {
+ bottom: 0
+ top: 10
+ }
+ formatValueFunction: val => Math.round(val).toString()
+ parseValueFunction: text => parseInt(text)
+
+ onValueModified: newValue => {
+ rootPane.visualiserRounding = Math.round(newValue);
+ rootPane.saveConfig();
+ }
+ }
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.normal
+
+ SliderInput {
+ Layout.fillWidth: true
+
+ label: qsTr("Visualiser spacing")
+ value: rootPane.visualiserSpacing
+ from: 0
+ to: 2
+ validator: DoubleValidator {
+ bottom: 0
+ top: 2
+ }
+
+ onValueModified: newValue => {
+ rootPane.visualiserSpacing = newValue;
+ rootPane.saveConfig();
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/BorderSection.qml b/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/BorderSection.qml
new file mode 100644
index 0000000..9532d70
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/BorderSection.qml
@@ -0,0 +1,68 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../../components"
+import qs.components
+import qs.components.controls
+import qs.components.containers
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+CollapsibleSection {
+ id: root
+
+ required property var rootPane
+
+ title: qsTr("Border")
+ showBackground: true
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.normal
+
+ SliderInput {
+ Layout.fillWidth: true
+
+ label: qsTr("Border rounding")
+ value: rootPane.borderRounding
+ from: 0.1
+ to: 100
+ decimals: 1
+ suffix: "px"
+ validator: DoubleValidator {
+ bottom: 0.1
+ top: 100
+ }
+
+ onValueModified: newValue => {
+ rootPane.borderRounding = newValue;
+ rootPane.saveConfig();
+ }
+ }
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.normal
+
+ SliderInput {
+ Layout.fillWidth: true
+
+ label: qsTr("Border thickness")
+ value: rootPane.borderThickness
+ from: 0.1
+ to: 100
+ decimals: 1
+ suffix: "px"
+ validator: DoubleValidator {
+ bottom: 0.1
+ top: 100
+ }
+
+ onValueModified: newValue => {
+ rootPane.borderThickness = newValue;
+ rootPane.saveConfig();
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/ColorSchemeSection.qml b/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/ColorSchemeSection.qml
new file mode 100644
index 0000000..95cb4b7
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/ColorSchemeSection.qml
@@ -0,0 +1,145 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../../../launcher/services"
+import qs.components
+import qs.components.controls
+import qs.components.containers
+import qs.services
+import qs.config
+import Quickshell
+import QtQuick
+import QtQuick.Layouts
+
+CollapsibleSection {
+ title: qsTr("Color scheme")
+ description: qsTr("Available color schemes")
+ showBackground: true
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.small / 2
+
+ Repeater {
+ model: Schemes.list
+
+ delegate: StyledRect {
+ required property var modelData
+
+ Layout.fillWidth: true
+
+ readonly property string schemeKey: `${modelData.name} ${modelData.flavour}`
+ readonly property bool isCurrent: schemeKey === Schemes.currentScheme
+
+ color: Qt.alpha(Colours.tPalette.m3surfaceContainer, isCurrent ? Colours.tPalette.m3surfaceContainer.a : 0)
+ radius: Appearance.rounding.normal
+ border.width: isCurrent ? 1 : 0
+ border.color: Colours.palette.m3primary
+
+ StateLayer {
+ function onClicked(): void {
+ const name = modelData.name;
+ const flavour = modelData.flavour;
+ const schemeKey = `${name} ${flavour}`;
+
+ Schemes.currentScheme = schemeKey;
+ Quickshell.execDetached(["caelestia", "scheme", "set", "-n", name, "-f", flavour]);
+
+ Qt.callLater(() => {
+ reloadTimer.restart();
+ });
+ }
+ }
+
+ Timer {
+ id: reloadTimer
+ interval: 300
+ onTriggered: {
+ Schemes.reload();
+ }
+ }
+
+ RowLayout {
+ id: schemeRow
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.normal
+
+ spacing: Appearance.spacing.normal
+
+ StyledRect {
+ id: preview
+
+ Layout.alignment: Qt.AlignVCenter
+
+ border.width: 1
+ border.color: Qt.alpha(`#${modelData.colours?.outline}`, 0.5)
+
+ color: `#${modelData.colours?.surface}`
+ radius: Appearance.rounding.full
+ implicitWidth: iconPlaceholder.implicitWidth
+ implicitHeight: iconPlaceholder.implicitWidth
+
+ MaterialIcon {
+ id: iconPlaceholder
+ visible: false
+ text: "circle"
+ font.pointSize: Appearance.font.size.large
+ }
+
+ Item {
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ anchors.right: parent.right
+
+ implicitWidth: parent.implicitWidth / 2
+ clip: true
+
+ StyledRect {
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ anchors.right: parent.right
+
+ implicitWidth: preview.implicitWidth
+ color: `#${modelData.colours?.primary}`
+ radius: Appearance.rounding.full
+ }
+ }
+ }
+
+ Column {
+ Layout.fillWidth: true
+ spacing: 0
+
+ StyledText {
+ text: modelData.flavour ?? ""
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ StyledText {
+ text: modelData.name ?? ""
+ font.pointSize: Appearance.font.size.small
+ color: Colours.palette.m3outline
+
+ elide: Text.ElideRight
+ anchors.left: parent.left
+ anchors.right: parent.right
+ }
+ }
+
+ Loader {
+ active: isCurrent
+
+ sourceComponent: MaterialIcon {
+ text: "check"
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.large
+ }
+ }
+ }
+
+ implicitHeight: schemeRow.implicitHeight + Appearance.padding.normal * 2
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/ColorVariantSection.qml b/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/ColorVariantSection.qml
new file mode 100644
index 0000000..3aa17dd
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/ColorVariantSection.qml
@@ -0,0 +1,91 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../../../launcher/services"
+import qs.components
+import qs.components.controls
+import qs.components.containers
+import qs.services
+import qs.config
+import Quickshell
+import QtQuick
+import QtQuick.Layouts
+
+CollapsibleSection {
+ title: qsTr("Color variant")
+ description: qsTr("Material theme variant")
+ showBackground: true
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.small / 2
+
+ Repeater {
+ model: M3Variants.list
+
+ delegate: StyledRect {
+ required property var modelData
+
+ Layout.fillWidth: true
+
+ color: Qt.alpha(Colours.tPalette.m3surfaceContainer, modelData.variant === Schemes.currentVariant ? Colours.tPalette.m3surfaceContainer.a : 0)
+ radius: Appearance.rounding.normal
+ border.width: modelData.variant === Schemes.currentVariant ? 1 : 0
+ border.color: Colours.palette.m3primary
+
+ StateLayer {
+ function onClicked(): void {
+ const variant = modelData.variant;
+
+ Schemes.currentVariant = variant;
+ Quickshell.execDetached(["caelestia", "scheme", "set", "-v", variant]);
+
+ Qt.callLater(() => {
+ reloadTimer.restart();
+ });
+ }
+ }
+
+ Timer {
+ id: reloadTimer
+ interval: 300
+ onTriggered: {
+ Schemes.reload();
+ }
+ }
+
+ RowLayout {
+ id: variantRow
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.normal
+
+ spacing: Appearance.spacing.normal
+
+ MaterialIcon {
+ text: modelData.icon
+ font.pointSize: Appearance.font.size.large
+ fill: modelData.variant === Schemes.currentVariant ? 1 : 0
+ }
+
+ StyledText {
+ Layout.fillWidth: true
+ text: modelData.name
+ font.weight: modelData.variant === Schemes.currentVariant ? 500 : 400
+ }
+
+ MaterialIcon {
+ visible: modelData.variant === Schemes.currentVariant
+ text: "check"
+ color: Colours.palette.m3primary
+ font.pointSize: Appearance.font.size.large
+ }
+ }
+
+ implicitHeight: variantRow.implicitHeight + Appearance.padding.normal * 2
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/FontsSection.qml b/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/FontsSection.qml
new file mode 100644
index 0000000..3988863
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/FontsSection.qml
@@ -0,0 +1,282 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../../components"
+import qs.components
+import qs.components.controls
+import qs.components.containers
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+CollapsibleSection {
+ id: root
+
+ required property var rootPane
+
+ title: qsTr("Fonts")
+ showBackground: true
+
+ CollapsibleSection {
+ id: materialFontSection
+ title: qsTr("Material font family")
+ expanded: true
+ showBackground: true
+ nested: true
+
+ Loader {
+ id: materialFontLoader
+ Layout.fillWidth: true
+ Layout.preferredHeight: item ? Math.min(item.contentHeight, 300) : 0
+ active: materialFontSection.expanded
+
+ sourceComponent: StyledListView {
+ id: materialFontList
+ property alias contentHeight: materialFontList.contentHeight
+
+ clip: true
+ spacing: Appearance.spacing.small / 2
+ model: Qt.fontFamilies()
+
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: materialFontList
+ }
+
+ delegate: StyledRect {
+ required property string modelData
+ required property int index
+
+ width: ListView.view.width
+
+ readonly property bool isCurrent: modelData === rootPane.fontFamilyMaterial
+ color: Qt.alpha(Colours.tPalette.m3surfaceContainer, isCurrent ? Colours.tPalette.m3surfaceContainer.a : 0)
+ radius: Appearance.rounding.normal
+ border.width: isCurrent ? 1 : 0
+ border.color: Colours.palette.m3primary
+
+ StateLayer {
+ function onClicked(): void {
+ rootPane.fontFamilyMaterial = modelData;
+ rootPane.saveConfig();
+ }
+ }
+
+ RowLayout {
+ id: fontFamilyMaterialRow
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.normal
+
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ text: modelData
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ Loader {
+ active: isCurrent
+
+ sourceComponent: MaterialIcon {
+ text: "check"
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.large
+ }
+ }
+ }
+
+ implicitHeight: fontFamilyMaterialRow.implicitHeight + Appearance.padding.normal * 2
+ }
+ }
+ }
+ }
+
+ CollapsibleSection {
+ id: monoFontSection
+ title: qsTr("Monospace font family")
+ expanded: false
+ showBackground: true
+ nested: true
+
+ Loader {
+ Layout.fillWidth: true
+ Layout.preferredHeight: item ? Math.min(item.contentHeight, 300) : 0
+ active: monoFontSection.expanded
+
+ sourceComponent: StyledListView {
+ id: monoFontList
+ property alias contentHeight: monoFontList.contentHeight
+
+ clip: true
+ spacing: Appearance.spacing.small / 2
+ model: Qt.fontFamilies()
+
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: monoFontList
+ }
+
+ delegate: StyledRect {
+ required property string modelData
+ required property int index
+
+ width: ListView.view.width
+
+ readonly property bool isCurrent: modelData === rootPane.fontFamilyMono
+ color: Qt.alpha(Colours.tPalette.m3surfaceContainer, isCurrent ? Colours.tPalette.m3surfaceContainer.a : 0)
+ radius: Appearance.rounding.normal
+ border.width: isCurrent ? 1 : 0
+ border.color: Colours.palette.m3primary
+
+ StateLayer {
+ function onClicked(): void {
+ rootPane.fontFamilyMono = modelData;
+ rootPane.saveConfig();
+ }
+ }
+
+ RowLayout {
+ id: fontFamilyMonoRow
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.normal
+
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ text: modelData
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ Loader {
+ active: isCurrent
+
+ sourceComponent: MaterialIcon {
+ text: "check"
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.large
+ }
+ }
+ }
+
+ implicitHeight: fontFamilyMonoRow.implicitHeight + Appearance.padding.normal * 2
+ }
+ }
+ }
+ }
+
+ CollapsibleSection {
+ id: sansFontSection
+ title: qsTr("Sans-serif font family")
+ expanded: false
+ showBackground: true
+ nested: true
+
+ Loader {
+ Layout.fillWidth: true
+ Layout.preferredHeight: item ? Math.min(item.contentHeight, 300) : 0
+ active: sansFontSection.expanded
+
+ sourceComponent: StyledListView {
+ id: sansFontList
+ property alias contentHeight: sansFontList.contentHeight
+
+ clip: true
+ spacing: Appearance.spacing.small / 2
+ model: Qt.fontFamilies()
+
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: sansFontList
+ }
+
+ delegate: StyledRect {
+ required property string modelData
+ required property int index
+
+ width: ListView.view.width
+
+ readonly property bool isCurrent: modelData === rootPane.fontFamilySans
+ color: Qt.alpha(Colours.tPalette.m3surfaceContainer, isCurrent ? Colours.tPalette.m3surfaceContainer.a : 0)
+ radius: Appearance.rounding.normal
+ border.width: isCurrent ? 1 : 0
+ border.color: Colours.palette.m3primary
+
+ StateLayer {
+ function onClicked(): void {
+ rootPane.fontFamilySans = modelData;
+ rootPane.saveConfig();
+ }
+ }
+
+ RowLayout {
+ id: fontFamilySansRow
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.normal
+
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ text: modelData
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ Loader {
+ active: isCurrent
+
+ sourceComponent: MaterialIcon {
+ text: "check"
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.large
+ }
+ }
+ }
+
+ implicitHeight: fontFamilySansRow.implicitHeight + Appearance.padding.normal * 2
+ }
+ }
+ }
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.normal
+
+ SliderInput {
+ Layout.fillWidth: true
+
+ label: qsTr("Font size scale")
+ value: rootPane.fontSizeScale
+ from: 0.7
+ to: 1.5
+ decimals: 2
+ suffix: "×"
+ validator: DoubleValidator {
+ bottom: 0.7
+ top: 1.5
+ }
+
+ onValueModified: newValue => {
+ rootPane.fontSizeScale = newValue;
+ rootPane.saveConfig();
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/ScalesSection.qml b/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/ScalesSection.qml
new file mode 100644
index 0000000..b0e6e38
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/ScalesSection.qml
@@ -0,0 +1,92 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../../components"
+import qs.components
+import qs.components.controls
+import qs.components.containers
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+CollapsibleSection {
+ id: root
+
+ required property var rootPane
+
+ title: qsTr("Scales")
+ showBackground: true
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.normal
+
+ SliderInput {
+ Layout.fillWidth: true
+
+ label: qsTr("Padding scale")
+ value: rootPane.paddingScale
+ from: 0.5
+ to: 2.0
+ decimals: 1
+ suffix: "×"
+ validator: DoubleValidator {
+ bottom: 0.5
+ top: 2.0
+ }
+
+ onValueModified: newValue => {
+ rootPane.paddingScale = newValue;
+ rootPane.saveConfig();
+ }
+ }
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.normal
+
+ SliderInput {
+ Layout.fillWidth: true
+
+ label: qsTr("Rounding scale")
+ value: rootPane.roundingScale
+ from: 0.1
+ to: 5.0
+ decimals: 1
+ suffix: "×"
+ validator: DoubleValidator {
+ bottom: 0.1
+ top: 5.0
+ }
+
+ onValueModified: newValue => {
+ rootPane.roundingScale = newValue;
+ rootPane.saveConfig();
+ }
+ }
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.normal
+
+ SliderInput {
+ Layout.fillWidth: true
+
+ label: qsTr("Spacing scale")
+ value: rootPane.spacingScale
+ from: 0.1
+ to: 2.0
+ decimals: 1
+ suffix: "×"
+ validator: DoubleValidator {
+ bottom: 0.1
+ top: 2.0
+ }
+
+ onValueModified: newValue => {
+ rootPane.spacingScale = newValue;
+ rootPane.saveConfig();
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/ThemeModeSection.qml b/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/ThemeModeSection.qml
new file mode 100644
index 0000000..04eed91
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/ThemeModeSection.qml
@@ -0,0 +1,23 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import qs.components
+import qs.components.controls
+import qs.components.containers
+import qs.services
+import qs.config
+import QtQuick
+
+CollapsibleSection {
+ title: qsTr("Theme mode")
+ description: qsTr("Light or dark theme")
+ showBackground: true
+
+ SwitchRow {
+ label: qsTr("Dark mode")
+ checked: !Colours.currentLight
+ onToggled: checked => {
+ Colours.setMode(checked ? "dark" : "light");
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/TransparencySection.qml b/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/TransparencySection.qml
new file mode 100644
index 0000000..9a48629
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/TransparencySection.qml
@@ -0,0 +1,79 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../../components"
+import qs.components
+import qs.components.controls
+import qs.components.containers
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+CollapsibleSection {
+ id: root
+
+ required property var rootPane
+
+ title: qsTr("Transparency")
+ showBackground: true
+
+ SwitchRow {
+ label: qsTr("Transparency enabled")
+ checked: rootPane.transparencyEnabled
+ onToggled: checked => {
+ rootPane.transparencyEnabled = checked;
+ rootPane.saveConfig();
+ }
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.normal
+
+ SliderInput {
+ Layout.fillWidth: true
+
+ label: qsTr("Transparency base")
+ value: rootPane.transparencyBase * 100
+ from: 0
+ to: 100
+ suffix: "%"
+ validator: IntValidator {
+ bottom: 0
+ top: 100
+ }
+ formatValueFunction: val => Math.round(val).toString()
+ parseValueFunction: text => parseInt(text)
+
+ onValueModified: newValue => {
+ rootPane.transparencyBase = newValue / 100;
+ rootPane.saveConfig();
+ }
+ }
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.normal
+
+ SliderInput {
+ Layout.fillWidth: true
+
+ label: qsTr("Transparency layers")
+ value: rootPane.transparencyLayers * 100
+ from: 0
+ to: 100
+ suffix: "%"
+ validator: IntValidator {
+ bottom: 0
+ top: 100
+ }
+ formatValueFunction: val => Math.round(val).toString()
+ parseValueFunction: text => parseInt(text)
+
+ onValueModified: newValue => {
+ rootPane.transparencyLayers = newValue / 100;
+ rootPane.saveConfig();
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/audio/AudioPane.qml b/.config/quickshell/caelestia/modules/controlcenter/audio/AudioPane.qml
new file mode 100644
index 0000000..01d90be
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/audio/AudioPane.qml
@@ -0,0 +1,621 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../components"
+import qs.components
+import qs.components.controls
+import qs.components.effects
+import qs.components.containers
+import qs.services
+import qs.config
+import Quickshell.Widgets
+import QtQuick
+import QtQuick.Layouts
+
+Item {
+ id: root
+
+ required property Session session
+
+ anchors.fill: parent
+
+ SplitPaneLayout {
+ anchors.fill: parent
+
+ leftContent: Component {
+
+ StyledFlickable {
+ id: leftAudioFlickable
+ flickableDirection: Flickable.VerticalFlick
+ contentHeight: leftContent.height
+
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: leftAudioFlickable
+ }
+
+ ColumnLayout {
+ id: leftContent
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ spacing: Appearance.spacing.normal
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.smaller
+
+ StyledText {
+ text: qsTr("Audio")
+ font.pointSize: Appearance.font.size.large
+ font.weight: 500
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+ }
+
+ CollapsibleSection {
+ id: outputDevicesSection
+
+ Layout.fillWidth: true
+ title: qsTr("Output devices")
+ expanded: true
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.small
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.small
+
+ StyledText {
+ text: qsTr("Devices (%1)").arg(Audio.sinks.length)
+ font.pointSize: Appearance.font.size.normal
+ font.weight: 500
+ }
+ }
+
+ StyledText {
+ Layout.fillWidth: true
+ text: qsTr("All available output devices")
+ color: Colours.palette.m3outline
+ }
+
+ Repeater {
+ Layout.fillWidth: true
+ model: Audio.sinks
+
+ delegate: StyledRect {
+ required property var modelData
+
+ Layout.fillWidth: true
+
+ color: Audio.sink?.id === modelData.id ? Colours.layer(Colours.palette.m3surfaceContainer, 2) : "transparent"
+ radius: Appearance.rounding.normal
+
+ StateLayer {
+ function onClicked(): void {
+ Audio.setAudioSink(modelData);
+ }
+ }
+
+ RowLayout {
+ id: outputRowLayout
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.normal
+
+ spacing: Appearance.spacing.normal
+
+ MaterialIcon {
+ text: Audio.sink?.id === modelData.id ? "speaker" : "speaker_group"
+ font.pointSize: Appearance.font.size.large
+ fill: Audio.sink?.id === modelData.id ? 1 : 0
+ }
+
+ StyledText {
+ Layout.fillWidth: true
+ elide: Text.ElideRight
+ maximumLineCount: 1
+
+ text: modelData.description || qsTr("Unknown")
+ font.weight: Audio.sink?.id === modelData.id ? 500 : 400
+ }
+ }
+
+ implicitHeight: outputRowLayout.implicitHeight + Appearance.padding.normal * 2
+ }
+ }
+ }
+ }
+
+ CollapsibleSection {
+ id: inputDevicesSection
+
+ Layout.fillWidth: true
+ title: qsTr("Input devices")
+ expanded: true
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.small
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.small
+
+ StyledText {
+ text: qsTr("Devices (%1)").arg(Audio.sources.length)
+ font.pointSize: Appearance.font.size.normal
+ font.weight: 500
+ }
+ }
+
+ StyledText {
+ Layout.fillWidth: true
+ text: qsTr("All available input devices")
+ color: Colours.palette.m3outline
+ }
+
+ Repeater {
+ Layout.fillWidth: true
+ model: Audio.sources
+
+ delegate: StyledRect {
+ required property var modelData
+
+ Layout.fillWidth: true
+
+ color: Audio.source?.id === modelData.id ? Colours.layer(Colours.palette.m3surfaceContainer, 2) : "transparent"
+ radius: Appearance.rounding.normal
+
+ StateLayer {
+ function onClicked(): void {
+ Audio.setAudioSource(modelData);
+ }
+ }
+
+ RowLayout {
+ id: inputRowLayout
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.normal
+
+ spacing: Appearance.spacing.normal
+
+ MaterialIcon {
+ text: "mic"
+ font.pointSize: Appearance.font.size.large
+ fill: Audio.source?.id === modelData.id ? 1 : 0
+ }
+
+ StyledText {
+ Layout.fillWidth: true
+ elide: Text.ElideRight
+ maximumLineCount: 1
+
+ text: modelData.description || qsTr("Unknown")
+ font.weight: Audio.source?.id === modelData.id ? 500 : 400
+ }
+ }
+
+ implicitHeight: inputRowLayout.implicitHeight + Appearance.padding.normal * 2
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ rightContent: Component {
+ StyledFlickable {
+ id: rightAudioFlickable
+ flickableDirection: Flickable.VerticalFlick
+ contentHeight: contentLayout.height
+
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: rightAudioFlickable
+ }
+
+ ColumnLayout {
+ id: contentLayout
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ spacing: Appearance.spacing.normal
+
+ SettingsHeader {
+ icon: "volume_up"
+ title: qsTr("Audio Settings")
+ }
+
+ SectionHeader {
+ title: qsTr("Output volume")
+ description: qsTr("Control the volume of your output device")
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.normal
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.small
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ text: qsTr("Volume")
+ font.pointSize: Appearance.font.size.normal
+ font.weight: 500
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ StyledInputField {
+ id: outputVolumeInput
+ Layout.preferredWidth: 70
+ validator: IntValidator {
+ bottom: 0
+ top: 100
+ }
+ enabled: !Audio.muted
+
+ Component.onCompleted: {
+ text = Math.round(Audio.volume * 100).toString();
+ }
+
+ Connections {
+ target: Audio
+ function onVolumeChanged() {
+ if (!outputVolumeInput.hasFocus) {
+ outputVolumeInput.text = Math.round(Audio.volume * 100).toString();
+ }
+ }
+ }
+
+ onTextEdited: text => {
+ if (hasFocus) {
+ const val = parseInt(text);
+ if (!isNaN(val) && val >= 0 && val <= 100) {
+ Audio.setVolume(val / 100);
+ }
+ }
+ }
+
+ onEditingFinished: {
+ const val = parseInt(text);
+ if (isNaN(val) || val < 0 || val > 100) {
+ text = Math.round(Audio.volume * 100).toString();
+ }
+ }
+ }
+
+ StyledText {
+ text: "%"
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.normal
+ opacity: Audio.muted ? 0.5 : 1
+ }
+
+ StyledRect {
+ implicitWidth: implicitHeight
+ implicitHeight: muteIcon.implicitHeight + Appearance.padding.normal * 2
+
+ radius: Appearance.rounding.normal
+ color: Audio.muted ? Colours.palette.m3secondary : Colours.palette.m3secondaryContainer
+
+ StateLayer {
+ function onClicked(): void {
+ if (Audio.sink?.audio) {
+ Audio.sink.audio.muted = !Audio.sink.audio.muted;
+ }
+ }
+ }
+
+ MaterialIcon {
+ id: muteIcon
+
+ anchors.centerIn: parent
+ text: Audio.muted ? "volume_off" : "volume_up"
+ color: Audio.muted ? Colours.palette.m3onSecondary : Colours.palette.m3onSecondaryContainer
+ }
+ }
+ }
+
+ StyledSlider {
+ id: outputVolumeSlider
+ Layout.fillWidth: true
+ implicitHeight: Appearance.padding.normal * 3
+
+ value: Audio.volume
+ enabled: !Audio.muted
+ opacity: enabled ? 1 : 0.5
+ onMoved: {
+ Audio.setVolume(value);
+ if (!outputVolumeInput.hasFocus) {
+ outputVolumeInput.text = Math.round(value * 100).toString();
+ }
+ }
+ }
+ }
+ }
+
+ SectionHeader {
+ title: qsTr("Input volume")
+ description: qsTr("Control the volume of your input device")
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.normal
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.small
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ text: qsTr("Volume")
+ font.pointSize: Appearance.font.size.normal
+ font.weight: 500
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ StyledInputField {
+ id: inputVolumeInput
+ Layout.preferredWidth: 70
+ validator: IntValidator {
+ bottom: 0
+ top: 100
+ }
+ enabled: !Audio.sourceMuted
+
+ Component.onCompleted: {
+ text = Math.round(Audio.sourceVolume * 100).toString();
+ }
+
+ Connections {
+ target: Audio
+ function onSourceVolumeChanged() {
+ if (!inputVolumeInput.hasFocus) {
+ inputVolumeInput.text = Math.round(Audio.sourceVolume * 100).toString();
+ }
+ }
+ }
+
+ onTextEdited: text => {
+ if (hasFocus) {
+ const val = parseInt(text);
+ if (!isNaN(val) && val >= 0 && val <= 100) {
+ Audio.setSourceVolume(val / 100);
+ }
+ }
+ }
+
+ onEditingFinished: {
+ const val = parseInt(text);
+ if (isNaN(val) || val < 0 || val > 100) {
+ text = Math.round(Audio.sourceVolume * 100).toString();
+ }
+ }
+ }
+
+ StyledText {
+ text: "%"
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.normal
+ opacity: Audio.sourceMuted ? 0.5 : 1
+ }
+
+ StyledRect {
+ implicitWidth: implicitHeight
+ implicitHeight: muteInputIcon.implicitHeight + Appearance.padding.normal * 2
+
+ radius: Appearance.rounding.normal
+ color: Audio.sourceMuted ? Colours.palette.m3secondary : Colours.palette.m3secondaryContainer
+
+ StateLayer {
+ function onClicked(): void {
+ if (Audio.source?.audio) {
+ Audio.source.audio.muted = !Audio.source.audio.muted;
+ }
+ }
+ }
+
+ MaterialIcon {
+ id: muteInputIcon
+
+ anchors.centerIn: parent
+ text: "mic_off"
+ color: Audio.sourceMuted ? Colours.palette.m3onSecondary : Colours.palette.m3onSecondaryContainer
+ }
+ }
+ }
+
+ StyledSlider {
+ id: inputVolumeSlider
+ Layout.fillWidth: true
+ implicitHeight: Appearance.padding.normal * 3
+
+ value: Audio.sourceVolume
+ enabled: !Audio.sourceMuted
+ opacity: enabled ? 1 : 0.5
+ onMoved: {
+ Audio.setSourceVolume(value);
+ if (!inputVolumeInput.hasFocus) {
+ inputVolumeInput.text = Math.round(value * 100).toString();
+ }
+ }
+ }
+ }
+ }
+
+ SectionHeader {
+ title: qsTr("Applications")
+ description: qsTr("Control volume for individual applications")
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.normal
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.small
+
+ Repeater {
+ model: Audio.streams
+ Layout.fillWidth: true
+
+ delegate: ColumnLayout {
+ required property var modelData
+ required property int index
+
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.smaller
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+
+ MaterialIcon {
+ text: "apps"
+ font.pointSize: Appearance.font.size.normal
+ fill: 0
+ }
+
+ StyledText {
+ Layout.fillWidth: true
+ elide: Text.ElideRight
+ maximumLineCount: 1
+ text: Audio.getStreamName(modelData)
+ font.pointSize: Appearance.font.size.normal
+ font.weight: 500
+ }
+
+ StyledInputField {
+ id: streamVolumeInput
+ Layout.preferredWidth: 70
+ validator: IntValidator {
+ bottom: 0
+ top: 100
+ }
+ enabled: !Audio.getStreamMuted(modelData)
+
+ Component.onCompleted: {
+ text = Math.round(Audio.getStreamVolume(modelData) * 100).toString();
+ }
+
+ Connections {
+ target: modelData
+ function onAudioChanged() {
+ if (!streamVolumeInput.hasFocus && modelData?.audio) {
+ streamVolumeInput.text = Math.round(modelData.audio.volume * 100).toString();
+ }
+ }
+ }
+
+ onTextEdited: text => {
+ if (hasFocus) {
+ const val = parseInt(text);
+ if (!isNaN(val) && val >= 0 && val <= 100) {
+ Audio.setStreamVolume(modelData, val / 100);
+ }
+ }
+ }
+
+ onEditingFinished: {
+ const val = parseInt(text);
+ if (isNaN(val) || val < 0 || val > 100) {
+ text = Math.round(Audio.getStreamVolume(modelData) * 100).toString();
+ }
+ }
+ }
+
+ StyledText {
+ text: "%"
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.normal
+ opacity: Audio.getStreamMuted(modelData) ? 0.5 : 1
+ }
+
+ StyledRect {
+ implicitWidth: implicitHeight
+ implicitHeight: streamMuteIcon.implicitHeight + Appearance.padding.normal * 2
+
+ radius: Appearance.rounding.normal
+ color: Audio.getStreamMuted(modelData) ? Colours.palette.m3secondary : Colours.palette.m3secondaryContainer
+
+ StateLayer {
+ function onClicked(): void {
+ Audio.setStreamMuted(modelData, !Audio.getStreamMuted(modelData));
+ }
+ }
+
+ MaterialIcon {
+ id: streamMuteIcon
+
+ anchors.centerIn: parent
+ text: Audio.getStreamMuted(modelData) ? "volume_off" : "volume_up"
+ color: Audio.getStreamMuted(modelData) ? Colours.palette.m3onSecondary : Colours.palette.m3onSecondaryContainer
+ }
+ }
+ }
+
+ StyledSlider {
+ Layout.fillWidth: true
+ implicitHeight: Appearance.padding.normal * 3
+
+ value: Audio.getStreamVolume(modelData)
+ enabled: !Audio.getStreamMuted(modelData)
+ opacity: enabled ? 1 : 0.5
+ onMoved: {
+ Audio.setStreamVolume(modelData, value);
+ if (!streamVolumeInput.hasFocus) {
+ streamVolumeInput.text = Math.round(value * 100).toString();
+ }
+ }
+
+ Connections {
+ target: modelData
+ function onAudioChanged() {
+ if (modelData?.audio) {
+ value = modelData.audio.volume;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ StyledText {
+ Layout.fillWidth: true
+ visible: Audio.streams.length === 0
+ text: qsTr("No applications currently playing audio")
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ horizontalAlignment: Text.AlignHCenter
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/bluetooth/BtPane.qml b/.config/quickshell/caelestia/modules/controlcenter/bluetooth/BtPane.qml
new file mode 100644
index 0000000..7d3b9ca
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/bluetooth/BtPane.qml
@@ -0,0 +1,73 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../components"
+import "."
+import qs.components
+import qs.components.controls
+import qs.components.containers
+import qs.config
+import Quickshell.Widgets
+import Quickshell.Bluetooth
+import QtQuick
+
+SplitPaneWithDetails {
+ id: root
+
+ required property Session session
+
+ anchors.fill: parent
+
+ activeItem: session.bt.active
+ paneIdGenerator: function (item) {
+ return item ? (item.address || "") : "";
+ }
+
+ leftContent: Component {
+ StyledFlickable {
+ id: leftFlickable
+
+ flickableDirection: Flickable.VerticalFlick
+ contentHeight: deviceList.height
+
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: leftFlickable
+ }
+
+ DeviceList {
+ id: deviceList
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ session: root.session
+ }
+ }
+ }
+
+ rightDetailsComponent: Component {
+ Details {
+ session: root.session
+ }
+ }
+
+ rightSettingsComponent: Component {
+ StyledFlickable {
+ id: settingsFlickable
+ flickableDirection: Flickable.VerticalFlick
+ contentHeight: settingsInner.height
+
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: settingsFlickable
+ }
+
+ Settings {
+ id: settingsInner
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ session: root.session
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/bluetooth/Details.qml b/.config/quickshell/caelestia/modules/controlcenter/bluetooth/Details.qml
new file mode 100644
index 0000000..bc276e0
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/bluetooth/Details.qml
@@ -0,0 +1,671 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../components"
+import qs.components
+import qs.components.controls
+import qs.components.effects
+import qs.components.containers
+import qs.services
+import qs.config
+import qs.utils
+import Quickshell.Bluetooth
+import QtQuick
+import QtQuick.Layouts
+
+StyledFlickable {
+ id: root
+
+ required property Session session
+ readonly property BluetoothDevice device: session.bt.active
+
+ flickableDirection: Flickable.VerticalFlick
+ contentHeight: detailsWrapper.height
+
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: root
+ }
+
+ Item {
+ id: detailsWrapper
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ implicitHeight: details.implicitHeight
+
+ DeviceDetails {
+ id: details
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+
+ session: root.session
+ device: root.device
+
+ headerComponent: Component {
+ SettingsHeader {
+ icon: Icons.getBluetoothIcon(root.device?.icon ?? "")
+ title: root.device?.name ?? ""
+ }
+ }
+
+ sections: [
+ Component {
+ ColumnLayout {
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ Layout.topMargin: Appearance.spacing.large
+ text: qsTr("Connection status")
+ font.pointSize: Appearance.font.size.larger
+ font.weight: 500
+ }
+
+ StyledText {
+ text: qsTr("Connection settings for this device")
+ color: Colours.palette.m3outline
+ }
+
+ StyledRect {
+ Layout.fillWidth: true
+ implicitHeight: deviceStatus.implicitHeight + Appearance.padding.large * 2
+
+ radius: Appearance.rounding.normal
+ color: Colours.tPalette.m3surfaceContainer
+
+ ColumnLayout {
+ id: deviceStatus
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.large
+
+ spacing: Appearance.spacing.larger
+
+ Toggle {
+ label: qsTr("Connected")
+ checked: root.device?.connected ?? false
+ toggle.onToggled: root.device.connected = checked
+ }
+
+ Toggle {
+ label: qsTr("Paired")
+ checked: root.device?.paired ?? false
+ toggle.onToggled: {
+ if (root.device.paired)
+ root.device.forget();
+ else
+ root.device.pair();
+ }
+ }
+
+ Toggle {
+ label: qsTr("Blocked")
+ checked: root.device?.blocked ?? false
+ toggle.onToggled: root.device.blocked = checked
+ }
+ }
+ }
+ }
+ },
+ Component {
+ ColumnLayout {
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ Layout.topMargin: Appearance.spacing.large
+ text: qsTr("Device properties")
+ font.pointSize: Appearance.font.size.larger
+ font.weight: 500
+ }
+
+ StyledText {
+ text: qsTr("Additional settings")
+ color: Colours.palette.m3outline
+ }
+
+ StyledRect {
+ Layout.fillWidth: true
+ implicitHeight: deviceProps.implicitHeight + Appearance.padding.large * 2
+
+ radius: Appearance.rounding.normal
+ color: Colours.tPalette.m3surfaceContainer
+
+ ColumnLayout {
+ id: deviceProps
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.large
+
+ spacing: Appearance.spacing.larger
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.small
+
+ Item {
+ id: renameDevice
+
+ Layout.fillWidth: true
+ Layout.rightMargin: Appearance.spacing.small
+
+ implicitHeight: renameLabel.implicitHeight + deviceNameEdit.implicitHeight
+
+ states: State {
+ name: "editingDeviceName"
+ when: root.session.bt.editingDeviceName
+
+ AnchorChanges {
+ target: deviceNameEdit
+ anchors.top: renameDevice.top
+ }
+ PropertyChanges {
+ renameDevice.implicitHeight: deviceNameEdit.implicitHeight
+ renameLabel.opacity: 0
+ deviceNameEdit.padding: Appearance.padding.normal
+ }
+ }
+
+ transitions: Transition {
+ AnchorAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ Anim {
+ properties: "implicitHeight,opacity,padding"
+ }
+ }
+
+ StyledText {
+ id: renameLabel
+
+ anchors.left: parent.left
+
+ text: qsTr("Device name")
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ }
+
+ StyledTextField {
+ id: deviceNameEdit
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: renameLabel.bottom
+ anchors.leftMargin: root.session.bt.editingDeviceName ? 0 : -Appearance.padding.normal
+
+ text: root.device?.name ?? ""
+ readOnly: !root.session.bt.editingDeviceName
+ onAccepted: {
+ root.session.bt.editingDeviceName = false;
+ root.device.name = text;
+ }
+
+ leftPadding: Appearance.padding.normal
+ rightPadding: Appearance.padding.normal
+
+ background: StyledRect {
+ radius: Appearance.rounding.small
+ border.width: 2
+ border.color: Colours.palette.m3primary
+ opacity: root.session.bt.editingDeviceName ? 1 : 0
+
+ Behavior on border.color {
+ CAnim {}
+ }
+
+ Behavior on opacity {
+ Anim {}
+ }
+ }
+
+ Behavior on anchors.leftMargin {
+ Anim {}
+ }
+ }
+ }
+
+ StyledRect {
+ implicitWidth: implicitHeight
+ implicitHeight: cancelEditIcon.implicitHeight + Appearance.padding.smaller * 2
+
+ radius: Appearance.rounding.small
+ color: Colours.palette.m3secondaryContainer
+ opacity: root.session.bt.editingDeviceName ? 1 : 0
+ scale: root.session.bt.editingDeviceName ? 1 : 0.5
+
+ StateLayer {
+ color: Colours.palette.m3onSecondaryContainer
+ disabled: !root.session.bt.editingDeviceName
+
+ function onClicked(): void {
+ root.session.bt.editingDeviceName = false;
+ deviceNameEdit.text = Qt.binding(() => root.device?.name ?? "");
+ }
+ }
+
+ MaterialIcon {
+ id: cancelEditIcon
+
+ anchors.centerIn: parent
+ animate: true
+ text: "cancel"
+ color: Colours.palette.m3onSecondaryContainer
+ }
+
+ Behavior on opacity {
+ Anim {}
+ }
+
+ Behavior on scale {
+ Anim {
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
+ }
+ }
+ }
+
+ StyledRect {
+ implicitWidth: implicitHeight
+ implicitHeight: editIcon.implicitHeight + Appearance.padding.smaller * 2
+
+ radius: root.session.bt.editingDeviceName ? Appearance.rounding.small : implicitHeight / 2 * Math.min(1, Appearance.rounding.scale)
+ color: Qt.alpha(Colours.palette.m3primary, root.session.bt.editingDeviceName ? 1 : 0)
+
+ StateLayer {
+ color: root.session.bt.editingDeviceName ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface
+
+ function onClicked(): void {
+ root.session.bt.editingDeviceName = !root.session.bt.editingDeviceName;
+ if (root.session.bt.editingDeviceName)
+ deviceNameEdit.forceActiveFocus();
+ else
+ deviceNameEdit.accepted();
+ }
+ }
+
+ MaterialIcon {
+ id: editIcon
+
+ anchors.centerIn: parent
+ animate: true
+ text: root.session.bt.editingDeviceName ? "check_circle" : "edit"
+ color: root.session.bt.editingDeviceName ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface
+ }
+
+ Behavior on radius {
+ Anim {}
+ }
+ }
+ }
+
+ Toggle {
+ label: qsTr("Trusted")
+ checked: root.device?.trusted ?? false
+ toggle.onToggled: root.device.trusted = checked
+ }
+
+ Toggle {
+ label: qsTr("Wake allowed")
+ checked: root.device?.wakeAllowed ?? false
+ toggle.onToggled: root.device.wakeAllowed = checked
+ }
+ }
+ }
+ }
+ },
+ Component {
+ ColumnLayout {
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ Layout.topMargin: Appearance.spacing.large
+ text: qsTr("Device information")
+ font.pointSize: Appearance.font.size.larger
+ font.weight: 500
+ }
+
+ StyledText {
+ text: qsTr("Information about this device")
+ color: Colours.palette.m3outline
+ }
+
+ StyledRect {
+ Layout.fillWidth: true
+ implicitHeight: deviceInfo.implicitHeight + Appearance.padding.large * 2
+
+ radius: Appearance.rounding.normal
+ color: Colours.tPalette.m3surfaceContainer
+
+ ColumnLayout {
+ id: deviceInfo
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.large
+
+ spacing: Appearance.spacing.small / 2
+
+ StyledText {
+ text: root.device?.batteryAvailable ? qsTr("Device battery (%1%)").arg(root.device.battery * 100) : qsTr("Battery unavailable")
+ }
+
+ RowLayout {
+ id: batteryPercent
+ Layout.topMargin: Appearance.spacing.small / 2
+ Layout.fillWidth: true
+ Layout.preferredHeight: Appearance.padding.smaller
+ spacing: Appearance.spacing.small / 2
+
+ StyledRect {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ radius: Appearance.rounding.full
+ color: Colours.palette.m3secondaryContainer
+
+ StyledRect {
+ anchors.left: parent.left
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ anchors.margins: parent.height * 0.25
+
+ implicitWidth: root.device?.batteryAvailable ? batteryPercent.width * root.device.battery : 0
+ radius: Appearance.rounding.full
+ color: Colours.palette.m3primary
+ }
+ }
+ }
+
+ StyledText {
+ Layout.topMargin: Appearance.spacing.normal
+ text: qsTr("Dbus path")
+ }
+
+ StyledText {
+ text: root.device?.dbusPath ?? ""
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ }
+
+ StyledText {
+ Layout.topMargin: Appearance.spacing.normal
+ text: qsTr("MAC address")
+ }
+
+ StyledText {
+ text: root.device?.address ?? ""
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ }
+
+ StyledText {
+ Layout.topMargin: Appearance.spacing.normal
+ text: qsTr("Bonded")
+ }
+
+ StyledText {
+ text: root.device?.bonded ? qsTr("Yes") : qsTr("No")
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ }
+
+ StyledText {
+ Layout.topMargin: Appearance.spacing.normal
+ text: qsTr("System name")
+ }
+
+ StyledText {
+ text: root.device?.deviceName ?? ""
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ }
+ }
+ }
+ }
+ }
+ ]
+ }
+ }
+
+ ColumnLayout {
+ anchors.right: fabRoot.right
+ anchors.bottom: fabRoot.top
+ anchors.bottomMargin: Appearance.padding.normal
+
+ Repeater {
+ id: fabMenu
+
+ model: ListModel {
+ ListElement {
+ name: "trust"
+ icon: "handshake"
+ }
+ ListElement {
+ name: "block"
+ icon: "block"
+ }
+ ListElement {
+ name: "pair"
+ icon: "missing_controller"
+ }
+ ListElement {
+ name: "connect"
+ icon: "bluetooth_connected"
+ }
+ }
+
+ StyledClippingRect {
+ id: fabMenuItem
+
+ required property var modelData
+ required property int index
+
+ Layout.alignment: Qt.AlignRight
+
+ implicitHeight: fabMenuItemInner.implicitHeight + Appearance.padding.larger * 2
+
+ radius: Appearance.rounding.full
+ color: Colours.palette.m3primaryContainer
+
+ opacity: 0
+
+ states: State {
+ name: "visible"
+ when: root.session.bt.fabMenuOpen
+
+ PropertyChanges {
+ fabMenuItem.implicitWidth: fabMenuItemInner.implicitWidth + Appearance.padding.large * 2
+ fabMenuItem.opacity: 1
+ fabMenuItemInner.opacity: 1
+ }
+ }
+
+ transitions: [
+ Transition {
+ to: "visible"
+
+ SequentialAnimation {
+ PauseAnimation {
+ duration: (fabMenu.count - 1 - fabMenuItem.index) * Appearance.anim.durations.small / 8
+ }
+ ParallelAnimation {
+ Anim {
+ property: "implicitWidth"
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
+ }
+ Anim {
+ property: "opacity"
+ duration: Appearance.anim.durations.small
+ }
+ }
+ }
+ },
+ Transition {
+ from: "visible"
+
+ SequentialAnimation {
+ PauseAnimation {
+ duration: fabMenuItem.index * Appearance.anim.durations.small / 8
+ }
+ ParallelAnimation {
+ Anim {
+ property: "implicitWidth"
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
+ }
+ Anim {
+ property: "opacity"
+ duration: Appearance.anim.durations.small
+ }
+ }
+ }
+ }
+ ]
+
+ StateLayer {
+ function onClicked(): void {
+ root.session.bt.fabMenuOpen = false;
+
+ const name = fabMenuItem.modelData.name;
+ if (fabMenuItem.modelData.name !== "pair")
+ root.device[`${name}ed`] = !root.device[`${name}ed`];
+ else if (root.device.paired)
+ root.device.forget();
+ else
+ root.device.pair();
+ }
+ }
+
+ RowLayout {
+ id: fabMenuItemInner
+
+ anchors.centerIn: parent
+ spacing: Appearance.spacing.normal
+ opacity: 0
+
+ MaterialIcon {
+ text: fabMenuItem.modelData.icon
+ color: Colours.palette.m3onPrimaryContainer
+ fill: 1
+ }
+
+ StyledText {
+ animate: true
+ text: (root.device && root.device[`${fabMenuItem.modelData.name}ed`] ? fabMenuItem.modelData.name === "connect" ? "dis" : "un" : "") + fabMenuItem.modelData.name
+ color: Colours.palette.m3onPrimaryContainer
+ font.capitalization: Font.Capitalize
+ Layout.preferredWidth: implicitWidth
+
+ Behavior on Layout.preferredWidth {
+ Anim {
+ duration: Appearance.anim.durations.small
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Item {
+ id: fabRoot
+
+ x: root.contentX + root.width - width
+ y: root.contentY + root.height - height
+ width: 64
+ height: 64
+ z: 10000
+
+ StyledRect {
+ id: fabBg
+
+ anchors.right: parent.right
+ anchors.top: parent.top
+
+ implicitWidth: 64
+ implicitHeight: 64
+
+ radius: Appearance.rounding.normal
+ color: root.session.bt.fabMenuOpen ? Colours.palette.m3primary : Colours.palette.m3primaryContainer
+
+ states: State {
+ name: "expanded"
+ when: root.session.bt.fabMenuOpen
+
+ PropertyChanges {
+ fabBg.implicitWidth: 48
+ fabBg.implicitHeight: 48
+ fabBg.radius: 48 / 2
+ fab.font.pointSize: Appearance.font.size.larger
+ }
+ }
+
+ transitions: Transition {
+ Anim {
+ properties: "implicitWidth,implicitHeight"
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
+ }
+ Anim {
+ properties: "radius,font.pointSize"
+ }
+ }
+
+ Elevation {
+ anchors.fill: parent
+ radius: parent.radius
+ z: -1
+ level: fabState.containsMouse && !fabState.pressed ? 4 : 3
+ }
+
+ StateLayer {
+ id: fabState
+
+ color: root.session.bt.fabMenuOpen ? Colours.palette.m3onPrimary : Colours.palette.m3onPrimaryContainer
+
+ function onClicked(): void {
+ root.session.bt.fabMenuOpen = !root.session.bt.fabMenuOpen;
+ }
+ }
+
+ MaterialIcon {
+ id: fab
+
+ anchors.centerIn: parent
+ animate: true
+ text: root.session.bt.fabMenuOpen ? "close" : "settings"
+ color: root.session.bt.fabMenuOpen ? Colours.palette.m3onPrimary : Colours.palette.m3onPrimaryContainer
+ font.pointSize: Appearance.font.size.large
+ fill: 1
+ }
+ }
+ }
+
+ component Toggle: RowLayout {
+ required property string label
+ property alias checked: toggle.checked
+ property alias toggle: toggle
+
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ Layout.fillWidth: true
+ text: parent.label
+ }
+
+ StyledSwitch {
+ id: toggle
+
+ cLayer: 2
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/bluetooth/DeviceList.qml b/.config/quickshell/caelestia/modules/controlcenter/bluetooth/DeviceList.qml
new file mode 100644
index 0000000..2a2bde9
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/bluetooth/DeviceList.qml
@@ -0,0 +1,264 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../components"
+import qs.components
+import qs.components.controls
+import qs.components.containers
+import qs.services
+import qs.config
+import qs.utils
+import Quickshell
+import Quickshell.Bluetooth
+import QtQuick
+import QtQuick.Layouts
+
+DeviceList {
+ id: root
+
+ required property Session session
+ readonly property bool smallDiscoverable: width <= 540
+ readonly property bool smallPairable: width <= 480
+
+ title: qsTr("Devices (%1)").arg(Bluetooth.devices.values.length)
+ description: qsTr("All available bluetooth devices")
+ activeItem: session.bt.active
+
+ model: ScriptModel {
+ id: deviceModel
+
+ values: [...Bluetooth.devices.values].sort((a, b) => (b.connected - a.connected) || (b.paired - a.paired) || a.name.localeCompare(b.name))
+ }
+
+ headerComponent: Component {
+ RowLayout {
+ spacing: Appearance.spacing.smaller
+
+ StyledText {
+ text: qsTr("Bluetooth")
+ font.pointSize: Appearance.font.size.large
+ font.weight: 500
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ ToggleButton {
+ toggled: Bluetooth.defaultAdapter?.enabled ?? false
+ icon: "power"
+ accent: "Tertiary"
+ iconSize: Appearance.font.size.normal
+ horizontalPadding: Appearance.padding.normal
+ verticalPadding: Appearance.padding.smaller
+ tooltip: qsTr("Toggle Bluetooth")
+
+ onClicked: {
+ const adapter = Bluetooth.defaultAdapter;
+ if (adapter)
+ adapter.enabled = !adapter.enabled;
+ }
+ }
+
+ ToggleButton {
+ toggled: Bluetooth.defaultAdapter?.discoverable ?? false
+ icon: root.smallDiscoverable ? "group_search" : ""
+ label: root.smallDiscoverable ? "" : qsTr("Discoverable")
+ iconSize: Appearance.font.size.normal
+ horizontalPadding: Appearance.padding.normal
+ verticalPadding: Appearance.padding.smaller
+ tooltip: qsTr("Make discoverable")
+
+ onClicked: {
+ const adapter = Bluetooth.defaultAdapter;
+ if (adapter)
+ adapter.discoverable = !adapter.discoverable;
+ }
+ }
+
+ ToggleButton {
+ toggled: Bluetooth.defaultAdapter?.pairable ?? false
+ icon: "missing_controller"
+ label: root.smallPairable ? "" : qsTr("Pairable")
+ iconSize: Appearance.font.size.normal
+ horizontalPadding: Appearance.padding.normal
+ verticalPadding: Appearance.padding.smaller
+ tooltip: qsTr("Make pairable")
+
+ onClicked: {
+ const adapter = Bluetooth.defaultAdapter;
+ if (adapter)
+ adapter.pairable = !adapter.pairable;
+ }
+ }
+
+ ToggleButton {
+ toggled: Bluetooth.defaultAdapter?.discovering ?? false
+ icon: "bluetooth_searching"
+ accent: "Secondary"
+ iconSize: Appearance.font.size.normal
+ horizontalPadding: Appearance.padding.normal
+ verticalPadding: Appearance.padding.smaller
+ tooltip: qsTr("Scan for devices")
+
+ onClicked: {
+ const adapter = Bluetooth.defaultAdapter;
+ if (adapter)
+ adapter.discovering = !adapter.discovering;
+ }
+ }
+
+ ToggleButton {
+ toggled: !root.session.bt.active
+ icon: "settings"
+ accent: "Primary"
+ iconSize: Appearance.font.size.normal
+ horizontalPadding: Appearance.padding.normal
+ verticalPadding: Appearance.padding.smaller
+ tooltip: qsTr("Bluetooth settings")
+
+ onClicked: {
+ if (root.session.bt.active)
+ root.session.bt.active = null;
+ else {
+ root.session.bt.active = root.model.values[0] ?? null;
+ }
+ }
+ }
+ }
+ }
+
+ delegate: Component {
+ StyledRect {
+ id: device
+
+ required property BluetoothDevice modelData
+ readonly property bool loading: modelData && (modelData.state === BluetoothDeviceState.Connecting || modelData.state === BluetoothDeviceState.Disconnecting)
+ readonly property bool connected: modelData && modelData.state === BluetoothDeviceState.Connected
+
+ width: ListView.view ? ListView.view.width : undefined
+ implicitHeight: deviceInner.implicitHeight + Appearance.padding.normal * 2
+
+ color: Qt.alpha(Colours.tPalette.m3surfaceContainer, root.activeItem === modelData ? Colours.tPalette.m3surfaceContainer.a : 0)
+ radius: Appearance.rounding.normal
+
+ StateLayer {
+ id: stateLayer
+
+ function onClicked(): void {
+ if (device.modelData)
+ root.session.bt.active = device.modelData;
+ }
+ }
+
+ RowLayout {
+ id: deviceInner
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.normal
+
+ spacing: Appearance.spacing.normal
+
+ StyledRect {
+ implicitWidth: implicitHeight
+ implicitHeight: icon.implicitHeight + Appearance.padding.normal * 2
+
+ radius: Appearance.rounding.normal
+ color: device.connected ? Colours.palette.m3primaryContainer : (device.modelData && device.modelData.bonded) ? Colours.palette.m3secondaryContainer : Colours.tPalette.m3surfaceContainerHigh
+
+ StyledRect {
+ anchors.fill: parent
+ radius: parent.radius
+ color: Qt.alpha(device.connected ? Colours.palette.m3onPrimaryContainer : (device.modelData && device.modelData.bonded) ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface, stateLayer.pressed ? 0.1 : stateLayer.containsMouse ? 0.08 : 0)
+ }
+
+ MaterialIcon {
+ id: icon
+
+ anchors.centerIn: parent
+ text: Icons.getBluetoothIcon(device.modelData ? device.modelData.icon : "")
+ color: device.connected ? Colours.palette.m3onPrimaryContainer : (device.modelData && device.modelData.bonded) ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface
+ font.pointSize: Appearance.font.size.large
+ fill: device.connected ? 1 : 0
+
+ Behavior on fill {
+ Anim {}
+ }
+ }
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+
+ spacing: 0
+
+ StyledText {
+ Layout.fillWidth: true
+ text: device.modelData ? device.modelData.name : qsTr("Unknown")
+ elide: Text.ElideRight
+ }
+
+ StyledText {
+ Layout.fillWidth: true
+ text: (device.modelData ? device.modelData.address : "") + (device.connected ? qsTr(" (Connected)") : (device.modelData && device.modelData.bonded) ? qsTr(" (Paired)") : "")
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ elide: Text.ElideRight
+ }
+ }
+
+ StyledRect {
+ id: connectBtn
+
+ implicitWidth: implicitHeight
+ implicitHeight: connectIcon.implicitHeight + Appearance.padding.smaller * 2
+
+ radius: Appearance.rounding.full
+ color: Qt.alpha(Colours.palette.m3primaryContainer, device.connected ? 1 : 0)
+
+ CircularIndicator {
+ anchors.fill: parent
+ running: device.loading
+ }
+
+ StateLayer {
+ color: device.connected ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface
+ disabled: device.loading
+
+ function onClicked(): void {
+ if (device.loading)
+ return;
+
+ if (device.connected) {
+ device.modelData.connected = false;
+ } else {
+ if (device.modelData.bonded) {
+ device.modelData.connected = true;
+ } else {
+ device.modelData.pair();
+ }
+ }
+ }
+ }
+
+ MaterialIcon {
+ id: connectIcon
+
+ anchors.centerIn: parent
+ animate: true
+ text: device.connected ? "link_off" : "link"
+ color: device.connected ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface
+
+ opacity: device.loading ? 0 : 1
+
+ Behavior on opacity {
+ Anim {}
+ }
+ }
+ }
+ }
+ }
+ }
+
+ onItemSelected: item => session.bt.active = item
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/bluetooth/Settings.qml b/.config/quickshell/caelestia/modules/controlcenter/bluetooth/Settings.qml
new file mode 100644
index 0000000..c547240
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/bluetooth/Settings.qml
@@ -0,0 +1,532 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../components"
+import qs.components
+import qs.components.controls
+import qs.components.effects
+import qs.services
+import qs.config
+import Quickshell.Bluetooth
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ required property Session session
+
+ spacing: Appearance.spacing.normal
+
+ SettingsHeader {
+ icon: "bluetooth"
+ title: qsTr("Bluetooth Settings")
+ }
+
+ StyledText {
+ Layout.topMargin: Appearance.spacing.large
+ text: qsTr("Adapter status")
+ font.pointSize: Appearance.font.size.larger
+ font.weight: 500
+ }
+
+ StyledText {
+ text: qsTr("General adapter settings")
+ color: Colours.palette.m3outline
+ }
+
+ StyledRect {
+ Layout.fillWidth: true
+ implicitHeight: adapterStatus.implicitHeight + Appearance.padding.large * 2
+
+ radius: Appearance.rounding.normal
+ color: Colours.tPalette.m3surfaceContainer
+
+ ColumnLayout {
+ id: adapterStatus
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.large
+
+ spacing: Appearance.spacing.larger
+
+ Toggle {
+ label: qsTr("Powered")
+ checked: Bluetooth.defaultAdapter?.enabled ?? false
+ toggle.onToggled: {
+ const adapter = Bluetooth.defaultAdapter;
+ if (adapter)
+ adapter.enabled = checked;
+ }
+ }
+
+ Toggle {
+ label: qsTr("Discoverable")
+ checked: Bluetooth.defaultAdapter?.discoverable ?? false
+ toggle.onToggled: {
+ const adapter = Bluetooth.defaultAdapter;
+ if (adapter)
+ adapter.discoverable = checked;
+ }
+ }
+
+ Toggle {
+ label: qsTr("Pairable")
+ checked: Bluetooth.defaultAdapter?.pairable ?? false
+ toggle.onToggled: {
+ const adapter = Bluetooth.defaultAdapter;
+ if (adapter)
+ adapter.pairable = checked;
+ }
+ }
+ }
+ }
+
+ StyledText {
+ Layout.topMargin: Appearance.spacing.large
+ text: qsTr("Adapter properties")
+ font.pointSize: Appearance.font.size.larger
+ font.weight: 500
+ }
+
+ StyledText {
+ text: qsTr("Per-adapter settings")
+ color: Colours.palette.m3outline
+ }
+
+ StyledRect {
+ Layout.fillWidth: true
+ implicitHeight: adapterSettings.implicitHeight + Appearance.padding.large * 2
+
+ radius: Appearance.rounding.normal
+ color: Colours.tPalette.m3surfaceContainer
+
+ ColumnLayout {
+ id: adapterSettings
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.large
+
+ spacing: Appearance.spacing.larger
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ Layout.fillWidth: true
+ text: qsTr("Current adapter")
+ }
+
+ Item {
+ id: adapterPickerButton
+
+ property bool expanded
+
+ implicitWidth: adapterPicker.implicitWidth + Appearance.padding.normal * 2
+ implicitHeight: adapterPicker.implicitHeight + Appearance.padding.smaller * 2
+
+ StateLayer {
+ radius: Appearance.rounding.small
+
+ function onClicked(): void {
+ adapterPickerButton.expanded = !adapterPickerButton.expanded;
+ }
+ }
+
+ RowLayout {
+ id: adapterPicker
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.normal
+ anchors.topMargin: Appearance.padding.smaller
+ anchors.bottomMargin: Appearance.padding.smaller
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ Layout.leftMargin: Appearance.padding.small
+ text: Bluetooth.defaultAdapter?.name ?? qsTr("None")
+ }
+
+ MaterialIcon {
+ text: "expand_more"
+ }
+ }
+
+ Elevation {
+ anchors.fill: adapterListBg
+ radius: adapterListBg.radius
+ opacity: adapterPickerButton.expanded ? 1 : 0
+ scale: adapterPickerButton.expanded ? 1 : 0.7
+ level: 2
+
+ Behavior on opacity {
+ Anim {}
+ }
+
+ Behavior on scale {
+ Anim {
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
+ }
+ }
+ }
+
+ StyledClippingRect {
+ id: adapterListBg
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ implicitHeight: adapterPickerButton.expanded ? adapterList.implicitHeight : adapterPickerButton.implicitHeight
+
+ color: Colours.palette.m3secondaryContainer
+ radius: Appearance.rounding.small
+ opacity: adapterPickerButton.expanded ? 1 : 0
+ scale: adapterPickerButton.expanded ? 1 : 0.7
+
+ ColumnLayout {
+ id: adapterList
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+
+ spacing: 0
+
+ Repeater {
+ model: Bluetooth.adapters
+
+ Item {
+ id: adapter
+
+ required property BluetoothAdapter modelData
+
+ Layout.fillWidth: true
+ implicitHeight: adapterInner.implicitHeight + Appearance.padding.normal * 2
+
+ StateLayer {
+ disabled: !adapterPickerButton.expanded
+
+ function onClicked(): void {
+ adapterPickerButton.expanded = false;
+ root.session.bt.currentAdapter = adapter.modelData;
+ }
+ }
+
+ RowLayout {
+ id: adapterInner
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.normal
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ Layout.fillWidth: true
+ Layout.leftMargin: Appearance.padding.small
+ text: adapter.modelData.name
+ color: Colours.palette.m3onSecondaryContainer
+ }
+
+ MaterialIcon {
+ text: "check"
+ color: Colours.palette.m3onSecondaryContainer
+ visible: adapter.modelData === root.session.bt.currentAdapter
+ }
+ }
+ }
+ }
+ }
+
+ Behavior on opacity {
+ Anim {}
+ }
+
+ Behavior on scale {
+ Anim {
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
+ }
+ }
+
+ Behavior on implicitHeight {
+ Anim {
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ }
+ }
+ }
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ Layout.fillWidth: true
+ text: qsTr("Discoverable timeout")
+ }
+
+ CustomSpinBox {
+ min: 0
+ value: root.session.bt.currentAdapter?.discoverableTimeout ?? 0
+ onValueModified: value => {
+ if (root.session.bt.currentAdapter) {
+ root.session.bt.currentAdapter.discoverableTimeout = value;
+ }
+ }
+ }
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.small
+
+ Item {
+ id: renameAdapter
+
+ Layout.fillWidth: true
+ Layout.rightMargin: Appearance.spacing.small
+
+ implicitHeight: renameLabel.implicitHeight + adapterNameEdit.implicitHeight
+
+ states: State {
+ name: "editingAdapterName"
+ when: root.session.bt.editingAdapterName
+
+ AnchorChanges {
+ target: adapterNameEdit
+ anchors.top: renameAdapter.top
+ }
+ PropertyChanges {
+ renameAdapter.implicitHeight: adapterNameEdit.implicitHeight
+ renameLabel.opacity: 0
+ adapterNameEdit.padding: Appearance.padding.normal
+ }
+ }
+
+ transitions: Transition {
+ AnchorAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ Anim {
+ properties: "implicitHeight,opacity,padding"
+ }
+ }
+
+ StyledText {
+ id: renameLabel
+
+ anchors.left: parent.left
+
+ text: qsTr("Rename adapter (currently does not work)")
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ }
+
+ StyledTextField {
+ id: adapterNameEdit
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: renameLabel.bottom
+ anchors.leftMargin: root.session.bt.editingAdapterName ? 0 : -Appearance.padding.normal
+
+ text: root.session.bt.currentAdapter?.name ?? ""
+ readOnly: !root.session.bt.editingAdapterName
+ onAccepted: {
+ root.session.bt.editingAdapterName = false;
+ }
+
+ leftPadding: Appearance.padding.normal
+ rightPadding: Appearance.padding.normal
+
+ background: StyledRect {
+ radius: Appearance.rounding.small
+ border.width: 2
+ border.color: Colours.palette.m3primary
+ opacity: root.session.bt.editingAdapterName ? 1 : 0
+
+ Behavior on border.color {
+ CAnim {}
+ }
+
+ Behavior on opacity {
+ Anim {}
+ }
+ }
+
+ Behavior on anchors.leftMargin {
+ Anim {}
+ }
+ }
+ }
+
+ StyledRect {
+ implicitWidth: implicitHeight
+ implicitHeight: cancelEditIcon.implicitHeight + Appearance.padding.smaller * 2
+
+ radius: Appearance.rounding.small
+ color: Colours.palette.m3secondaryContainer
+ opacity: root.session.bt.editingAdapterName ? 1 : 0
+ scale: root.session.bt.editingAdapterName ? 1 : 0.5
+
+ StateLayer {
+ color: Colours.palette.m3onSecondaryContainer
+ disabled: !root.session.bt.editingAdapterName
+
+ function onClicked(): void {
+ root.session.bt.editingAdapterName = false;
+ adapterNameEdit.text = Qt.binding(() => root.session.bt.currentAdapter?.name ?? "");
+ }
+ }
+
+ MaterialIcon {
+ id: cancelEditIcon
+
+ anchors.centerIn: parent
+ animate: true
+ text: "cancel"
+ color: Colours.palette.m3onSecondaryContainer
+ }
+
+ Behavior on opacity {
+ Anim {}
+ }
+
+ Behavior on scale {
+ Anim {
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
+ }
+ }
+ }
+
+ StyledRect {
+ implicitWidth: implicitHeight
+ implicitHeight: editIcon.implicitHeight + Appearance.padding.smaller * 2
+
+ radius: root.session.bt.editingAdapterName ? Appearance.rounding.small : implicitHeight / 2 * Math.min(1, Appearance.rounding.scale)
+ color: Qt.alpha(Colours.palette.m3primary, root.session.bt.editingAdapterName ? 1 : 0)
+
+ StateLayer {
+ color: root.session.bt.editingAdapterName ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface
+
+ function onClicked(): void {
+ root.session.bt.editingAdapterName = !root.session.bt.editingAdapterName;
+ if (root.session.bt.editingAdapterName)
+ adapterNameEdit.forceActiveFocus();
+ else
+ adapterNameEdit.accepted();
+ }
+ }
+
+ MaterialIcon {
+ id: editIcon
+
+ anchors.centerIn: parent
+ animate: true
+ text: root.session.bt.editingAdapterName ? "check_circle" : "edit"
+ color: root.session.bt.editingAdapterName ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface
+ }
+
+ Behavior on radius {
+ Anim {}
+ }
+ }
+ }
+ }
+ }
+
+ StyledText {
+ Layout.topMargin: Appearance.spacing.large
+ text: qsTr("Adapter information")
+ font.pointSize: Appearance.font.size.larger
+ font.weight: 500
+ }
+
+ StyledText {
+ text: qsTr("Information about the default adapter")
+ color: Colours.palette.m3outline
+ }
+
+ StyledRect {
+ Layout.fillWidth: true
+ implicitHeight: adapterInfo.implicitHeight + Appearance.padding.large * 2
+
+ radius: Appearance.rounding.normal
+ color: Colours.tPalette.m3surfaceContainer
+
+ ColumnLayout {
+ id: adapterInfo
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.large
+
+ spacing: Appearance.spacing.small / 2
+
+ StyledText {
+ text: qsTr("Adapter state")
+ }
+
+ StyledText {
+ text: Bluetooth.defaultAdapter ? BluetoothAdapterState.toString(Bluetooth.defaultAdapter.state) : qsTr("Unknown")
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ }
+
+ StyledText {
+ Layout.topMargin: Appearance.spacing.normal
+ text: qsTr("Dbus path")
+ }
+
+ StyledText {
+ text: Bluetooth.defaultAdapter?.dbusPath ?? ""
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ }
+
+ StyledText {
+ Layout.topMargin: Appearance.spacing.normal
+ text: qsTr("Adapter id")
+ }
+
+ StyledText {
+ text: Bluetooth.defaultAdapter?.adapterId ?? ""
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ }
+ }
+ }
+
+ component Toggle: RowLayout {
+ required property string label
+ property alias checked: toggle.checked
+ property alias toggle: toggle
+
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ Layout.fillWidth: true
+ text: parent.label
+ }
+
+ StyledSwitch {
+ id: toggle
+
+ cLayer: 2
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/components/ConnectedButtonGroup.qml b/.config/quickshell/caelestia/modules/controlcenter/components/ConnectedButtonGroup.qml
new file mode 100644
index 0000000..01cd612
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/components/ConnectedButtonGroup.qml
@@ -0,0 +1,108 @@
+import ".."
+import qs.components
+import qs.components.controls
+import qs.components.effects
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+StyledRect {
+ id: root
+
+ property var options: [] // Array of {label: string, propertyName: string, onToggled: function}
+ property var rootItem: null // The root item that contains the properties we want to bind to
+ property string title: "" // Optional title text
+
+ Layout.fillWidth: true
+ implicitHeight: layout.implicitHeight + Appearance.padding.large * 2
+ radius: Appearance.rounding.normal
+ color: Colours.layer(Colours.palette.m3surfaceContainer, 2)
+ clip: true
+
+ Behavior on implicitHeight {
+ Anim {}
+ }
+
+ ColumnLayout {
+ id: layout
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.large
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ visible: root.title !== ""
+ text: root.title
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ RowLayout {
+ id: buttonRow
+ Layout.alignment: Qt.AlignHCenter
+ spacing: Appearance.spacing.small
+
+ Repeater {
+ id: repeater
+ model: root.options
+
+ delegate: TextButton {
+ id: button
+ required property int index
+ required property var modelData
+
+ Layout.fillWidth: true
+ text: modelData.label
+
+ property bool _checked: false
+
+ checked: _checked
+ toggle: false
+ type: TextButton.Tonal
+
+ // Create binding in Component.onCompleted
+ Component.onCompleted: {
+ if (root.rootItem && modelData.propertyName) {
+ const propName = modelData.propertyName;
+ const rootItem = root.rootItem;
+ _checked = Qt.binding(function () {
+ return rootItem[propName] ?? false;
+ });
+ }
+ }
+
+ // Match utilities Toggles radius styling
+ // Each button has full rounding (not connected) since they have spacing
+ radius: stateLayer.pressed ? Appearance.rounding.small / 2 : internalChecked ? Appearance.rounding.small : Appearance.rounding.normal
+
+ // Match utilities Toggles inactive color
+ inactiveColour: Colours.layer(Colours.palette.m3surfaceContainerHighest, 2)
+
+ // Adjust width similar to utilities toggles
+ Layout.preferredWidth: implicitWidth + (stateLayer.pressed ? Appearance.padding.large : internalChecked ? Appearance.padding.smaller : 0)
+
+ onClicked: {
+ if (modelData.onToggled && root.rootItem && modelData.propertyName) {
+ const currentValue = root.rootItem[modelData.propertyName] ?? false;
+ modelData.onToggled(!currentValue);
+ }
+ }
+
+ Behavior on Layout.preferredWidth {
+ Anim {
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
+ }
+ }
+
+ Behavior on radius {
+ Anim {
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/components/DeviceDetails.qml b/.config/quickshell/caelestia/modules/controlcenter/components/DeviceDetails.qml
new file mode 100644
index 0000000..a5d0647
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/components/DeviceDetails.qml
@@ -0,0 +1,70 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import qs.components
+import qs.components.controls
+import qs.components.effects
+import qs.components.containers
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+Item {
+ id: root
+
+ property Session session
+ property var device: null
+
+ property Component headerComponent: null
+ property list sections: []
+
+ property Component topContent: null
+ property Component bottomContent: null
+
+ implicitWidth: layout.implicitWidth
+ implicitHeight: layout.implicitHeight
+
+ ColumnLayout {
+ id: layout
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ spacing: Appearance.spacing.normal
+
+ Loader {
+ id: headerLoader
+
+ Layout.fillWidth: true
+ sourceComponent: root.headerComponent
+ visible: root.headerComponent !== null
+ }
+
+ Loader {
+ id: topContentLoader
+
+ Layout.fillWidth: true
+ sourceComponent: root.topContent
+ visible: root.topContent !== null
+ }
+
+ Repeater {
+ model: root.sections
+
+ Loader {
+ required property Component modelData
+
+ Layout.fillWidth: true
+ sourceComponent: modelData
+ }
+ }
+
+ Loader {
+ id: bottomContentLoader
+
+ Layout.fillWidth: true
+ sourceComponent: root.bottomContent
+ visible: root.bottomContent !== null
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/components/DeviceList.qml b/.config/quickshell/caelestia/modules/controlcenter/components/DeviceList.qml
new file mode 100644
index 0000000..722f9a1
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/components/DeviceList.qml
@@ -0,0 +1,84 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import qs.components
+import qs.components.controls
+import qs.components.containers
+import qs.services
+import qs.config
+import Quickshell
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ property Session session: null
+ property var model: null
+ property Component delegate: null
+
+ property string title: ""
+ property string description: ""
+ property var activeItem: null
+ property Component headerComponent: null
+ property Component titleSuffix: null
+ property bool showHeader: true
+
+ signal itemSelected(var item)
+
+ spacing: Appearance.spacing.small
+
+ Loader {
+ id: headerLoader
+
+ Layout.fillWidth: true
+ sourceComponent: root.headerComponent
+ visible: root.headerComponent !== null && root.showHeader
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ Layout.topMargin: root.headerComponent ? 0 : 0
+ spacing: Appearance.spacing.small
+ visible: root.title !== "" || root.description !== ""
+
+ StyledText {
+ visible: root.title !== ""
+ text: root.title
+ font.pointSize: Appearance.font.size.large
+ font.weight: 500
+ }
+
+ Loader {
+ sourceComponent: root.titleSuffix
+ visible: root.titleSuffix !== null
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+ }
+
+ property alias view: view
+
+ StyledText {
+ visible: root.description !== ""
+ Layout.fillWidth: true
+ text: root.description
+ color: Colours.palette.m3outline
+ }
+
+ StyledListView {
+ id: view
+
+ Layout.fillWidth: true
+ implicitHeight: contentHeight
+
+ model: root.model
+ delegate: root.delegate
+
+ spacing: Appearance.spacing.small / 2
+ interactive: false
+ clip: false
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/components/PaneTransition.qml b/.config/quickshell/caelestia/modules/controlcenter/components/PaneTransition.qml
new file mode 100644
index 0000000..5d80dbe
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/components/PaneTransition.qml
@@ -0,0 +1,71 @@
+pragma ComponentBehavior: Bound
+
+import qs.config
+import QtQuick
+
+SequentialAnimation {
+ id: root
+
+ required property Item target
+ property list propertyActions
+
+ property real scaleFrom: 1.0
+ property real scaleTo: 0.8
+ property real opacityFrom: 1.0
+ property real opacityTo: 0.0
+
+ ParallelAnimation {
+ NumberAnimation {
+ target: root.target
+ property: "opacity"
+ from: root.opacityFrom
+ to: root.opacityTo
+ duration: Appearance.anim.durations.normal / 2
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standardAccel
+ }
+
+ NumberAnimation {
+ target: root.target
+ property: "scale"
+ from: root.scaleFrom
+ to: root.scaleTo
+ duration: Appearance.anim.durations.normal / 2
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standardAccel
+ }
+ }
+
+ ScriptAction {
+ script: {
+ for (let i = 0; i < root.propertyActions.length; i++) {
+ const action = root.propertyActions[i];
+ if (action.target && action.property !== undefined) {
+ action.target[action.property] = action.value;
+ }
+ }
+ }
+ }
+
+ ParallelAnimation {
+ NumberAnimation {
+ target: root.target
+ property: "opacity"
+ from: root.opacityTo
+ to: root.opacityFrom
+ duration: Appearance.anim.durations.normal / 2
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standardDecel
+ }
+
+ NumberAnimation {
+ target: root.target
+ property: "scale"
+ from: root.scaleTo
+ to: root.scaleFrom
+ duration: Appearance.anim.durations.normal / 2
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standardDecel
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/components/ReadonlySlider.qml b/.config/quickshell/caelestia/modules/controlcenter/components/ReadonlySlider.qml
new file mode 100644
index 0000000..169d636
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/components/ReadonlySlider.qml
@@ -0,0 +1,67 @@
+import ".."
+import "../components"
+import qs.components
+import qs.components.controls
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ property string label: ""
+ property real value: 0
+ property real from: 0
+ property real to: 100
+ property string suffix: ""
+ property bool readonly: false
+
+ spacing: Appearance.spacing.small
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ visible: root.label !== ""
+ text: root.label
+ font.pointSize: Appearance.font.size.normal
+ color: root.readonly ? Colours.palette.m3outline : Colours.palette.m3onSurface
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ MaterialIcon {
+ visible: root.readonly
+ text: "lock"
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ }
+
+ StyledText {
+ text: Math.round(root.value) + (root.suffix !== "" ? " " + root.suffix : "")
+ font.pointSize: Appearance.font.size.normal
+ color: root.readonly ? Colours.palette.m3outline : Colours.palette.m3onSurface
+ }
+ }
+
+ StyledRect {
+ Layout.fillWidth: true
+ implicitHeight: Appearance.padding.normal
+ radius: Appearance.rounding.full
+ color: Colours.layer(Colours.palette.m3surfaceContainerHighest, 1)
+ opacity: root.readonly ? 0.5 : 1.0
+
+ StyledRect {
+ anchors.left: parent.left
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ width: parent.width * ((root.value - root.from) / (root.to - root.from))
+ radius: parent.radius
+ color: root.readonly ? Colours.palette.m3outline : Colours.palette.m3primary
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/components/SettingsHeader.qml b/.config/quickshell/caelestia/modules/controlcenter/components/SettingsHeader.qml
new file mode 100644
index 0000000..0dc190c
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/components/SettingsHeader.qml
@@ -0,0 +1,37 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+Item {
+ id: root
+
+ required property string icon
+ required property string title
+
+ Layout.fillWidth: true
+ implicitHeight: column.implicitHeight
+
+ ColumnLayout {
+ id: column
+
+ anchors.centerIn: parent
+ spacing: Appearance.spacing.normal
+
+ MaterialIcon {
+ Layout.alignment: Qt.AlignHCenter
+ text: root.icon
+ font.pointSize: Appearance.font.size.extraLarge * 3
+ font.bold: true
+ }
+
+ StyledText {
+ Layout.alignment: Qt.AlignHCenter
+ text: root.title
+ font.pointSize: Appearance.font.size.large
+ font.bold: true
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/components/SliderInput.qml b/.config/quickshell/caelestia/modules/controlcenter/components/SliderInput.qml
new file mode 100644
index 0000000..11b3f70
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/components/SliderInput.qml
@@ -0,0 +1,180 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.components.controls
+import qs.components.effects
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ property string label: ""
+ property real value: 0
+ property real from: 0
+ property real to: 100
+ property real stepSize: 0
+ property var validator: null
+ property string suffix: "" // Optional suffix text (e.g., "×", "px")
+ property int decimals: 1 // Number of decimal places to show (default: 1)
+ property var formatValueFunction: null // Optional custom format function
+ property var parseValueFunction: null // Optional custom parse function
+
+ function formatValue(val: real): string {
+ if (formatValueFunction) {
+ return formatValueFunction(val);
+ }
+ // Default format function
+ // Check if it's an IntValidator (IntValidator doesn't have a 'decimals' property)
+ if (validator && validator.bottom !== undefined && validator.decimals === undefined) {
+ return Math.round(val).toString();
+ }
+ // For DoubleValidator or no validator, use the decimals property
+ return val.toFixed(root.decimals);
+ }
+
+ function parseValue(text: string): real {
+ if (parseValueFunction) {
+ return parseValueFunction(text);
+ }
+ // Default parse function
+ if (validator && validator.bottom !== undefined) {
+ // Check if it's an integer validator
+ if (validator.top !== undefined && validator.top === Math.floor(validator.top)) {
+ return parseInt(text);
+ }
+ }
+ return parseFloat(text);
+ }
+
+ signal valueModified(real newValue)
+
+ property bool _initialized: false
+
+ spacing: Appearance.spacing.small
+
+ Component.onCompleted: {
+ // Set initialized flag after a brief delay to allow component to fully load
+ Qt.callLater(() => {
+ _initialized = true;
+ });
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ visible: root.label !== ""
+ text: root.label
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ StyledInputField {
+ id: inputField
+ Layout.preferredWidth: 70
+ validator: root.validator
+
+ Component.onCompleted: {
+ // Initialize text without triggering valueModified signal
+ text = root.formatValue(root.value);
+ }
+
+ onTextEdited: text => {
+ if (hasFocus) {
+ const val = root.parseValue(text);
+ if (!isNaN(val)) {
+ // Validate against validator bounds if available
+ let isValid = true;
+ if (root.validator) {
+ if (root.validator.bottom !== undefined && val < root.validator.bottom) {
+ isValid = false;
+ }
+ if (root.validator.top !== undefined && val > root.validator.top) {
+ isValid = false;
+ }
+ }
+
+ if (isValid) {
+ root.valueModified(val);
+ }
+ }
+ }
+ }
+
+ onEditingFinished: {
+ const val = root.parseValue(text);
+ let isValid = true;
+ if (root.validator) {
+ if (root.validator.bottom !== undefined && val < root.validator.bottom) {
+ isValid = false;
+ }
+ if (root.validator.top !== undefined && val > root.validator.top) {
+ isValid = false;
+ }
+ }
+
+ if (isNaN(val) || !isValid) {
+ text = root.formatValue(root.value);
+ }
+ }
+ }
+
+ StyledText {
+ visible: root.suffix !== ""
+ text: root.suffix
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.normal
+ }
+ }
+
+ StyledSlider {
+ id: slider
+
+ Layout.fillWidth: true
+ implicitHeight: Appearance.padding.normal * 3
+
+ from: root.from
+ to: root.to
+ stepSize: root.stepSize
+
+ // Use Binding to allow slider to move freely during dragging
+ Binding {
+ target: slider
+ property: "value"
+ value: root.value
+ when: !slider.pressed
+ }
+
+ onValueChanged: {
+ // Update input field text in real-time as slider moves during dragging
+ // Always update when slider value changes (during dragging or external updates)
+ if (!inputField.hasFocus) {
+ const newValue = root.stepSize > 0 ? Math.round(value / root.stepSize) * root.stepSize : value;
+ inputField.text = root.formatValue(newValue);
+ }
+ }
+
+ onMoved: {
+ const newValue = root.stepSize > 0 ? Math.round(value / root.stepSize) * root.stepSize : value;
+ root.valueModified(newValue);
+ if (!inputField.hasFocus) {
+ inputField.text = root.formatValue(newValue);
+ }
+ }
+ }
+
+ // Update input field when value changes externally (slider is already bound)
+ onValueChanged: {
+ // Only update if component is initialized to avoid issues during creation
+ if (root._initialized && !inputField.hasFocus) {
+ inputField.text = root.formatValue(root.value);
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/components/SplitPaneLayout.qml b/.config/quickshell/caelestia/modules/controlcenter/components/SplitPaneLayout.qml
new file mode 100644
index 0000000..89504a0
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/components/SplitPaneLayout.qml
@@ -0,0 +1,109 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.components.effects
+import qs.config
+import Quickshell.Widgets
+import QtQuick
+import QtQuick.Layouts
+
+RowLayout {
+ id: root
+
+ spacing: 0
+
+ property Component leftContent: null
+ property Component rightContent: null
+
+ property real leftWidthRatio: 0.4
+ property int leftMinimumWidth: 420
+ property var leftLoaderProperties: ({})
+ property var rightLoaderProperties: ({})
+
+ property alias leftLoader: leftLoader
+ property alias rightLoader: rightLoader
+
+ Item {
+ id: leftPane
+
+ Layout.preferredWidth: Math.floor(parent.width * root.leftWidthRatio)
+ Layout.minimumWidth: root.leftMinimumWidth
+ Layout.fillHeight: true
+
+ ClippingRectangle {
+ id: leftClippingRect
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.normal
+ anchors.leftMargin: 0
+ anchors.rightMargin: Appearance.padding.normal / 2
+
+ radius: leftBorder.innerRadius
+ color: "transparent"
+
+ Loader {
+ id: leftLoader
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.large + Appearance.padding.normal
+ anchors.leftMargin: Appearance.padding.large
+ anchors.rightMargin: Appearance.padding.large + Appearance.padding.normal / 2
+
+ sourceComponent: root.leftContent
+
+ Component.onCompleted: {
+ for (const key in root.leftLoaderProperties) {
+ leftLoader[key] = root.leftLoaderProperties[key];
+ }
+ }
+ }
+ }
+
+ InnerBorder {
+ id: leftBorder
+
+ leftThickness: 0
+ rightThickness: Appearance.padding.normal / 2
+ }
+ }
+
+ Item {
+ id: rightPane
+
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+
+ ClippingRectangle {
+ id: rightClippingRect
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.normal
+ anchors.leftMargin: 0
+ anchors.rightMargin: Appearance.padding.normal / 2
+
+ radius: rightBorder.innerRadius
+ color: "transparent"
+
+ Loader {
+ id: rightLoader
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.large * 2
+
+ sourceComponent: root.rightContent
+
+ Component.onCompleted: {
+ for (const key in root.rightLoaderProperties) {
+ rightLoader[key] = root.rightLoaderProperties[key];
+ }
+ }
+ }
+ }
+
+ InnerBorder {
+ id: rightBorder
+
+ leftThickness: Appearance.padding.normal / 2
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/components/SplitPaneWithDetails.qml b/.config/quickshell/caelestia/modules/controlcenter/components/SplitPaneWithDetails.qml
new file mode 100644
index 0000000..ce8c9d0
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/components/SplitPaneWithDetails.qml
@@ -0,0 +1,93 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import qs.components
+import qs.components.effects
+import qs.components.containers
+import qs.config
+import Quickshell.Widgets
+import QtQuick
+import QtQuick.Layouts
+
+Item {
+ id: root
+
+ required property Component leftContent
+ required property Component rightDetailsComponent
+ required property Component rightSettingsComponent
+
+ property var activeItem: null
+ property var paneIdGenerator: function (item) {
+ return item ? String(item) : "";
+ }
+
+ property Component overlayComponent: null
+
+ SplitPaneLayout {
+ id: splitLayout
+
+ anchors.fill: parent
+
+ leftContent: root.leftContent
+
+ rightContent: Component {
+ Item {
+ id: rightPaneItem
+
+ property var pane: root.activeItem
+ property string paneId: root.paneIdGenerator(pane)
+ property Component targetComponent: root.rightSettingsComponent
+ property Component nextComponent: root.rightSettingsComponent
+
+ function getComponentForPane() {
+ return pane ? root.rightDetailsComponent : root.rightSettingsComponent;
+ }
+
+ Component.onCompleted: {
+ targetComponent = getComponentForPane();
+ nextComponent = targetComponent;
+ }
+
+ Loader {
+ id: rightLoader
+
+ anchors.fill: parent
+
+ opacity: 1
+ scale: 1
+ transformOrigin: Item.Center
+
+ clip: false
+ sourceComponent: rightPaneItem.targetComponent
+ }
+
+ Behavior on paneId {
+ PaneTransition {
+ target: rightLoader
+ propertyActions: [
+ PropertyAction {
+ target: rightPaneItem
+ property: "targetComponent"
+ value: rightPaneItem.nextComponent
+ }
+ ]
+ }
+ }
+
+ onPaneChanged: {
+ nextComponent = getComponentForPane();
+ paneId = root.paneIdGenerator(pane);
+ }
+ }
+ }
+ }
+
+ Loader {
+ id: overlayLoader
+
+ anchors.fill: parent
+ z: 1000
+ sourceComponent: root.overlayComponent
+ active: root.overlayComponent !== null
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/components/WallpaperGrid.qml b/.config/quickshell/caelestia/modules/controlcenter/components/WallpaperGrid.qml
new file mode 100644
index 0000000..ed6bb40
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/components/WallpaperGrid.qml
@@ -0,0 +1,233 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import qs.components
+import qs.components.controls
+import qs.components.effects
+import qs.components.images
+import qs.services
+import qs.config
+import Caelestia.Models
+import QtQuick
+
+GridView {
+ id: root
+
+ required property Session session
+
+ readonly property int minCellWidth: 200 + Appearance.spacing.normal
+ readonly property int columnsCount: Math.max(1, Math.floor(width / minCellWidth))
+
+ cellWidth: width / columnsCount
+ cellHeight: 140 + Appearance.spacing.normal
+
+ model: Wallpapers.list
+
+ clip: true
+
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: root
+ }
+
+ delegate: Item {
+ required property var modelData
+ required property int index
+
+ width: root.cellWidth
+ height: root.cellHeight
+
+ readonly property bool isCurrent: modelData && modelData.path === Wallpapers.actualCurrent
+ readonly property real itemMargin: Appearance.spacing.normal / 2
+ readonly property real itemRadius: Appearance.rounding.normal
+
+ StateLayer {
+ anchors.fill: parent
+ anchors.leftMargin: itemMargin
+ anchors.rightMargin: itemMargin
+ anchors.topMargin: itemMargin
+ anchors.bottomMargin: itemMargin
+ radius: itemRadius
+
+ function onClicked(): void {
+ Wallpapers.setWallpaper(modelData.path);
+ }
+ }
+
+ StyledClippingRect {
+ id: image
+
+ anchors.fill: parent
+ anchors.leftMargin: itemMargin
+ anchors.rightMargin: itemMargin
+ anchors.topMargin: itemMargin
+ anchors.bottomMargin: itemMargin
+ color: Colours.tPalette.m3surfaceContainer
+ radius: itemRadius
+ antialiasing: true
+ layer.enabled: true
+ layer.smooth: true
+
+ CachingImage {
+ id: cachingImage
+
+ path: modelData.path
+ anchors.fill: parent
+ fillMode: Image.PreserveAspectCrop
+ cache: true
+ visible: opacity > 0
+ antialiasing: true
+ smooth: true
+ sourceSize: Qt.size(width, height)
+
+ opacity: status === Image.Ready ? 1 : 0
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: 1000
+ easing.type: Easing.OutQuad
+ }
+ }
+ }
+
+ // Fallback if CachingImage fails to load
+ Image {
+ id: fallbackImage
+
+ anchors.fill: parent
+ source: fallbackTimer.triggered && cachingImage.status !== Image.Ready ? modelData.path : ""
+ asynchronous: true
+ fillMode: Image.PreserveAspectCrop
+ cache: true
+ visible: opacity > 0
+ antialiasing: true
+ smooth: true
+ sourceSize: Qt.size(width, height)
+
+ opacity: status === Image.Ready && cachingImage.status !== Image.Ready ? 1 : 0
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: 1000
+ easing.type: Easing.OutQuad
+ }
+ }
+ }
+
+ Timer {
+ id: fallbackTimer
+
+ property bool triggered: false
+ interval: 800
+ running: cachingImage.status === Image.Loading || cachingImage.status === Image.Null
+ onTriggered: triggered = true
+ }
+
+ // Gradient overlay for filename
+ Rectangle {
+ id: filenameOverlay
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+
+ implicitHeight: filenameText.implicitHeight + Appearance.padding.normal * 1.5
+ radius: 0
+
+ gradient: Gradient {
+ GradientStop {
+ position: 0.0
+ color: Qt.rgba(Colours.palette.m3surface.r, Colours.palette.m3surface.g, Colours.palette.m3surface.b, 0)
+ }
+ GradientStop {
+ position: 0.3
+ color: Qt.rgba(Colours.palette.m3surface.r, Colours.palette.m3surface.g, Colours.palette.m3surface.b, 0.7)
+ }
+ GradientStop {
+ position: 0.6
+ color: Qt.rgba(Colours.palette.m3surface.r, Colours.palette.m3surface.g, Colours.palette.m3surface.b, 0.9)
+ }
+ GradientStop {
+ position: 1.0
+ color: Qt.rgba(Colours.palette.m3surface.r, Colours.palette.m3surface.g, Colours.palette.m3surface.b, 0.95)
+ }
+ }
+
+ opacity: 0
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: 1000
+ easing.type: Easing.OutCubic
+ }
+ }
+
+ Component.onCompleted: {
+ opacity = 1;
+ }
+ }
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ anchors.leftMargin: itemMargin
+ anchors.rightMargin: itemMargin
+ anchors.topMargin: itemMargin
+ anchors.bottomMargin: itemMargin
+ color: "transparent"
+ radius: itemRadius + border.width
+ border.width: isCurrent ? 2 : 0
+ border.color: Colours.palette.m3primary
+ antialiasing: true
+ smooth: true
+
+ Behavior on border.width {
+ NumberAnimation {
+ duration: 150
+ easing.type: Easing.OutQuad
+ }
+ }
+
+ MaterialIcon {
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.margins: Appearance.padding.small
+
+ visible: isCurrent
+ text: "check_circle"
+ color: Colours.palette.m3primary
+ font.pointSize: Appearance.font.size.large
+ }
+ }
+
+ StyledText {
+ id: filenameText
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ anchors.leftMargin: Appearance.padding.normal + Appearance.spacing.normal / 2
+ anchors.rightMargin: Appearance.padding.normal + Appearance.spacing.normal / 2
+ anchors.bottomMargin: Appearance.padding.normal
+
+ text: modelData.name
+ font.pointSize: Appearance.font.size.smaller
+ font.weight: 500
+ color: isCurrent ? Colours.palette.m3primary : Colours.palette.m3onSurface
+ elide: Text.ElideMiddle
+ maximumLineCount: 1
+ horizontalAlignment: Text.AlignHCenter
+
+ opacity: 0
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: 1000
+ easing.type: Easing.OutCubic
+ }
+ }
+
+ Component.onCompleted: {
+ opacity = 1;
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/dashboard/DashboardPane.qml b/.config/quickshell/caelestia/modules/controlcenter/dashboard/DashboardPane.qml
new file mode 100644
index 0000000..72e3e6e
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/dashboard/DashboardPane.qml
@@ -0,0 +1,123 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../components"
+import qs.components
+import qs.components.controls
+import qs.components.effects
+import qs.components.containers
+import qs.services
+import qs.config
+import qs.utils
+import Quickshell
+import Quickshell.Widgets
+import QtQuick
+import QtQuick.Layouts
+
+Item {
+ id: root
+
+ required property Session session
+
+ // General Settings
+ property bool enabled: Config.dashboard.enabled ?? true
+ property bool showOnHover: Config.dashboard.showOnHover ?? true
+ property int updateInterval: Config.dashboard.updateInterval ?? 1000
+ property int dragThreshold: Config.dashboard.dragThreshold ?? 50
+
+ // Performance Resources
+ property bool showBattery: Config.dashboard.performance.showBattery ?? false
+ property bool showGpu: Config.dashboard.performance.showGpu ?? true
+ property bool showCpu: Config.dashboard.performance.showCpu ?? true
+ property bool showMemory: Config.dashboard.performance.showMemory ?? true
+ property bool showStorage: Config.dashboard.performance.showStorage ?? true
+ property bool showNetwork: Config.dashboard.performance.showNetwork ?? true
+
+ anchors.fill: parent
+
+ function saveConfig() {
+ Config.dashboard.enabled = root.enabled;
+ Config.dashboard.showOnHover = root.showOnHover;
+ Config.dashboard.updateInterval = root.updateInterval;
+ Config.dashboard.dragThreshold = root.dragThreshold;
+ Config.dashboard.performance.showBattery = root.showBattery;
+ Config.dashboard.performance.showGpu = root.showGpu;
+ Config.dashboard.performance.showCpu = root.showCpu;
+ Config.dashboard.performance.showMemory = root.showMemory;
+ Config.dashboard.performance.showStorage = root.showStorage;
+ Config.dashboard.performance.showNetwork = root.showNetwork;
+ // Note: sizes properties are readonly and cannot be modified
+ Config.save();
+ }
+
+ ClippingRectangle {
+ id: dashboardClippingRect
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.normal
+ anchors.leftMargin: 0
+ anchors.rightMargin: Appearance.padding.normal
+
+ radius: dashboardBorder.innerRadius
+ color: "transparent"
+
+ Loader {
+ id: dashboardLoader
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.large + Appearance.padding.normal
+ anchors.leftMargin: Appearance.padding.large
+ anchors.rightMargin: Appearance.padding.large
+
+ sourceComponent: dashboardContentComponent
+ }
+ }
+
+ InnerBorder {
+ id: dashboardBorder
+ leftThickness: 0
+ rightThickness: Appearance.padding.normal
+ }
+
+ Component {
+ id: dashboardContentComponent
+
+ StyledFlickable {
+ id: dashboardFlickable
+ flickableDirection: Flickable.VerticalFlick
+ contentHeight: dashboardLayout.height
+
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: dashboardFlickable
+ }
+
+ ColumnLayout {
+ id: dashboardLayout
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+
+ spacing: Appearance.spacing.normal
+
+ RowLayout {
+ spacing: Appearance.spacing.smaller
+
+ StyledText {
+ text: qsTr("Dashboard")
+ font.pointSize: Appearance.font.size.large
+ font.weight: 500
+ }
+ }
+
+ // General Settings Section
+ GeneralSection {
+ rootItem: root
+ }
+
+ // Performance Resources Section
+ PerformanceSection {
+ rootItem: root
+ }
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/dashboard/GeneralSection.qml b/.config/quickshell/caelestia/modules/controlcenter/dashboard/GeneralSection.qml
new file mode 100644
index 0000000..bf54e97
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/dashboard/GeneralSection.qml
@@ -0,0 +1,81 @@
+import ".."
+import "../components"
+import qs.components
+import qs.components.controls
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+SectionContainer {
+ id: root
+
+ required property var rootItem
+
+ Layout.fillWidth: true
+ alignTop: true
+
+ StyledText {
+ text: qsTr("General Settings")
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ SwitchRow {
+ label: qsTr("Enabled")
+ checked: root.rootItem.enabled
+ onToggled: checked => {
+ root.rootItem.enabled = checked;
+ root.rootItem.saveConfig();
+ }
+ }
+
+ SwitchRow {
+ label: qsTr("Show on hover")
+ checked: root.rootItem.showOnHover
+ onToggled: checked => {
+ root.rootItem.showOnHover = checked;
+ root.rootItem.saveConfig();
+ }
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.normal
+
+ SliderInput {
+ Layout.fillWidth: true
+
+ label: qsTr("Update interval")
+ value: root.rootItem.updateInterval
+ from: 100
+ to: 10000
+ stepSize: 100
+ suffix: "ms"
+ validator: IntValidator { bottom: 100; top: 10000 }
+ formatValueFunction: (val) => Math.round(val).toString()
+ parseValueFunction: (text) => parseInt(text)
+
+ onValueModified: (newValue) => {
+ root.rootItem.updateInterval = Math.round(newValue);
+ root.rootItem.saveConfig();
+ }
+ }
+
+ SliderInput {
+ Layout.fillWidth: true
+
+ label: qsTr("Drag threshold")
+ value: root.rootItem.dragThreshold
+ from: 0
+ to: 100
+ suffix: "px"
+ validator: IntValidator { bottom: 0; top: 100 }
+ formatValueFunction: (val) => Math.round(val).toString()
+ parseValueFunction: (text) => parseInt(text)
+
+ onValueModified: (newValue) => {
+ root.rootItem.dragThreshold = Math.round(newValue);
+ root.rootItem.saveConfig();
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/dashboard/PerformanceSection.qml b/.config/quickshell/caelestia/modules/controlcenter/dashboard/PerformanceSection.qml
new file mode 100644
index 0000000..7e72782
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/dashboard/PerformanceSection.qml
@@ -0,0 +1,85 @@
+import ".."
+import "../components"
+import QtQuick
+import QtQuick.Layouts
+import Quickshell.Services.UPower
+import qs.components
+import qs.components.controls
+import qs.config
+import qs.services
+
+SectionContainer {
+ id: root
+
+ required property var rootItem
+ // GPU toggle is hidden when gpuType is "NONE" (no GPU data available)
+ readonly property bool gpuAvailable: SystemUsage.gpuType !== "NONE"
+ // Battery toggle is hidden when no laptop battery is present
+ readonly property bool batteryAvailable: UPower.displayDevice.isLaptopBattery
+
+ Layout.fillWidth: true
+ alignTop: true
+
+ StyledText {
+ text: qsTr("Performance Resources")
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ ConnectedButtonGroup {
+ rootItem: root.rootItem
+ options: {
+ let opts = [];
+ if (root.batteryAvailable)
+ opts.push({
+ "label": qsTr("Battery"),
+ "propertyName": "showBattery",
+ "onToggled": function(checked) {
+ root.rootItem.showBattery = checked;
+ root.rootItem.saveConfig();
+ }
+ });
+
+ if (root.gpuAvailable)
+ opts.push({
+ "label": qsTr("GPU"),
+ "propertyName": "showGpu",
+ "onToggled": function(checked) {
+ root.rootItem.showGpu = checked;
+ root.rootItem.saveConfig();
+ }
+ });
+
+ opts.push({
+ "label": qsTr("CPU"),
+ "propertyName": "showCpu",
+ "onToggled": function(checked) {
+ root.rootItem.showCpu = checked;
+ root.rootItem.saveConfig();
+ }
+ }, {
+ "label": qsTr("Memory"),
+ "propertyName": "showMemory",
+ "onToggled": function(checked) {
+ root.rootItem.showMemory = checked;
+ root.rootItem.saveConfig();
+ }
+ }, {
+ "label": qsTr("Storage"),
+ "propertyName": "showStorage",
+ "onToggled": function(checked) {
+ root.rootItem.showStorage = checked;
+ root.rootItem.saveConfig();
+ }
+ }, {
+ "label": qsTr("Network"),
+ "propertyName": "showNetwork",
+ "onToggled": function(checked) {
+ root.rootItem.showNetwork = checked;
+ root.rootItem.saveConfig();
+ }
+ });
+ return opts;
+ }
+ }
+
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/launcher/LauncherPane.qml b/.config/quickshell/caelestia/modules/controlcenter/launcher/LauncherPane.qml
new file mode 100644
index 0000000..b236cf9
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/launcher/LauncherPane.qml
@@ -0,0 +1,658 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../components"
+import "../../launcher/services"
+import qs.components
+import qs.components.controls
+import qs.components.effects
+import qs.components.containers
+import qs.services
+import qs.config
+import qs.utils
+import Caelestia
+import Quickshell
+import Quickshell.Widgets
+import QtQuick
+import QtQuick.Layouts
+import "../../../utils/scripts/fuzzysort.js" as Fuzzy
+
+Item {
+ id: root
+
+ required property Session session
+
+ property var selectedApp: root.session.launcher.active
+ property bool hideFromLauncherChecked: false
+ property bool favouriteChecked: false
+
+ anchors.fill: parent
+
+ onSelectedAppChanged: {
+ root.session.launcher.active = root.selectedApp;
+ updateToggleState();
+ }
+
+ Connections {
+ target: root.session.launcher
+ function onActiveChanged() {
+ root.selectedApp = root.session.launcher.active;
+ updateToggleState();
+ }
+ }
+
+ function updateToggleState() {
+ if (!root.selectedApp) {
+ root.hideFromLauncherChecked = false;
+ root.favouriteChecked = false;
+ return;
+ }
+
+ const appId = root.selectedApp.id || root.selectedApp.entry?.id;
+
+ root.hideFromLauncherChecked = Config.launcher.hiddenApps && Config.launcher.hiddenApps.length > 0 && Strings.testRegexList(Config.launcher.hiddenApps, appId);
+ root.favouriteChecked = Config.launcher.favouriteApps && Config.launcher.favouriteApps.length > 0 && Strings.testRegexList(Config.launcher.favouriteApps, appId);
+ }
+
+ function saveHiddenApps(isHidden) {
+ if (!root.selectedApp) {
+ return;
+ }
+
+ const appId = root.selectedApp.id || root.selectedApp.entry?.id;
+
+ const hiddenApps = Config.launcher.hiddenApps ? [...Config.launcher.hiddenApps] : [];
+
+ if (isHidden) {
+ if (!hiddenApps.includes(appId)) {
+ hiddenApps.push(appId);
+ }
+ } else {
+ const index = hiddenApps.indexOf(appId);
+ if (index !== -1) {
+ hiddenApps.splice(index, 1);
+ }
+ }
+
+ Config.launcher.hiddenApps = hiddenApps;
+ Config.save();
+ }
+
+ AppDb {
+ id: allAppsDb
+
+ path: `${Paths.state}/apps.sqlite`
+ favouriteApps: Config.launcher.favouriteApps
+ entries: DesktopEntries.applications.values
+ }
+
+ property string searchText: ""
+
+ function filterApps(search: string): list {
+ if (!search || search.trim() === "") {
+ const apps = [];
+ for (let i = 0; i < allAppsDb.apps.length; i++) {
+ apps.push(allAppsDb.apps[i]);
+ }
+ return apps;
+ }
+
+ if (!allAppsDb.apps || allAppsDb.apps.length === 0) {
+ return [];
+ }
+
+ const preparedApps = [];
+ for (let i = 0; i < allAppsDb.apps.length; i++) {
+ const app = allAppsDb.apps[i];
+ const name = app.name || app.entry?.name || "";
+ preparedApps.push({
+ _item: app,
+ name: Fuzzy.prepare(name)
+ });
+ }
+
+ const results = Fuzzy.go(search, preparedApps, {
+ all: true,
+ keys: ["name"],
+ scoreFn: r => r[0].score
+ });
+
+ return results.sort((a, b) => b._score - a._score).map(r => r.obj._item);
+ }
+
+ property list filteredApps: []
+
+ function updateFilteredApps() {
+ filteredApps = filterApps(searchText);
+ }
+
+ onSearchTextChanged: {
+ updateFilteredApps();
+ }
+
+ Component.onCompleted: {
+ updateFilteredApps();
+ }
+
+ Connections {
+ target: allAppsDb
+ function onAppsChanged() {
+ updateFilteredApps();
+ }
+ }
+
+ SplitPaneLayout {
+ anchors.fill: parent
+
+ leftContent: Component {
+
+ ColumnLayout {
+ id: leftLauncherLayout
+ anchors.fill: parent
+
+ spacing: Appearance.spacing.small
+
+ RowLayout {
+ spacing: Appearance.spacing.smaller
+
+ StyledText {
+ text: qsTr("Launcher")
+ font.pointSize: Appearance.font.size.large
+ font.weight: 500
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ ToggleButton {
+ toggled: !root.session.launcher.active
+ icon: "settings"
+ accent: "Primary"
+ iconSize: Appearance.font.size.normal
+ horizontalPadding: Appearance.padding.normal
+ verticalPadding: Appearance.padding.smaller
+ tooltip: qsTr("Launcher settings")
+
+ onClicked: {
+ if (root.session.launcher.active) {
+ root.session.launcher.active = null;
+ } else {
+ if (root.filteredApps.length > 0) {
+ root.session.launcher.active = root.filteredApps[0];
+ }
+ }
+ }
+ }
+ }
+
+ StyledText {
+ Layout.topMargin: Appearance.spacing.large
+ text: qsTr("Applications (%1)").arg(root.searchText ? root.filteredApps.length : allAppsDb.apps.length)
+ font.pointSize: Appearance.font.size.normal
+ font.weight: 500
+ }
+
+ StyledText {
+ text: qsTr("All applications available in the launcher")
+ color: Colours.palette.m3outline
+ }
+
+ StyledRect {
+ Layout.fillWidth: true
+ Layout.topMargin: Appearance.spacing.normal
+ Layout.bottomMargin: Appearance.spacing.small
+
+ color: Colours.layer(Colours.palette.m3surfaceContainer, 2)
+ radius: Appearance.rounding.full
+
+ implicitHeight: Math.max(searchIcon.implicitHeight, searchField.implicitHeight, clearIcon.implicitHeight)
+
+ MaterialIcon {
+ id: searchIcon
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: parent.left
+ anchors.leftMargin: Appearance.padding.normal
+
+ text: "search"
+ color: Colours.palette.m3onSurfaceVariant
+ }
+
+ StyledTextField {
+ id: searchField
+
+ anchors.left: searchIcon.right
+ anchors.right: clearIcon.left
+ anchors.leftMargin: Appearance.spacing.small
+ anchors.rightMargin: Appearance.spacing.small
+
+ topPadding: Appearance.padding.normal
+ bottomPadding: Appearance.padding.normal
+
+ placeholderText: qsTr("Search applications...")
+
+ onTextChanged: {
+ root.searchText = text;
+ }
+ }
+
+ MaterialIcon {
+ id: clearIcon
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.right: parent.right
+ anchors.rightMargin: Appearance.padding.normal
+
+ width: searchField.text ? implicitWidth : implicitWidth / 2
+ opacity: {
+ if (!searchField.text)
+ return 0;
+ if (clearMouse.pressed)
+ return 0.7;
+ if (clearMouse.containsMouse)
+ return 0.8;
+ return 1;
+ }
+
+ text: "close"
+ color: Colours.palette.m3onSurfaceVariant
+
+ MouseArea {
+ id: clearMouse
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: searchField.text ? Qt.PointingHandCursor : undefined
+
+ onClicked: searchField.text = ""
+ }
+
+ Behavior on width {
+ Anim {
+ duration: Appearance.anim.durations.small
+ }
+ }
+
+ Behavior on opacity {
+ Anim {
+ duration: Appearance.anim.durations.small
+ }
+ }
+ }
+ }
+
+ Loader {
+ id: appsListLoader
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ asynchronous: true
+ active: true
+
+ sourceComponent: StyledListView {
+ id: appsListView
+
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+
+ model: root.filteredApps
+ spacing: Appearance.spacing.small / 2
+ clip: true
+
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: parent
+ }
+
+ delegate: StyledRect {
+ required property var modelData
+
+ width: parent ? parent.width : 0
+ implicitHeight: 40
+
+ readonly property bool isSelected: root.selectedApp === modelData
+
+ color: isSelected ? Colours.layer(Colours.palette.m3surfaceContainer, 2) : "transparent"
+ radius: Appearance.rounding.normal
+
+ opacity: 0
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: 1000
+ easing.type: Easing.OutCubic
+ }
+ }
+
+ Component.onCompleted: {
+ opacity = 1;
+ }
+
+ StateLayer {
+ function onClicked(): void {
+ root.session.launcher.active = modelData;
+ }
+ }
+
+ RowLayout {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.normal
+
+ spacing: Appearance.spacing.normal
+
+ IconImage {
+ Layout.alignment: Qt.AlignVCenter
+ implicitSize: 32
+ source: {
+ const entry = modelData.entry;
+ return entry ? Quickshell.iconPath(entry.icon, "image-missing") : "image-missing";
+ }
+ }
+
+ StyledText {
+ Layout.fillWidth: true
+ text: modelData.name || modelData.entry?.name || qsTr("Unknown")
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ Loader {
+ Layout.alignment: Qt.AlignVCenter
+ readonly property bool isHidden: modelData ? Strings.testRegexList(Config.launcher.hiddenApps, modelData.id) : false
+ readonly property bool isFav: modelData ? Strings.testRegexList(Config.launcher.favouriteApps, modelData.id) : false
+ active: isHidden || isFav
+
+ sourceComponent: isHidden ? hiddenIcon : (isFav ? favouriteIcon : null)
+ }
+
+ Component {
+ id: hiddenIcon
+ MaterialIcon {
+ text: "visibility_off"
+ fill: 1
+ color: Colours.palette.m3primary
+ }
+ }
+
+ Component {
+ id: favouriteIcon
+ MaterialIcon {
+ text: "favorite"
+ fill: 1
+ color: Colours.palette.m3primary
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ rightContent: Component {
+ Item {
+ id: rightLauncherPane
+
+ property var pane: root.session.launcher.active
+ property string paneId: pane ? (pane.id || pane.entry?.id || "") : ""
+ property Component targetComponent: settings
+ property Component nextComponent: settings
+ property var displayedApp: null
+
+ function getComponentForPane() {
+ return pane ? appDetails : settings;
+ }
+
+ Component.onCompleted: {
+ displayedApp = pane;
+ targetComponent = getComponentForPane();
+ nextComponent = targetComponent;
+ }
+
+ Loader {
+ id: rightLauncherLoader
+
+ anchors.fill: parent
+
+ opacity: 1
+ scale: 1
+ transformOrigin: Item.Center
+ clip: false
+
+ sourceComponent: rightLauncherPane.targetComponent
+ active: true
+
+ property var displayedApp: rightLauncherPane.displayedApp
+
+ onItemChanged: {
+ if (item && rightLauncherPane.pane && rightLauncherPane.displayedApp !== rightLauncherPane.pane) {
+ rightLauncherPane.displayedApp = rightLauncherPane.pane;
+ }
+ }
+ }
+
+ Behavior on paneId {
+ PaneTransition {
+ target: rightLauncherLoader
+ propertyActions: [
+ PropertyAction {
+ target: rightLauncherPane
+ property: "displayedApp"
+ value: rightLauncherPane.pane
+ },
+ PropertyAction {
+ target: rightLauncherLoader
+ property: "active"
+ value: false
+ },
+ PropertyAction {
+ target: rightLauncherPane
+ property: "targetComponent"
+ value: rightLauncherPane.nextComponent
+ },
+ PropertyAction {
+ target: rightLauncherLoader
+ property: "active"
+ value: true
+ }
+ ]
+ }
+ }
+
+ onPaneChanged: {
+ nextComponent = getComponentForPane();
+ paneId = pane ? (pane.id || pane.entry?.id || "") : "";
+ }
+
+ onDisplayedAppChanged: {
+ if (displayedApp) {
+ const appId = displayedApp.id || displayedApp.entry?.id;
+ root.hideFromLauncherChecked = Config.launcher.hiddenApps && Config.launcher.hiddenApps.length > 0 && Strings.testRegexList(Config.launcher.hiddenApps, appId);
+ root.favouriteChecked = Config.launcher.favouriteApps && Config.launcher.favouriteApps.length > 0 && Strings.testRegexList(Config.launcher.favouriteApps, appId);
+ } else {
+ root.hideFromLauncherChecked = false;
+ root.favouriteChecked = false;
+ }
+ }
+ }
+ }
+ }
+
+ Component {
+ id: settings
+
+ StyledFlickable {
+ id: settingsFlickable
+ flickableDirection: Flickable.VerticalFlick
+ contentHeight: settingsInner.height
+
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: settingsFlickable
+ }
+
+ Settings {
+ id: settingsInner
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ session: root.session
+ }
+ }
+ }
+
+ Component {
+ id: appDetails
+
+ ColumnLayout {
+ id: appDetailsLayout
+ anchors.fill: parent
+
+ readonly property var displayedApp: parent && parent.displayedApp !== undefined ? parent.displayedApp : null
+
+ spacing: Appearance.spacing.normal
+
+ SettingsHeader {
+ Layout.leftMargin: Appearance.padding.large * 2
+ Layout.rightMargin: Appearance.padding.large * 2
+ Layout.topMargin: Appearance.padding.large * 2
+ visible: displayedApp === null
+ icon: "apps"
+ title: qsTr("Launcher Applications")
+ }
+
+ Item {
+ Layout.alignment: Qt.AlignHCenter
+ Layout.leftMargin: Appearance.padding.large * 2
+ Layout.rightMargin: Appearance.padding.large * 2
+ Layout.topMargin: Appearance.padding.large * 2
+ visible: displayedApp !== null
+ implicitWidth: Math.max(appIconImage.implicitWidth, appTitleText.implicitWidth)
+ implicitHeight: appIconImage.implicitHeight + Appearance.spacing.normal + appTitleText.implicitHeight
+
+ ColumnLayout {
+ anchors.centerIn: parent
+ spacing: Appearance.spacing.normal
+
+ IconImage {
+ id: appIconImage
+ Layout.alignment: Qt.AlignHCenter
+ implicitSize: Appearance.font.size.extraLarge * 3 * 2
+ source: {
+ const app = appDetailsLayout.displayedApp;
+ if (!app)
+ return "image-missing";
+ const entry = app.entry;
+ if (entry && entry.icon) {
+ return Quickshell.iconPath(entry.icon, "image-missing");
+ }
+ return "image-missing";
+ }
+ }
+
+ StyledText {
+ id: appTitleText
+ Layout.alignment: Qt.AlignHCenter
+ text: displayedApp ? (displayedApp.name || displayedApp.entry?.name || qsTr("Application Details")) : ""
+ font.pointSize: Appearance.font.size.large
+ font.bold: true
+ }
+ }
+ }
+
+ Item {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ Layout.topMargin: Appearance.spacing.large
+ Layout.leftMargin: Appearance.padding.large * 2
+ Layout.rightMargin: Appearance.padding.large * 2
+
+ StyledFlickable {
+ id: detailsFlickable
+ anchors.fill: parent
+ flickableDirection: Flickable.VerticalFlick
+ contentHeight: debugLayout.height
+
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: parent
+ }
+
+ ColumnLayout {
+ id: debugLayout
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ spacing: Appearance.spacing.normal
+
+ SwitchRow {
+ Layout.topMargin: Appearance.spacing.normal
+ visible: appDetailsLayout.displayedApp !== null
+ label: qsTr("Mark as favourite")
+ checked: root.favouriteChecked
+ // disabled if:
+ // * app is hidden
+ // * app isn't in favouriteApps array but marked as favourite anyway
+ // ^^^ This means that this app is favourited because of a regex check
+ // this button can not toggle regexed apps
+ enabled: appDetailsLayout.displayedApp !== null && !root.hideFromLauncherChecked && (Config.launcher.favouriteApps.indexOf(appDetailsLayout.displayedApp.id || appDetailsLayout.displayedApp.entry?.id) !== -1 || !root.favouriteChecked)
+ opacity: enabled ? 1 : 0.6
+ onToggled: checked => {
+ root.favouriteChecked = checked;
+ const app = appDetailsLayout.displayedApp;
+ if (app) {
+ const appId = app.id || app.entry?.id;
+ const favouriteApps = Config.launcher.favouriteApps ? [...Config.launcher.favouriteApps] : [];
+ if (checked) {
+ if (!favouriteApps.includes(appId)) {
+ favouriteApps.push(appId);
+ }
+ } else {
+ const index = favouriteApps.indexOf(appId);
+ if (index !== -1) {
+ favouriteApps.splice(index, 1);
+ }
+ }
+ Config.launcher.favouriteApps = favouriteApps;
+ Config.save();
+ }
+ }
+ }
+ SwitchRow {
+ Layout.topMargin: Appearance.spacing.normal
+ visible: appDetailsLayout.displayedApp !== null
+ label: qsTr("Hide from launcher")
+ checked: root.hideFromLauncherChecked
+ // disabled if:
+ // * app is favourited
+ // * app isn't in hiddenApps array but marked as hidden anyway
+ // ^^^ This means that this app is hidden because of a regex check
+ // this button can not toggle regexed apps
+ enabled: appDetailsLayout.displayedApp !== null && !root.favouriteChecked && (Config.launcher.hiddenApps.indexOf(appDetailsLayout.displayedApp.id || appDetailsLayout.displayedApp.entry?.id) !== -1 || !root.hideFromLauncherChecked)
+ opacity: enabled ? 1 : 0.6
+ onToggled: checked => {
+ root.hideFromLauncherChecked = checked;
+ const app = appDetailsLayout.displayedApp;
+ if (app) {
+ const appId = app.id || app.entry?.id;
+ const hiddenApps = Config.launcher.hiddenApps ? [...Config.launcher.hiddenApps] : [];
+ if (checked) {
+ if (!hiddenApps.includes(appId)) {
+ hiddenApps.push(appId);
+ }
+ } else {
+ const index = hiddenApps.indexOf(appId);
+ if (index !== -1) {
+ hiddenApps.splice(index, 1);
+ }
+ }
+ Config.launcher.hiddenApps = hiddenApps;
+ Config.save();
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/launcher/Settings.qml b/.config/quickshell/caelestia/modules/controlcenter/launcher/Settings.qml
new file mode 100644
index 0000000..5eaf6e0
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/launcher/Settings.qml
@@ -0,0 +1,217 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../components"
+import qs.components
+import qs.components.controls
+import qs.components.effects
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ required property Session session
+
+ spacing: Appearance.spacing.normal
+
+ SettingsHeader {
+ icon: "apps"
+ title: qsTr("Launcher Settings")
+ }
+
+ SectionHeader {
+ Layout.topMargin: Appearance.spacing.large
+ title: qsTr("General")
+ description: qsTr("General launcher settings")
+ }
+
+ SectionContainer {
+ ToggleRow {
+ label: qsTr("Enabled")
+ checked: Config.launcher.enabled
+ toggle.onToggled: {
+ Config.launcher.enabled = checked;
+ Config.save();
+ }
+ }
+
+ ToggleRow {
+ label: qsTr("Show on hover")
+ checked: Config.launcher.showOnHover
+ toggle.onToggled: {
+ Config.launcher.showOnHover = checked;
+ Config.save();
+ }
+ }
+
+ ToggleRow {
+ label: qsTr("Vim keybinds")
+ checked: Config.launcher.vimKeybinds
+ toggle.onToggled: {
+ Config.launcher.vimKeybinds = checked;
+ Config.save();
+ }
+ }
+
+ ToggleRow {
+ label: qsTr("Enable dangerous actions")
+ checked: Config.launcher.enableDangerousActions
+ toggle.onToggled: {
+ Config.launcher.enableDangerousActions = checked;
+ Config.save();
+ }
+ }
+ }
+
+ SectionHeader {
+ Layout.topMargin: Appearance.spacing.large
+ title: qsTr("Display")
+ description: qsTr("Display and appearance settings")
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.small / 2
+
+ PropertyRow {
+ label: qsTr("Max shown items")
+ value: qsTr("%1").arg(Config.launcher.maxShown)
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ label: qsTr("Max wallpapers")
+ value: qsTr("%1").arg(Config.launcher.maxWallpapers)
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ label: qsTr("Drag threshold")
+ value: qsTr("%1 px").arg(Config.launcher.dragThreshold)
+ }
+ }
+
+ SectionHeader {
+ Layout.topMargin: Appearance.spacing.large
+ title: qsTr("Prefixes")
+ description: qsTr("Command prefix settings")
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.small / 2
+
+ PropertyRow {
+ label: qsTr("Special prefix")
+ value: Config.launcher.specialPrefix || qsTr("None")
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ label: qsTr("Action prefix")
+ value: Config.launcher.actionPrefix || qsTr("None")
+ }
+ }
+
+ SectionHeader {
+ Layout.topMargin: Appearance.spacing.large
+ title: qsTr("Fuzzy search")
+ description: qsTr("Fuzzy search settings")
+ }
+
+ SectionContainer {
+ ToggleRow {
+ label: qsTr("Apps")
+ checked: Config.launcher.useFuzzy.apps
+ toggle.onToggled: {
+ Config.launcher.useFuzzy.apps = checked;
+ Config.save();
+ }
+ }
+
+ ToggleRow {
+ label: qsTr("Actions")
+ checked: Config.launcher.useFuzzy.actions
+ toggle.onToggled: {
+ Config.launcher.useFuzzy.actions = checked;
+ Config.save();
+ }
+ }
+
+ ToggleRow {
+ label: qsTr("Schemes")
+ checked: Config.launcher.useFuzzy.schemes
+ toggle.onToggled: {
+ Config.launcher.useFuzzy.schemes = checked;
+ Config.save();
+ }
+ }
+
+ ToggleRow {
+ label: qsTr("Variants")
+ checked: Config.launcher.useFuzzy.variants
+ toggle.onToggled: {
+ Config.launcher.useFuzzy.variants = checked;
+ Config.save();
+ }
+ }
+
+ ToggleRow {
+ label: qsTr("Wallpapers")
+ checked: Config.launcher.useFuzzy.wallpapers
+ toggle.onToggled: {
+ Config.launcher.useFuzzy.wallpapers = checked;
+ Config.save();
+ }
+ }
+ }
+
+ SectionHeader {
+ Layout.topMargin: Appearance.spacing.large
+ title: qsTr("Sizes")
+ description: qsTr("Size settings for launcher items")
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.small / 2
+
+ PropertyRow {
+ label: qsTr("Item width")
+ value: qsTr("%1 px").arg(Config.launcher.sizes.itemWidth)
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ label: qsTr("Item height")
+ value: qsTr("%1 px").arg(Config.launcher.sizes.itemHeight)
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ label: qsTr("Wallpaper width")
+ value: qsTr("%1 px").arg(Config.launcher.sizes.wallpaperWidth)
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ label: qsTr("Wallpaper height")
+ value: qsTr("%1 px").arg(Config.launcher.sizes.wallpaperHeight)
+ }
+ }
+
+ SectionHeader {
+ Layout.topMargin: Appearance.spacing.large
+ title: qsTr("Hidden apps")
+ description: qsTr("Applications hidden from launcher")
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.small / 2
+
+ PropertyRow {
+ label: qsTr("Total hidden")
+ value: qsTr("%1").arg(Config.launcher.hiddenApps ? Config.launcher.hiddenApps.length : 0)
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/network/EthernetDetails.qml b/.config/quickshell/caelestia/modules/controlcenter/network/EthernetDetails.qml
new file mode 100644
index 0000000..4e60b3d
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/network/EthernetDetails.qml
@@ -0,0 +1,118 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../components"
+import qs.components
+import qs.components.controls
+import qs.components.effects
+import qs.components.containers
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+DeviceDetails {
+ id: root
+
+ required property Session session
+ readonly property var ethernetDevice: root.session.ethernet.active
+
+ device: ethernetDevice
+
+ Component.onCompleted: {
+ if (ethernetDevice && ethernetDevice.interface) {
+ Nmcli.getEthernetDeviceDetails(ethernetDevice.interface, () => {});
+ }
+ }
+
+ onEthernetDeviceChanged: {
+ if (ethernetDevice && ethernetDevice.interface) {
+ Nmcli.getEthernetDeviceDetails(ethernetDevice.interface, () => {});
+ } else {
+ Nmcli.ethernetDeviceDetails = null;
+ }
+ }
+
+ headerComponent: Component {
+ ConnectionHeader {
+ icon: "cable"
+ title: root.ethernetDevice?.interface ?? qsTr("Unknown")
+ }
+ }
+
+ sections: [
+ Component {
+ ColumnLayout {
+ spacing: Appearance.spacing.normal
+
+ SectionHeader {
+ title: qsTr("Connection status")
+ description: qsTr("Connection settings for this device")
+ }
+
+ SectionContainer {
+ ToggleRow {
+ label: qsTr("Connected")
+ checked: root.ethernetDevice?.connected ?? false
+ toggle.onToggled: {
+ if (checked) {
+ Nmcli.connectEthernet(root.ethernetDevice?.connection || "", root.ethernetDevice?.interface || "", () => {});
+ } else {
+ if (root.ethernetDevice?.connection) {
+ Nmcli.disconnectEthernet(root.ethernetDevice.connection, () => {});
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ Component {
+ ColumnLayout {
+ spacing: Appearance.spacing.normal
+
+ SectionHeader {
+ title: qsTr("Device properties")
+ description: qsTr("Additional information")
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.small / 2
+
+ PropertyRow {
+ label: qsTr("Interface")
+ value: root.ethernetDevice?.interface ?? qsTr("Unknown")
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ label: qsTr("Connection")
+ value: root.ethernetDevice?.connection || qsTr("Not connected")
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ label: qsTr("State")
+ value: root.ethernetDevice?.state ?? qsTr("Unknown")
+ }
+ }
+ }
+ },
+ Component {
+ ColumnLayout {
+ spacing: Appearance.spacing.normal
+
+ SectionHeader {
+ title: qsTr("Connection information")
+ description: qsTr("Network connection details")
+ }
+
+ SectionContainer {
+ ConnectionInfoSection {
+ deviceDetails: Nmcli.ethernetDeviceDetails
+ }
+ }
+ }
+ }
+ ]
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/network/EthernetList.qml b/.config/quickshell/caelestia/modules/controlcenter/network/EthernetList.qml
new file mode 100644
index 0000000..d1eb957
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/network/EthernetList.qml
@@ -0,0 +1,177 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../components"
+import qs.components
+import qs.components.controls
+import qs.components.containers
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+DeviceList {
+ id: root
+
+ required property Session session
+
+ title: qsTr("Devices (%1)").arg(Nmcli.ethernetDevices.length)
+ description: qsTr("All available ethernet devices")
+ activeItem: session.ethernet.active
+
+ model: Nmcli.ethernetDevices
+
+ headerComponent: Component {
+ RowLayout {
+ spacing: Appearance.spacing.smaller
+
+ StyledText {
+ text: qsTr("Settings")
+ font.pointSize: Appearance.font.size.large
+ font.weight: 500
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ ToggleButton {
+ toggled: !root.session.ethernet.active
+ icon: "settings"
+ accent: "Primary"
+ iconSize: Appearance.font.size.normal
+ horizontalPadding: Appearance.padding.normal
+ verticalPadding: Appearance.padding.smaller
+
+ onClicked: {
+ if (root.session.ethernet.active)
+ root.session.ethernet.active = null;
+ else {
+ root.session.ethernet.active = root.view.model.get(0)?.modelData ?? null;
+ }
+ }
+ }
+ }
+ }
+
+ delegate: Component {
+ StyledRect {
+ id: ethernetItem
+
+ required property var modelData
+ readonly property bool isActive: root.activeItem && modelData && root.activeItem.interface === modelData.interface
+
+ width: ListView.view ? ListView.view.width : undefined
+ implicitHeight: rowLayout.implicitHeight + Appearance.padding.normal * 2
+
+ color: Qt.alpha(Colours.tPalette.m3surfaceContainer, ethernetItem.isActive ? Colours.tPalette.m3surfaceContainer.a : 0)
+ radius: Appearance.rounding.normal
+
+ StateLayer {
+ id: stateLayer
+
+ function onClicked(): void {
+ root.session.ethernet.active = modelData;
+ }
+ }
+
+ RowLayout {
+ id: rowLayout
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.normal
+
+ spacing: Appearance.spacing.normal
+
+ StyledRect {
+ implicitWidth: implicitHeight
+ implicitHeight: icon.implicitHeight + Appearance.padding.normal * 2
+
+ radius: Appearance.rounding.normal
+ color: modelData.connected ? Colours.palette.m3primaryContainer : Colours.tPalette.m3surfaceContainerHigh
+
+ StyledRect {
+ anchors.fill: parent
+ radius: parent.radius
+ color: Qt.alpha(modelData.connected ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface, stateLayer.pressed ? 0.1 : stateLayer.containsMouse ? 0.08 : 0)
+ }
+
+ MaterialIcon {
+ id: icon
+
+ anchors.centerIn: parent
+ text: "cable"
+ font.pointSize: Appearance.font.size.large
+ fill: modelData.connected ? 1 : 0
+ color: modelData.connected ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface
+
+ Behavior on fill {
+ Anim {}
+ }
+ }
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+
+ spacing: 0
+
+ StyledText {
+ Layout.fillWidth: true
+ text: modelData.interface || qsTr("Unknown")
+ elide: Text.ElideRight
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.smaller
+
+ StyledText {
+ Layout.fillWidth: true
+ text: modelData.connected ? qsTr("Connected") : qsTr("Disconnected")
+ color: modelData.connected ? Colours.palette.m3primary : Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ font.weight: modelData.connected ? 500 : 400
+ elide: Text.ElideRight
+ }
+ }
+ }
+
+ StyledRect {
+ id: connectBtn
+
+ implicitWidth: implicitHeight
+ implicitHeight: connectIcon.implicitHeight + Appearance.padding.smaller * 2
+
+ radius: Appearance.rounding.full
+ color: Qt.alpha(Colours.palette.m3primaryContainer, modelData.connected ? 1 : 0)
+
+ StateLayer {
+ color: modelData.connected ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface
+
+ function onClicked(): void {
+ if (modelData.connected && modelData.connection) {
+ Nmcli.disconnectEthernet(modelData.connection, () => {});
+ } else {
+ Nmcli.connectEthernet(modelData.connection || "", modelData.interface || "", () => {});
+ }
+ }
+ }
+
+ MaterialIcon {
+ id: connectIcon
+
+ anchors.centerIn: parent
+ animate: true
+ text: modelData.connected ? "link_off" : "link"
+ color: modelData.connected ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface
+ }
+ }
+ }
+ }
+ }
+
+ onItemSelected: function (item) {
+ session.ethernet.active = item;
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/network/EthernetPane.qml b/.config/quickshell/caelestia/modules/controlcenter/network/EthernetPane.qml
new file mode 100644
index 0000000..59d82bb
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/network/EthernetPane.qml
@@ -0,0 +1,50 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../components"
+import qs.components
+import qs.components.containers
+import qs.config
+import Quickshell.Widgets
+import QtQuick
+
+SplitPaneWithDetails {
+ id: root
+
+ required property Session session
+
+ anchors.fill: parent
+
+ activeItem: session.ethernet.active
+ paneIdGenerator: function (item) {
+ return item ? (item.interface || "") : "";
+ }
+
+ leftContent: Component {
+ EthernetList {
+ session: root.session
+ }
+ }
+
+ rightDetailsComponent: Component {
+ EthernetDetails {
+ session: root.session
+ }
+ }
+
+ rightSettingsComponent: Component {
+ StyledFlickable {
+ flickableDirection: Flickable.VerticalFlick
+ contentHeight: settingsInner.height
+ clip: true
+
+ EthernetSettings {
+ id: settingsInner
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ session: root.session
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/network/EthernetSettings.qml b/.config/quickshell/caelestia/modules/controlcenter/network/EthernetSettings.qml
new file mode 100644
index 0000000..90bfcf4
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/network/EthernetSettings.qml
@@ -0,0 +1,76 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../components"
+import qs.components
+import qs.components.controls
+import qs.components.effects
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ required property Session session
+
+ spacing: Appearance.spacing.normal
+
+ SettingsHeader {
+ icon: "cable"
+ title: qsTr("Ethernet settings")
+ }
+
+ StyledText {
+ Layout.topMargin: Appearance.spacing.large
+ text: qsTr("Ethernet devices")
+ font.pointSize: Appearance.font.size.larger
+ font.weight: 500
+ }
+
+ StyledText {
+ text: qsTr("Available ethernet devices")
+ color: Colours.palette.m3outline
+ }
+
+ StyledRect {
+ Layout.fillWidth: true
+ implicitHeight: ethernetInfo.implicitHeight + Appearance.padding.large * 2
+
+ radius: Appearance.rounding.normal
+ color: Colours.tPalette.m3surfaceContainer
+
+ ColumnLayout {
+ id: ethernetInfo
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.large
+
+ spacing: Appearance.spacing.small / 2
+
+ StyledText {
+ text: qsTr("Total devices")
+ }
+
+ StyledText {
+ text: qsTr("%1").arg(Nmcli.ethernetDevices.length)
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ }
+
+ StyledText {
+ Layout.topMargin: Appearance.spacing.normal
+ text: qsTr("Connected devices")
+ }
+
+ StyledText {
+ text: qsTr("%1").arg(Nmcli.ethernetDevices.filter(d => d.connected).length)
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/network/NetworkSettings.qml b/.config/quickshell/caelestia/modules/controlcenter/network/NetworkSettings.qml
new file mode 100644
index 0000000..bda7cb1
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/network/NetworkSettings.qml
@@ -0,0 +1,171 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../components"
+import qs.components
+import qs.components.controls
+import qs.components.containers
+import qs.components.effects
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ required property Session session
+
+ spacing: Appearance.spacing.normal
+
+ SettingsHeader {
+ icon: "router"
+ title: qsTr("Network Settings")
+ }
+
+ SectionHeader {
+ Layout.topMargin: Appearance.spacing.large
+ title: qsTr("Ethernet")
+ description: qsTr("Ethernet device information")
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.small / 2
+
+ PropertyRow {
+ label: qsTr("Total devices")
+ value: qsTr("%1").arg(Nmcli.ethernetDevices.length)
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ label: qsTr("Connected devices")
+ value: qsTr("%1").arg(Nmcli.ethernetDevices.filter(d => d.connected).length)
+ }
+ }
+
+ SectionHeader {
+ Layout.topMargin: Appearance.spacing.large
+ title: qsTr("Wireless")
+ description: qsTr("WiFi network settings")
+ }
+
+ SectionContainer {
+ ToggleRow {
+ label: qsTr("WiFi enabled")
+ checked: Nmcli.wifiEnabled
+ toggle.onToggled: {
+ Nmcli.enableWifi(checked);
+ }
+ }
+ }
+
+ SectionHeader {
+ Layout.topMargin: Appearance.spacing.large
+ title: qsTr("VPN")
+ description: qsTr("VPN provider settings")
+ visible: Config.utilities.vpn.enabled || Config.utilities.vpn.provider.length > 0
+ }
+
+ SectionContainer {
+ visible: Config.utilities.vpn.enabled || Config.utilities.vpn.provider.length > 0
+
+ ToggleRow {
+ label: qsTr("VPN enabled")
+ checked: Config.utilities.vpn.enabled
+ toggle.onToggled: {
+ Config.utilities.vpn.enabled = checked;
+ Config.save();
+ }
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ label: qsTr("Providers")
+ value: qsTr("%1").arg(Config.utilities.vpn.provider.length)
+ }
+
+ TextButton {
+ Layout.fillWidth: true
+ Layout.topMargin: Appearance.spacing.normal
+ Layout.minimumHeight: Appearance.font.size.normal + Appearance.padding.normal * 2
+ text: qsTr("⚙ Manage VPN Providers")
+ inactiveColour: Colours.palette.m3secondaryContainer
+ inactiveOnColour: Colours.palette.m3onSecondaryContainer
+
+ onClicked: {
+ vpnSettingsDialog.open();
+ }
+ }
+ }
+
+ SectionHeader {
+ Layout.topMargin: Appearance.spacing.large
+ title: qsTr("Current connection")
+ description: qsTr("Active network connection information")
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.small / 2
+
+ PropertyRow {
+ label: qsTr("Network")
+ value: Nmcli.active ? Nmcli.active.ssid : (Nmcli.activeEthernet ? Nmcli.activeEthernet.interface : qsTr("Not connected"))
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ visible: Nmcli.active !== null
+ label: qsTr("Signal strength")
+ value: Nmcli.active ? qsTr("%1%").arg(Nmcli.active.strength) : qsTr("N/A")
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ visible: Nmcli.active !== null
+ label: qsTr("Security")
+ value: Nmcli.active ? (Nmcli.active.isSecure ? qsTr("Secured") : qsTr("Open")) : qsTr("N/A")
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ visible: Nmcli.active !== null
+ label: qsTr("Frequency")
+ value: Nmcli.active ? qsTr("%1 MHz").arg(Nmcli.active.frequency) : qsTr("N/A")
+ }
+ }
+
+ Popup {
+ id: vpnSettingsDialog
+
+ parent: Overlay.overlay
+ anchors.centerIn: parent
+ width: Math.min(600, parent.width - Appearance.padding.large * 2)
+ height: Math.min(700, parent.height - Appearance.padding.large * 2)
+
+ modal: true
+ closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
+
+ background: StyledRect {
+ color: Colours.palette.m3surface
+ radius: Appearance.rounding.large
+ }
+
+ StyledFlickable {
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.large * 1.5
+ flickableDirection: Flickable.VerticalFlick
+ contentHeight: vpnSettingsContent.height
+ clip: true
+
+ VpnSettings {
+ id: vpnSettingsContent
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ session: root.session
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/network/NetworkingPane.qml b/.config/quickshell/caelestia/modules/controlcenter/network/NetworkingPane.qml
new file mode 100644
index 0000000..26cdbfa
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/network/NetworkingPane.qml
@@ -0,0 +1,373 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../components"
+import "."
+import qs.components
+import qs.components.controls
+import qs.components.effects
+import qs.components.containers
+import qs.services
+import qs.config
+import qs.utils
+import Quickshell
+import Quickshell.Widgets
+import QtQuick
+import QtQuick.Layouts
+
+Item {
+ id: root
+
+ required property Session session
+
+ anchors.fill: parent
+
+ SplitPaneLayout {
+ id: splitLayout
+
+ anchors.fill: parent
+
+ leftContent: Component {
+ StyledFlickable {
+ id: leftFlickable
+
+ flickableDirection: Flickable.VerticalFlick
+ contentHeight: leftContent.height
+
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: leftFlickable
+ }
+
+ ColumnLayout {
+ id: leftContent
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ spacing: Appearance.spacing.normal
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.smaller
+
+ StyledText {
+ text: qsTr("Network")
+ font.pointSize: Appearance.font.size.large
+ font.weight: 500
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ ToggleButton {
+ toggled: Nmcli.wifiEnabled
+ icon: "wifi"
+ accent: "Tertiary"
+ iconSize: Appearance.font.size.normal
+ horizontalPadding: Appearance.padding.normal
+ verticalPadding: Appearance.padding.smaller
+ tooltip: qsTr("Toggle WiFi")
+
+ onClicked: {
+ Nmcli.toggleWifi(null);
+ }
+ }
+
+ ToggleButton {
+ toggled: Nmcli.scanning
+ icon: "wifi_find"
+ accent: "Secondary"
+ iconSize: Appearance.font.size.normal
+ horizontalPadding: Appearance.padding.normal
+ verticalPadding: Appearance.padding.smaller
+ tooltip: qsTr("Scan for networks")
+
+ onClicked: {
+ Nmcli.rescanWifi();
+ }
+ }
+
+ ToggleButton {
+ toggled: !root.session.ethernet.active && !root.session.network.active
+ icon: "settings"
+ accent: "Primary"
+ iconSize: Appearance.font.size.normal
+ horizontalPadding: Appearance.padding.normal
+ verticalPadding: Appearance.padding.smaller
+ tooltip: qsTr("Network settings")
+
+ onClicked: {
+ if (root.session.ethernet.active || root.session.network.active) {
+ root.session.ethernet.active = null;
+ root.session.network.active = null;
+ } else {
+ if (Nmcli.ethernetDevices.length > 0) {
+ root.session.ethernet.active = Nmcli.ethernetDevices[0];
+ } else if (Nmcli.networks.length > 0) {
+ root.session.network.active = Nmcli.networks[0];
+ }
+ }
+ }
+ }
+ }
+
+ CollapsibleSection {
+ id: vpnListSection
+
+ Layout.fillWidth: true
+ title: qsTr("VPN")
+ expanded: true
+
+ Loader {
+ Layout.fillWidth: true
+ sourceComponent: Component {
+ VpnList {
+ session: root.session
+ showHeader: false
+ }
+ }
+ }
+ }
+
+ CollapsibleSection {
+ id: ethernetListSection
+
+ Layout.fillWidth: true
+ title: qsTr("Ethernet")
+ expanded: true
+
+ Loader {
+ Layout.fillWidth: true
+ sourceComponent: Component {
+ EthernetList {
+ session: root.session
+ showHeader: false
+ }
+ }
+ }
+ }
+
+ CollapsibleSection {
+ id: wirelessListSection
+
+ Layout.fillWidth: true
+ title: qsTr("Wireless")
+ expanded: true
+
+ Loader {
+ Layout.fillWidth: true
+ sourceComponent: Component {
+ WirelessList {
+ session: root.session
+ showHeader: false
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ rightContent: Component {
+ Item {
+ id: rightPaneItem
+
+ property var vpnPane: root.session && root.session.vpn ? root.session.vpn.active : null
+ property var ethernetPane: root.session && root.session.ethernet ? root.session.ethernet.active : null
+ property var wirelessPane: root.session && root.session.network ? root.session.network.active : null
+ property var pane: vpnPane || ethernetPane || wirelessPane
+ property string paneId: vpnPane ? ("vpn:" + (vpnPane.name || "")) : (ethernetPane ? ("eth:" + (ethernetPane.interface || "")) : (wirelessPane ? ("wifi:" + (wirelessPane.ssid || wirelessPane.bssid || "")) : "settings"))
+ property Component targetComponent: settingsComponent
+ property Component nextComponent: settingsComponent
+
+ function getComponentForPane() {
+ if (vpnPane)
+ return vpnDetailsComponent;
+ if (ethernetPane)
+ return ethernetDetailsComponent;
+ if (wirelessPane)
+ return wirelessDetailsComponent;
+ return settingsComponent;
+ }
+
+ Component.onCompleted: {
+ targetComponent = getComponentForPane();
+ nextComponent = targetComponent;
+ }
+
+ Connections {
+ target: root.session && root.session.vpn ? root.session.vpn : null
+ enabled: target !== null
+
+ function onActiveChanged() {
+ // Clear others when VPN is selected
+ if (root.session && root.session.vpn && root.session.vpn.active) {
+ if (root.session.ethernet && root.session.ethernet.active)
+ root.session.ethernet.active = null;
+ if (root.session.network && root.session.network.active)
+ root.session.network.active = null;
+ }
+ rightPaneItem.nextComponent = rightPaneItem.getComponentForPane();
+ }
+ }
+
+ Connections {
+ target: root.session && root.session.ethernet ? root.session.ethernet : null
+ enabled: target !== null
+
+ function onActiveChanged() {
+ // Clear others when ethernet is selected
+ if (root.session && root.session.ethernet && root.session.ethernet.active) {
+ if (root.session.vpn && root.session.vpn.active)
+ root.session.vpn.active = null;
+ if (root.session.network && root.session.network.active)
+ root.session.network.active = null;
+ }
+ rightPaneItem.nextComponent = rightPaneItem.getComponentForPane();
+ }
+ }
+
+ Connections {
+ target: root.session && root.session.network ? root.session.network : null
+ enabled: target !== null
+
+ function onActiveChanged() {
+ // Clear others when wireless is selected
+ if (root.session && root.session.network && root.session.network.active) {
+ if (root.session.vpn && root.session.vpn.active)
+ root.session.vpn.active = null;
+ if (root.session.ethernet && root.session.ethernet.active)
+ root.session.ethernet.active = null;
+ }
+ rightPaneItem.nextComponent = rightPaneItem.getComponentForPane();
+ }
+ }
+
+ Loader {
+ id: rightLoader
+
+ anchors.fill: parent
+
+ opacity: 1
+ scale: 1
+ transformOrigin: Item.Center
+ clip: false
+
+ asynchronous: true
+ sourceComponent: rightPaneItem.targetComponent
+ }
+
+ Behavior on paneId {
+ PaneTransition {
+ target: rightLoader
+ propertyActions: [
+ PropertyAction {
+ target: rightPaneItem
+ property: "targetComponent"
+ value: rightPaneItem.nextComponent
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+
+ Component {
+ id: settingsComponent
+
+ StyledFlickable {
+ id: settingsFlickable
+ flickableDirection: Flickable.VerticalFlick
+ contentHeight: settingsInner.height
+
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: settingsFlickable
+ }
+
+ NetworkSettings {
+ id: settingsInner
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ session: root.session
+ }
+ }
+ }
+
+ Component {
+ id: ethernetDetailsComponent
+
+ StyledFlickable {
+ id: ethernetFlickable
+ flickableDirection: Flickable.VerticalFlick
+ contentHeight: ethernetDetailsInner.height
+
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: ethernetFlickable
+ }
+
+ EthernetDetails {
+ id: ethernetDetailsInner
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ session: root.session
+ }
+ }
+ }
+
+ Component {
+ id: wirelessDetailsComponent
+
+ StyledFlickable {
+ id: wirelessFlickable
+ flickableDirection: Flickable.VerticalFlick
+ contentHeight: wirelessDetailsInner.height
+
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: wirelessFlickable
+ }
+
+ WirelessDetails {
+ id: wirelessDetailsInner
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ session: root.session
+ }
+ }
+ }
+
+ Component {
+ id: vpnDetailsComponent
+
+ StyledFlickable {
+ id: vpnFlickable
+ flickableDirection: Flickable.VerticalFlick
+ contentHeight: vpnDetailsInner.height
+
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: vpnFlickable
+ }
+
+ VpnDetails {
+ id: vpnDetailsInner
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ session: root.session
+ }
+ }
+ }
+
+ WirelessPasswordDialog {
+ anchors.fill: parent
+ session: root.session
+ z: 1000
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/network/VpnDetails.qml b/.config/quickshell/caelestia/modules/controlcenter/network/VpnDetails.qml
new file mode 100644
index 0000000..1c71cd7
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/network/VpnDetails.qml
@@ -0,0 +1,396 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../components"
+import qs.components
+import qs.components.controls
+import qs.components.effects
+import qs.components.containers
+import qs.services
+import qs.config
+import qs.utils
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+
+DeviceDetails {
+ id: root
+
+ required property Session session
+ readonly property var vpnProvider: root.session.vpn.active
+ readonly property bool providerEnabled: {
+ if (!vpnProvider || vpnProvider.index === undefined)
+ return false;
+ const provider = Config.utilities.vpn.provider[vpnProvider.index];
+ return provider && typeof provider === "object" && provider.enabled === true;
+ }
+
+ device: vpnProvider
+
+ headerComponent: Component {
+ ConnectionHeader {
+ icon: "vpn_key"
+ title: root.vpnProvider?.displayName ?? qsTr("Unknown")
+ }
+ }
+
+ sections: [
+ Component {
+ ColumnLayout {
+ spacing: Appearance.spacing.normal
+
+ SectionHeader {
+ title: qsTr("Connection status")
+ description: qsTr("VPN connection settings")
+ }
+
+ SectionContainer {
+ ToggleRow {
+ label: qsTr("Enable this provider")
+ checked: root.providerEnabled
+ toggle.onToggled: {
+ if (!root.vpnProvider)
+ return;
+ const providers = [];
+ const index = root.vpnProvider.index;
+
+ // Copy providers and update enabled state
+ for (let i = 0; i < Config.utilities.vpn.provider.length; i++) {
+ const p = Config.utilities.vpn.provider[i];
+ if (typeof p === "object") {
+ const newProvider = {
+ name: p.name,
+ displayName: p.displayName,
+ interface: p.interface
+ };
+
+ if (checked) {
+ // Enable this one, disable others
+ newProvider.enabled = (i === index);
+ } else {
+ // Just disable this one
+ newProvider.enabled = (i === index) ? false : (p.enabled !== false);
+ }
+
+ providers.push(newProvider);
+ } else {
+ providers.push(p);
+ }
+ }
+
+ Config.utilities.vpn.provider = providers;
+ Config.save();
+ }
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ Layout.topMargin: Appearance.spacing.normal
+ spacing: Appearance.spacing.normal
+
+ TextButton {
+ Layout.fillWidth: true
+ Layout.minimumHeight: Appearance.font.size.normal + Appearance.padding.normal * 2
+ visible: root.providerEnabled
+ enabled: !VPN.connecting
+ inactiveColour: Colours.palette.m3primaryContainer
+ inactiveOnColour: Colours.palette.m3onPrimaryContainer
+ text: VPN.connected ? qsTr("Disconnect") : qsTr("Connect")
+
+ onClicked: {
+ VPN.toggle();
+ }
+ }
+
+ TextButton {
+ Layout.fillWidth: true
+ text: qsTr("Edit Provider")
+ inactiveColour: Colours.palette.m3secondaryContainer
+ inactiveOnColour: Colours.palette.m3onSecondaryContainer
+
+ onClicked: {
+ editVpnDialog.editIndex = root.vpnProvider.index;
+ editVpnDialog.providerName = root.vpnProvider.name;
+ editVpnDialog.displayName = root.vpnProvider.displayName;
+ editVpnDialog.interfaceName = root.vpnProvider.interface;
+ editVpnDialog.open();
+ }
+ }
+
+ TextButton {
+ Layout.fillWidth: true
+ text: qsTr("Delete Provider")
+ inactiveColour: Colours.palette.m3errorContainer
+ inactiveOnColour: Colours.palette.m3onErrorContainer
+
+ onClicked: {
+ const providers = [];
+ for (let i = 0; i < Config.utilities.vpn.provider.length; i++) {
+ if (i !== root.vpnProvider.index) {
+ providers.push(Config.utilities.vpn.provider[i]);
+ }
+ }
+ Config.utilities.vpn.provider = providers;
+ Config.save();
+ root.session.vpn.active = null;
+ }
+ }
+ }
+ }
+ }
+ },
+ Component {
+ ColumnLayout {
+ spacing: Appearance.spacing.normal
+
+ SectionHeader {
+ title: qsTr("Provider details")
+ description: qsTr("VPN provider information")
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.small / 2
+
+ PropertyRow {
+ label: qsTr("Provider")
+ value: root.vpnProvider?.name ?? qsTr("Unknown")
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ label: qsTr("Display name")
+ value: root.vpnProvider?.displayName ?? qsTr("Unknown")
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ label: qsTr("Interface")
+ value: root.vpnProvider?.interface || qsTr("N/A")
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ label: qsTr("Status")
+ value: {
+ if (!root.providerEnabled)
+ return qsTr("Disabled");
+ if (VPN.connecting)
+ return qsTr("Connecting...");
+ if (VPN.connected)
+ return qsTr("Connected");
+ return qsTr("Enabled (Not connected)");
+ }
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ label: qsTr("Enabled")
+ value: root.providerEnabled ? qsTr("Yes") : qsTr("No")
+ }
+ }
+ }
+ }
+ ]
+
+ // Edit VPN Dialog
+ Popup {
+ id: editVpnDialog
+
+ property int editIndex: -1
+ property string providerName: ""
+ property string displayName: ""
+ property string interfaceName: ""
+
+ parent: Overlay.overlay
+ anchors.centerIn: parent
+ width: Math.min(400, parent.width - Appearance.padding.large * 2)
+ padding: Appearance.padding.large * 1.5
+
+ modal: true
+ closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
+
+ opacity: 0
+ scale: 0.7
+
+ enter: Transition {
+ Anim {
+ property: "opacity"
+ from: 0
+ to: 1
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
+ }
+ Anim {
+ property: "scale"
+ from: 0.7
+ to: 1
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
+ }
+ }
+
+ exit: Transition {
+ Anim {
+ property: "opacity"
+ from: 1
+ to: 0
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
+ }
+ Anim {
+ property: "scale"
+ from: 1
+ to: 0.7
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
+ }
+ }
+
+ function closeWithAnimation(): void {
+ close();
+ }
+
+ Overlay.modal: Rectangle {
+ color: Qt.rgba(0, 0, 0, 0.4 * editVpnDialog.opacity)
+ }
+
+ background: StyledRect {
+ color: Colours.palette.m3surfaceContainerHigh
+ radius: Appearance.rounding.large
+
+ Elevation {
+ anchors.fill: parent
+ radius: parent.radius
+ level: 3
+ z: -1
+ }
+ }
+
+ contentItem: ColumnLayout {
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ text: qsTr("Edit VPN Provider")
+ font.pointSize: Appearance.font.size.large
+ font.weight: 500
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.smaller / 2
+
+ StyledText {
+ text: qsTr("Display Name")
+ font.pointSize: Appearance.font.size.small
+ color: Colours.palette.m3onSurfaceVariant
+ }
+
+ StyledRect {
+ Layout.fillWidth: true
+ implicitHeight: 40
+ color: displayNameField.activeFocus ? Colours.layer(Colours.palette.m3surfaceContainer, 3) : Colours.layer(Colours.palette.m3surfaceContainer, 2)
+ radius: Appearance.rounding.small
+ border.width: 1
+ border.color: displayNameField.activeFocus ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3outline, 0.3)
+
+ Behavior on color {
+ CAnim {}
+ }
+ Behavior on border.color {
+ CAnim {}
+ }
+
+ StyledTextField {
+ id: displayNameField
+ anchors.centerIn: parent
+ width: parent.width - Appearance.padding.normal
+ horizontalAlignment: TextInput.AlignLeft
+ text: editVpnDialog.displayName
+ onTextChanged: editVpnDialog.displayName = text
+ }
+ }
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.smaller / 2
+
+ StyledText {
+ text: qsTr("Interface (e.g., wg0, torguard)")
+ font.pointSize: Appearance.font.size.small
+ color: Colours.palette.m3onSurfaceVariant
+ }
+
+ StyledRect {
+ Layout.fillWidth: true
+ implicitHeight: 40
+ color: interfaceNameField.activeFocus ? Colours.layer(Colours.palette.m3surfaceContainer, 3) : Colours.layer(Colours.palette.m3surfaceContainer, 2)
+ radius: Appearance.rounding.small
+ border.width: 1
+ border.color: interfaceNameField.activeFocus ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3outline, 0.3)
+
+ Behavior on color {
+ CAnim {}
+ }
+ Behavior on border.color {
+ CAnim {}
+ }
+
+ StyledTextField {
+ id: interfaceNameField
+ anchors.centerIn: parent
+ width: parent.width - Appearance.padding.normal
+ horizontalAlignment: TextInput.AlignLeft
+ text: editVpnDialog.interfaceName
+ onTextChanged: editVpnDialog.interfaceName = text
+ }
+ }
+ }
+
+ RowLayout {
+ Layout.topMargin: Appearance.spacing.normal
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+
+ TextButton {
+ Layout.fillWidth: true
+ text: qsTr("Cancel")
+ inactiveColour: Colours.tPalette.m3surfaceContainerHigh
+ inactiveOnColour: Colours.palette.m3onSurface
+ onClicked: editVpnDialog.closeWithAnimation()
+ }
+
+ TextButton {
+ Layout.fillWidth: true
+ text: qsTr("Save")
+ enabled: editVpnDialog.interfaceName.length > 0
+ inactiveColour: Colours.palette.m3primaryContainer
+ inactiveOnColour: Colours.palette.m3onPrimaryContainer
+
+ onClicked: {
+ const providers = [];
+ const oldProvider = Config.utilities.vpn.provider[editVpnDialog.editIndex];
+ const wasEnabled = typeof oldProvider === "object" ? (oldProvider.enabled !== false) : true;
+
+ for (let i = 0; i < Config.utilities.vpn.provider.length; i++) {
+ if (i === editVpnDialog.editIndex) {
+ providers.push({
+ name: editVpnDialog.providerName,
+ displayName: editVpnDialog.displayName || editVpnDialog.interfaceName,
+ interface: editVpnDialog.interfaceName,
+ enabled: wasEnabled
+ });
+ } else {
+ providers.push(Config.utilities.vpn.provider[i]);
+ }
+ }
+
+ Config.utilities.vpn.provider = providers;
+ Config.save();
+ editVpnDialog.closeWithAnimation();
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/network/VpnList.qml b/.config/quickshell/caelestia/modules/controlcenter/network/VpnList.qml
new file mode 100644
index 0000000..81f4a45
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/network/VpnList.qml
@@ -0,0 +1,686 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import qs.components
+import qs.components.controls
+import qs.components.effects
+import qs.services
+import qs.config
+import Quickshell
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ required property Session session
+ property bool showHeader: true
+ property int pendingSwitchIndex: -1
+
+ spacing: Appearance.spacing.normal
+
+ Connections {
+ target: VPN
+ function onConnectedChanged() {
+ if (!VPN.connected && root.pendingSwitchIndex >= 0) {
+ const targetIndex = root.pendingSwitchIndex;
+ root.pendingSwitchIndex = -1;
+
+ const providers = [];
+ for (let i = 0; i < Config.utilities.vpn.provider.length; i++) {
+ const p = Config.utilities.vpn.provider[i];
+ if (typeof p === "object") {
+ const newProvider = {
+ name: p.name,
+ displayName: p.displayName,
+ interface: p.interface,
+ enabled: (i === targetIndex)
+ };
+ providers.push(newProvider);
+ } else {
+ providers.push(p);
+ }
+ }
+ Config.utilities.vpn.provider = providers;
+ Config.save();
+
+ Qt.callLater(function () {
+ VPN.toggle();
+ });
+ }
+ }
+ }
+
+ TextButton {
+ Layout.fillWidth: true
+ text: qsTr("+ Add VPN Provider")
+ inactiveColour: Colours.palette.m3primaryContainer
+ inactiveOnColour: Colours.palette.m3onPrimaryContainer
+
+ onClicked: {
+ vpnDialog.showProviderSelection();
+ }
+ }
+
+ ListView {
+ id: listView
+
+ Layout.fillWidth: true
+ Layout.preferredHeight: contentHeight
+
+ interactive: false
+ spacing: Appearance.spacing.smaller
+
+ model: ScriptModel {
+ values: Config.utilities.vpn.provider.map((provider, index) => {
+ const isObject = typeof provider === "object";
+ const name = isObject ? (provider.name || "custom") : String(provider);
+ const displayName = isObject ? (provider.displayName || name) : name;
+ const iface = isObject ? (provider.interface || "") : "";
+ const enabled = isObject ? (provider.enabled === true) : false;
+
+ return {
+ index: index,
+ name: name,
+ displayName: displayName,
+ interface: iface,
+ provider: provider,
+ enabled: enabled
+ };
+ })
+ }
+
+ delegate: Component {
+ StyledRect {
+ required property var modelData
+ required property int index
+
+ width: ListView.view ? ListView.view.width : undefined
+
+ color: Qt.alpha(Colours.tPalette.m3surfaceContainer, (root.session && root.session.vpn && root.session.vpn.active === modelData) ? Colours.tPalette.m3surfaceContainer.a : 0)
+ radius: Appearance.rounding.normal
+
+ StateLayer {
+ function onClicked(): void {
+ if (root.session && root.session.vpn) {
+ root.session.vpn.active = modelData;
+ }
+ }
+ }
+
+ RowLayout {
+ id: rowLayout
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.normal
+
+ spacing: Appearance.spacing.normal
+
+ StyledRect {
+ implicitWidth: implicitHeight
+ implicitHeight: icon.implicitHeight + Appearance.padding.normal * 2
+
+ radius: Appearance.rounding.normal
+ color: modelData.enabled && VPN.connected ? Colours.palette.m3primaryContainer : Colours.tPalette.m3surfaceContainerHigh
+
+ MaterialIcon {
+ id: icon
+
+ anchors.centerIn: parent
+ text: modelData.enabled && VPN.connected ? "vpn_key" : "vpn_key_off"
+ font.pointSize: Appearance.font.size.large
+ fill: modelData.enabled && VPN.connected ? 1 : 0
+ color: modelData.enabled && VPN.connected ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface
+ }
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+
+ spacing: 0
+
+ StyledText {
+ Layout.fillWidth: true
+ elide: Text.ElideRight
+ maximumLineCount: 1
+
+ text: modelData.displayName || qsTr("Unknown")
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.smaller
+
+ StyledText {
+ Layout.fillWidth: true
+ text: {
+ if (modelData.enabled && VPN.connected)
+ return qsTr("Connected");
+ if (modelData.enabled && VPN.connecting)
+ return qsTr("Connecting...");
+ if (modelData.enabled)
+ return qsTr("Enabled");
+ return qsTr("Disabled");
+ }
+ color: modelData.enabled ? (VPN.connected ? Colours.palette.m3primary : Colours.palette.m3onSurface) : Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ font.weight: modelData.enabled && VPN.connected ? 500 : 400
+ elide: Text.ElideRight
+ }
+ }
+ }
+
+ StyledRect {
+ implicitWidth: implicitHeight
+ implicitHeight: connectIcon.implicitHeight + Appearance.padding.smaller * 2
+
+ radius: Appearance.rounding.full
+ color: Qt.alpha(Colours.palette.m3primaryContainer, VPN.connected && modelData.enabled ? 1 : 0)
+
+ StateLayer {
+ enabled: !VPN.connecting
+ function onClicked(): void {
+ const clickedIndex = modelData.index;
+
+ if (modelData.enabled) {
+ VPN.toggle();
+ } else {
+ if (VPN.connected) {
+ root.pendingSwitchIndex = clickedIndex;
+ VPN.toggle();
+ } else {
+ const providers = [];
+ for (let i = 0; i < Config.utilities.vpn.provider.length; i++) {
+ const p = Config.utilities.vpn.provider[i];
+ if (typeof p === "object") {
+ const newProvider = {
+ name: p.name,
+ displayName: p.displayName,
+ interface: p.interface,
+ enabled: (i === clickedIndex)
+ };
+ providers.push(newProvider);
+ } else {
+ providers.push(p);
+ }
+ }
+ Config.utilities.vpn.provider = providers;
+ Config.save();
+
+ Qt.callLater(function () {
+ VPN.toggle();
+ });
+ }
+ }
+ }
+ }
+
+ MaterialIcon {
+ id: connectIcon
+
+ anchors.centerIn: parent
+ text: VPN.connected && modelData.enabled ? "link_off" : "link"
+ color: VPN.connected && modelData.enabled ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface
+ }
+ }
+
+ StyledRect {
+ implicitWidth: implicitHeight
+ implicitHeight: deleteIcon.implicitHeight + Appearance.padding.smaller * 2
+
+ radius: Appearance.rounding.full
+ color: "transparent"
+
+ StateLayer {
+ function onClicked(): void {
+ const providers = [];
+ for (let i = 0; i < Config.utilities.vpn.provider.length; i++) {
+ if (i !== modelData.index) {
+ providers.push(Config.utilities.vpn.provider[i]);
+ }
+ }
+ Config.utilities.vpn.provider = providers;
+ Config.save();
+ }
+ }
+
+ MaterialIcon {
+ id: deleteIcon
+
+ anchors.centerIn: parent
+ text: "delete"
+ color: Colours.palette.m3onSurface
+ }
+ }
+ }
+
+ implicitHeight: rowLayout.implicitHeight + Appearance.padding.normal * 2
+ }
+ }
+ }
+
+ Popup {
+ id: vpnDialog
+
+ property string currentState: "selection"
+ property int editIndex: -1
+ property string providerName: ""
+ property string displayName: ""
+ property string interfaceName: ""
+
+ parent: Overlay.overlay
+ x: Math.round((parent.width - width) / 2)
+ y: Math.round((parent.height - height) / 2)
+ implicitWidth: Math.min(400, parent.width - Appearance.padding.large * 2)
+ padding: Appearance.padding.large * 1.5
+
+ modal: true
+ closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
+
+ opacity: 0
+ scale: 0.7
+
+ enter: Transition {
+ ParallelAnimation {
+ Anim {
+ property: "opacity"
+ from: 0
+ to: 1
+ duration: Appearance.anim.durations.normal
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ Anim {
+ property: "scale"
+ from: 0.7
+ to: 1
+ duration: Appearance.anim.durations.normal
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+ }
+
+ exit: Transition {
+ ParallelAnimation {
+ Anim {
+ property: "opacity"
+ from: 1
+ to: 0
+ duration: Appearance.anim.durations.small
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ Anim {
+ property: "scale"
+ from: 1
+ to: 0.7
+ duration: Appearance.anim.durations.small
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+ }
+
+ function showProviderSelection(): void {
+ currentState = "selection";
+ open();
+ }
+
+ function closeWithAnimation(): void {
+ close();
+ }
+
+ function showAddForm(providerType: string, defaultDisplayName: string): void {
+ editIndex = -1;
+ providerName = providerType;
+ displayName = defaultDisplayName;
+ interfaceName = "";
+
+ if (currentState === "selection") {
+ transitionToForm.start();
+ } else {
+ currentState = "form";
+ isClosing = false;
+ open();
+ }
+ }
+
+ function showEditForm(index: int): void {
+ const provider = Config.utilities.vpn.provider[index];
+ const isObject = typeof provider === "object";
+
+ editIndex = index;
+ providerName = isObject ? (provider.name || "custom") : String(provider);
+ displayName = isObject ? (provider.displayName || providerName) : providerName;
+ interfaceName = isObject ? (provider.interface || "") : "";
+
+ currentState = "form";
+ open();
+ }
+
+ Overlay.modal: Rectangle {
+ color: Qt.rgba(0, 0, 0, 0.4 * vpnDialog.opacity)
+ }
+
+ onClosed: {
+ currentState = "selection";
+ }
+
+ SequentialAnimation {
+ id: transitionToForm
+
+ ParallelAnimation {
+ Anim {
+ target: selectionContent
+ property: "opacity"
+ to: 0
+ duration: Appearance.anim.durations.small
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+
+ ScriptAction {
+ script: {
+ vpnDialog.currentState = "form";
+ }
+ }
+
+ ParallelAnimation {
+ Anim {
+ target: formContent
+ property: "opacity"
+ to: 1
+ duration: Appearance.anim.durations.small
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+ }
+
+ background: StyledRect {
+ color: Colours.palette.m3surfaceContainerHigh
+ radius: Appearance.rounding.large
+
+ Elevation {
+ anchors.fill: parent
+ radius: parent.radius
+ level: 3
+ z: -1
+ }
+
+ Behavior on implicitHeight {
+ Anim {
+ duration: Appearance.anim.durations.normal
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+ }
+
+ contentItem: Item {
+ implicitHeight: vpnDialog.currentState === "selection" ? selectionContent.implicitHeight : formContent.implicitHeight
+
+ Behavior on implicitHeight {
+ Anim {
+ duration: Appearance.anim.durations.normal
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+
+ ColumnLayout {
+ id: selectionContent
+
+ anchors.fill: parent
+ spacing: Appearance.spacing.normal
+ visible: vpnDialog.currentState === "selection"
+ opacity: vpnDialog.currentState === "selection" ? 1 : 0
+
+ Behavior on opacity {
+ Anim {
+ duration: Appearance.anim.durations.small
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+
+ StyledText {
+ text: qsTr("Add VPN Provider")
+ font.pointSize: Appearance.font.size.large
+ font.weight: 500
+ }
+
+ StyledText {
+ Layout.fillWidth: true
+ text: qsTr("Choose a provider to add")
+ wrapMode: Text.WordWrap
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ }
+
+ TextButton {
+ Layout.topMargin: Appearance.spacing.normal
+ Layout.fillWidth: true
+ text: qsTr("NetBird")
+ inactiveColour: Colours.tPalette.m3surfaceContainerHigh
+ inactiveOnColour: Colours.palette.m3onSurface
+ onClicked: {
+ const providers = [];
+ for (let i = 0; i < Config.utilities.vpn.provider.length; i++) {
+ providers.push(Config.utilities.vpn.provider[i]);
+ }
+ providers.push({
+ name: "netbird",
+ displayName: "NetBird",
+ interface: "wt0"
+ });
+ Config.utilities.vpn.provider = providers;
+ Config.save();
+ vpnDialog.closeWithAnimation();
+ }
+ }
+
+ TextButton {
+ Layout.fillWidth: true
+ text: qsTr("Tailscale")
+ inactiveColour: Colours.tPalette.m3surfaceContainerHigh
+ inactiveOnColour: Colours.palette.m3onSurface
+ onClicked: {
+ const providers = [];
+ for (let i = 0; i < Config.utilities.vpn.provider.length; i++) {
+ providers.push(Config.utilities.vpn.provider[i]);
+ }
+ providers.push({
+ name: "tailscale",
+ displayName: "Tailscale",
+ interface: "tailscale0"
+ });
+ Config.utilities.vpn.provider = providers;
+ Config.save();
+ vpnDialog.closeWithAnimation();
+ }
+ }
+
+ TextButton {
+ Layout.fillWidth: true
+ text: qsTr("Cloudflare WARP")
+ inactiveColour: Colours.tPalette.m3surfaceContainerHigh
+ inactiveOnColour: Colours.palette.m3onSurface
+ onClicked: {
+ const providers = [];
+ for (let i = 0; i < Config.utilities.vpn.provider.length; i++) {
+ providers.push(Config.utilities.vpn.provider[i]);
+ }
+ providers.push({
+ name: "warp",
+ displayName: "Cloudflare WARP",
+ interface: "CloudflareWARP"
+ });
+ Config.utilities.vpn.provider = providers;
+ Config.save();
+ vpnDialog.closeWithAnimation();
+ }
+ }
+
+ TextButton {
+ Layout.fillWidth: true
+ text: qsTr("WireGuard (Custom)")
+ inactiveColour: Colours.tPalette.m3surfaceContainerHigh
+ inactiveOnColour: Colours.palette.m3onSurface
+ onClicked: {
+ vpnDialog.showAddForm("wireguard", "WireGuard");
+ }
+ }
+
+ TextButton {
+ Layout.topMargin: Appearance.spacing.normal
+ Layout.fillWidth: true
+ text: qsTr("Cancel")
+ inactiveColour: Colours.palette.m3secondaryContainer
+ inactiveOnColour: Colours.palette.m3onSecondaryContainer
+ onClicked: vpnDialog.closeWithAnimation()
+ }
+ }
+
+ ColumnLayout {
+ id: formContent
+
+ anchors.fill: parent
+ spacing: Appearance.spacing.normal
+ visible: vpnDialog.currentState === "form"
+ opacity: vpnDialog.currentState === "form" ? 1 : 0
+
+ Behavior on opacity {
+ Anim {
+ duration: Appearance.anim.durations.small
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+
+ StyledText {
+ text: vpnDialog.editIndex >= 0 ? qsTr("Edit VPN Provider") : qsTr("Add %1 VPN").arg(vpnDialog.displayName)
+ font.pointSize: Appearance.font.size.large
+ font.weight: 500
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.smaller / 2
+
+ StyledText {
+ text: qsTr("Display Name")
+ font.pointSize: Appearance.font.size.small
+ color: Colours.palette.m3onSurfaceVariant
+ }
+
+ StyledRect {
+ Layout.fillWidth: true
+ implicitHeight: 40
+ color: displayNameField.activeFocus ? Colours.layer(Colours.palette.m3surfaceContainer, 3) : Colours.layer(Colours.palette.m3surfaceContainer, 2)
+ radius: Appearance.rounding.small
+ border.width: 1
+ border.color: displayNameField.activeFocus ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3outline, 0.3)
+
+ Behavior on color {
+ CAnim {}
+ }
+ Behavior on border.color {
+ CAnim {}
+ }
+
+ StyledTextField {
+ id: displayNameField
+ anchors.centerIn: parent
+ width: parent.width - Appearance.padding.normal
+ horizontalAlignment: TextInput.AlignLeft
+ text: vpnDialog.displayName
+ onTextChanged: vpnDialog.displayName = text
+ }
+ }
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.smaller / 2
+
+ StyledText {
+ text: qsTr("Interface (e.g., wg0, torguard)")
+ font.pointSize: Appearance.font.size.small
+ color: Colours.palette.m3onSurfaceVariant
+ }
+
+ StyledRect {
+ Layout.fillWidth: true
+ implicitHeight: 40
+ color: interfaceNameField.activeFocus ? Colours.layer(Colours.palette.m3surfaceContainer, 3) : Colours.layer(Colours.palette.m3surfaceContainer, 2)
+ radius: Appearance.rounding.small
+ border.width: 1
+ border.color: interfaceNameField.activeFocus ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3outline, 0.3)
+
+ Behavior on color {
+ CAnim {}
+ }
+ Behavior on border.color {
+ CAnim {}
+ }
+
+ StyledTextField {
+ id: interfaceNameField
+ anchors.centerIn: parent
+ width: parent.width - Appearance.padding.normal
+ horizontalAlignment: TextInput.AlignLeft
+ text: vpnDialog.interfaceName
+ onTextChanged: vpnDialog.interfaceName = text
+ }
+ }
+ }
+
+ RowLayout {
+ Layout.topMargin: Appearance.spacing.normal
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+
+ TextButton {
+ Layout.fillWidth: true
+ text: qsTr("Cancel")
+ inactiveColour: Colours.tPalette.m3surfaceContainerHigh
+ inactiveOnColour: Colours.palette.m3onSurface
+ onClicked: vpnDialog.closeWithAnimation()
+ }
+
+ TextButton {
+ Layout.fillWidth: true
+ text: qsTr("Save")
+ enabled: vpnDialog.interfaceName.length > 0
+ inactiveColour: Colours.palette.m3primaryContainer
+ inactiveOnColour: Colours.palette.m3onPrimaryContainer
+
+ onClicked: {
+ const providers = [];
+ const newProvider = {
+ name: vpnDialog.providerName,
+ displayName: vpnDialog.displayName || vpnDialog.interfaceName,
+ interface: vpnDialog.interfaceName
+ };
+
+ if (vpnDialog.editIndex >= 0) {
+ for (let i = 0; i < Config.utilities.vpn.provider.length; i++) {
+ if (i === vpnDialog.editIndex) {
+ providers.push(newProvider);
+ } else {
+ providers.push(Config.utilities.vpn.provider[i]);
+ }
+ }
+ } else {
+ for (let i = 0; i < Config.utilities.vpn.provider.length; i++) {
+ providers.push(Config.utilities.vpn.provider[i]);
+ }
+ providers.push(newProvider);
+ }
+
+ Config.utilities.vpn.provider = providers;
+ Config.save();
+ vpnDialog.closeWithAnimation();
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/network/VpnSettings.qml b/.config/quickshell/caelestia/modules/controlcenter/network/VpnSettings.qml
new file mode 100644
index 0000000..49d801d
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/network/VpnSettings.qml
@@ -0,0 +1,232 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../components"
+import qs.components
+import qs.components.controls
+import qs.components.containers
+import qs.components.effects
+import qs.services
+import qs.config
+import Quickshell
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ required property Session session
+
+ spacing: Appearance.spacing.normal
+
+ SettingsHeader {
+ icon: "vpn_key"
+ title: qsTr("VPN Settings")
+ }
+
+ SectionHeader {
+ Layout.topMargin: Appearance.spacing.large
+ title: qsTr("General")
+ description: qsTr("VPN configuration")
+ }
+
+ SectionContainer {
+ ToggleRow {
+ label: qsTr("VPN enabled")
+ checked: Config.utilities.vpn.enabled
+ toggle.onToggled: {
+ Config.utilities.vpn.enabled = checked;
+ Config.save();
+ }
+ }
+ }
+
+ SectionHeader {
+ Layout.topMargin: Appearance.spacing.large
+ title: qsTr("Providers")
+ description: qsTr("Manage VPN providers")
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.normal
+
+ ListView {
+ Layout.fillWidth: true
+ Layout.preferredHeight: contentHeight
+
+ interactive: false
+ spacing: Appearance.spacing.smaller
+
+ model: ScriptModel {
+ values: Config.utilities.vpn.provider.map((provider, index) => {
+ const isObject = typeof provider === "object";
+ const name = isObject ? (provider.name || "custom") : String(provider);
+ const displayName = isObject ? (provider.displayName || name) : name;
+ const iface = isObject ? (provider.interface || "") : "";
+
+ return {
+ index: index,
+ name: name,
+ displayName: displayName,
+ interface: iface,
+ provider: provider,
+ isActive: index === 0
+ };
+ })
+ }
+
+ delegate: Component {
+ StyledRect {
+ required property var modelData
+ required property int index
+
+ width: ListView.view ? ListView.view.width : undefined
+ color: Colours.tPalette.m3surfaceContainerHigh
+ radius: Appearance.rounding.normal
+
+ RowLayout {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.normal
+ spacing: Appearance.spacing.normal
+
+ MaterialIcon {
+ text: modelData.isActive ? "vpn_key" : "vpn_key_off"
+ font.pointSize: Appearance.font.size.large
+ color: modelData.isActive ? Colours.palette.m3primary : Colours.palette.m3outline
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: 0
+
+ StyledText {
+ text: modelData.displayName
+ font.weight: modelData.isActive ? 500 : 400
+ }
+
+ StyledText {
+ text: qsTr("%1 • %2").arg(modelData.name).arg(modelData.interface || qsTr("No interface"))
+ font.pointSize: Appearance.font.size.small
+ color: Colours.palette.m3outline
+ }
+ }
+
+ IconButton {
+ icon: modelData.isActive ? "arrow_downward" : "arrow_upward"
+ visible: !modelData.isActive || Config.utilities.vpn.provider.length > 1
+ onClicked: {
+ if (modelData.isActive && index < Config.utilities.vpn.provider.length - 1) {
+ // Move down
+ const providers = [...Config.utilities.vpn.provider];
+ const temp = providers[index];
+ providers[index] = providers[index + 1];
+ providers[index + 1] = temp;
+ Config.utilities.vpn.provider = providers;
+ Config.save();
+ } else if (!modelData.isActive) {
+ // Make active (move to top)
+ const providers = [...Config.utilities.vpn.provider];
+ const provider = providers.splice(index, 1)[0];
+ providers.unshift(provider);
+ Config.utilities.vpn.provider = providers;
+ Config.save();
+ }
+ }
+ }
+
+ IconButton {
+ icon: "delete"
+ onClicked: {
+ const providers = [...Config.utilities.vpn.provider];
+ providers.splice(index, 1);
+ Config.utilities.vpn.provider = providers;
+ Config.save();
+ }
+ }
+ }
+
+ implicitHeight: 60
+ }
+ }
+ }
+
+ TextButton {
+ Layout.fillWidth: true
+ Layout.topMargin: Appearance.spacing.normal
+ text: qsTr("+ Add Provider")
+ inactiveColour: Colours.palette.m3primaryContainer
+ inactiveOnColour: Colours.palette.m3onPrimaryContainer
+
+ onClicked: {
+ addProviderDialog.open();
+ }
+ }
+ }
+
+ SectionHeader {
+ Layout.topMargin: Appearance.spacing.large
+ title: qsTr("Quick Add")
+ description: qsTr("Add common VPN providers")
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.smaller
+
+ TextButton {
+ Layout.fillWidth: true
+ text: qsTr("+ Add NetBird")
+ inactiveColour: Colours.tPalette.m3surfaceContainerHigh
+ inactiveOnColour: Colours.palette.m3onSurface
+
+ onClicked: {
+ const providers = [...Config.utilities.vpn.provider];
+ providers.push({
+ name: "netbird",
+ displayName: "NetBird",
+ interface: "wt0"
+ });
+ Config.utilities.vpn.provider = providers;
+ Config.save();
+ }
+ }
+
+ TextButton {
+ Layout.fillWidth: true
+ text: qsTr("+ Add Tailscale")
+ inactiveColour: Colours.tPalette.m3surfaceContainerHigh
+ inactiveOnColour: Colours.palette.m3onSurface
+
+ onClicked: {
+ const providers = [...Config.utilities.vpn.provider];
+ providers.push({
+ name: "tailscale",
+ displayName: "Tailscale",
+ interface: "tailscale0"
+ });
+ Config.utilities.vpn.provider = providers;
+ Config.save();
+ }
+ }
+
+ TextButton {
+ Layout.fillWidth: true
+ text: qsTr("+ Add Cloudflare WARP")
+ inactiveColour: Colours.tPalette.m3surfaceContainerHigh
+ inactiveOnColour: Colours.palette.m3onSurface
+
+ onClicked: {
+ const providers = [...Config.utilities.vpn.provider];
+ providers.push({
+ name: "warp",
+ displayName: "Cloudflare WARP",
+ interface: "CloudflareWARP"
+ });
+ Config.utilities.vpn.provider = providers;
+ Config.save();
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/network/WirelessDetails.qml b/.config/quickshell/caelestia/modules/controlcenter/network/WirelessDetails.qml
new file mode 100644
index 0000000..e8777cd
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/network/WirelessDetails.qml
@@ -0,0 +1,211 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../components"
+import "."
+import qs.components
+import qs.components.controls
+import qs.components.effects
+import qs.components.containers
+import qs.services
+import qs.config
+import qs.utils
+import QtQuick
+import QtQuick.Layouts
+
+DeviceDetails {
+ id: root
+
+ required property Session session
+ readonly property var network: root.session.network.active
+
+ device: network
+
+ Component.onCompleted: {
+ updateDeviceDetails();
+ checkSavedProfile();
+ }
+
+ onNetworkChanged: {
+ connectionUpdateTimer.stop();
+ if (network && network.ssid) {
+ connectionUpdateTimer.start();
+ }
+ updateDeviceDetails();
+ checkSavedProfile();
+ }
+
+ function checkSavedProfile(): void {
+ if (network && network.ssid) {
+ Nmcli.loadSavedConnections(() => {});
+ }
+ }
+
+ Connections {
+ target: Nmcli
+ function onActiveChanged() {
+ updateDeviceDetails();
+ }
+ function onWirelessDeviceDetailsChanged() {
+ if (network && network.ssid) {
+ const isActive = network.active || (Nmcli.active && Nmcli.active.ssid === network.ssid);
+ if (isActive && Nmcli.wirelessDeviceDetails && Nmcli.wirelessDeviceDetails !== null) {
+ connectionUpdateTimer.stop();
+ }
+ }
+ }
+ }
+
+ Timer {
+ id: connectionUpdateTimer
+ interval: 500
+ repeat: true
+ running: network && network.ssid
+ onTriggered: {
+ if (network) {
+ const isActive = network.active || (Nmcli.active && Nmcli.active.ssid === network.ssid);
+ if (isActive) {
+ if (!Nmcli.wirelessDeviceDetails || Nmcli.wirelessDeviceDetails === null) {
+ Nmcli.getWirelessDeviceDetails("", () => {});
+ } else {
+ connectionUpdateTimer.stop();
+ }
+ } else {
+ if (Nmcli.wirelessDeviceDetails !== null) {
+ Nmcli.wirelessDeviceDetails = null;
+ }
+ }
+ }
+ }
+ }
+
+ function updateDeviceDetails(): void {
+ if (network && network.ssid) {
+ const isActive = network.active || (Nmcli.active && Nmcli.active.ssid === network.ssid);
+ if (isActive) {
+ Nmcli.getWirelessDeviceDetails("");
+ } else {
+ Nmcli.wirelessDeviceDetails = null;
+ }
+ } else {
+ Nmcli.wirelessDeviceDetails = null;
+ }
+ }
+
+ headerComponent: Component {
+ ConnectionHeader {
+ icon: root.network?.isSecure ? "lock" : "wifi"
+ title: root.network?.ssid ?? qsTr("Unknown")
+ }
+ }
+
+ sections: [
+ Component {
+ ColumnLayout {
+ spacing: Appearance.spacing.normal
+
+ SectionHeader {
+ title: qsTr("Connection status")
+ description: qsTr("Connection settings for this network")
+ }
+
+ SectionContainer {
+ ToggleRow {
+ label: qsTr("Connected")
+ checked: root.network?.active ?? false
+ toggle.onToggled: {
+ if (checked) {
+ NetworkConnection.handleConnect(root.network, root.session, null);
+ } else {
+ Nmcli.disconnectFromNetwork();
+ }
+ }
+ }
+
+ TextButton {
+ Layout.fillWidth: true
+ Layout.topMargin: Appearance.spacing.normal
+ Layout.minimumHeight: Appearance.font.size.normal + Appearance.padding.normal * 2
+ visible: {
+ if (!root.network || !root.network.ssid) {
+ return false;
+ }
+ return Nmcli.hasSavedProfile(root.network.ssid);
+ }
+ inactiveColour: Colours.palette.m3secondaryContainer
+ inactiveOnColour: Colours.palette.m3onSecondaryContainer
+ text: qsTr("Forget Network")
+
+ onClicked: {
+ if (root.network && root.network.ssid) {
+ if (root.network.active) {
+ Nmcli.disconnectFromNetwork();
+ }
+ Nmcli.forgetNetwork(root.network.ssid);
+ }
+ }
+ }
+ }
+ }
+ },
+ Component {
+ ColumnLayout {
+ spacing: Appearance.spacing.normal
+
+ SectionHeader {
+ title: qsTr("Network properties")
+ description: qsTr("Additional information")
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.small / 2
+
+ PropertyRow {
+ label: qsTr("SSID")
+ value: root.network?.ssid ?? qsTr("Unknown")
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ label: qsTr("BSSID")
+ value: root.network?.bssid ?? qsTr("Unknown")
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ label: qsTr("Signal strength")
+ value: root.network ? qsTr("%1%").arg(root.network.strength) : qsTr("N/A")
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ label: qsTr("Frequency")
+ value: root.network ? qsTr("%1 MHz").arg(root.network.frequency) : qsTr("N/A")
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ label: qsTr("Security")
+ value: root.network ? (root.network.isSecure ? root.network.security : qsTr("Open")) : qsTr("N/A")
+ }
+ }
+ }
+ },
+ Component {
+ ColumnLayout {
+ spacing: Appearance.spacing.normal
+
+ SectionHeader {
+ title: qsTr("Connection information")
+ description: qsTr("Network connection details")
+ }
+
+ SectionContainer {
+ ConnectionInfoSection {
+ deviceDetails: Nmcli.wirelessDeviceDetails
+ }
+ }
+ }
+ }
+ ]
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/network/WirelessList.qml b/.config/quickshell/caelestia/modules/controlcenter/network/WirelessList.qml
new file mode 100644
index 0000000..57a155f
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/network/WirelessList.qml
@@ -0,0 +1,228 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../components"
+import "."
+import qs.components
+import qs.components.controls
+import qs.components.containers
+import qs.components.effects
+import qs.services
+import qs.config
+import qs.utils
+import Quickshell
+import QtQuick
+import QtQuick.Layouts
+
+DeviceList {
+ id: root
+
+ required property Session session
+
+ title: qsTr("Networks (%1)").arg(Nmcli.networks.length)
+ description: qsTr("All available WiFi networks")
+ activeItem: session.network.active
+
+ titleSuffix: Component {
+ StyledText {
+ visible: Nmcli.scanning
+ text: qsTr("Scanning...")
+ color: Colours.palette.m3primary
+ font.pointSize: Appearance.font.size.small
+ }
+ }
+
+ model: ScriptModel {
+ values: [...Nmcli.networks].sort((a, b) => {
+ if (a.active !== b.active)
+ return b.active - a.active;
+ return b.strength - a.strength;
+ })
+ }
+
+ headerComponent: Component {
+ RowLayout {
+ spacing: Appearance.spacing.smaller
+
+ StyledText {
+ text: qsTr("Settings")
+ font.pointSize: Appearance.font.size.large
+ font.weight: 500
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ ToggleButton {
+ toggled: Nmcli.wifiEnabled
+ icon: "wifi"
+ accent: "Tertiary"
+ iconSize: Appearance.font.size.normal
+ horizontalPadding: Appearance.padding.normal
+ verticalPadding: Appearance.padding.smaller
+
+ onClicked: {
+ Nmcli.toggleWifi(null);
+ }
+ }
+
+ ToggleButton {
+ toggled: Nmcli.scanning
+ icon: "wifi_find"
+ accent: "Secondary"
+ iconSize: Appearance.font.size.normal
+ horizontalPadding: Appearance.padding.normal
+ verticalPadding: Appearance.padding.smaller
+
+ onClicked: {
+ Nmcli.rescanWifi();
+ }
+ }
+
+ ToggleButton {
+ toggled: !root.session.network.active
+ icon: "settings"
+ accent: "Primary"
+ iconSize: Appearance.font.size.normal
+ horizontalPadding: Appearance.padding.normal
+ verticalPadding: Appearance.padding.smaller
+
+ onClicked: {
+ if (root.session.network.active)
+ root.session.network.active = null;
+ else {
+ root.session.network.active = root.view.model.get(0)?.modelData ?? null;
+ }
+ }
+ }
+ }
+ }
+
+ delegate: Component {
+ StyledRect {
+ required property var modelData
+
+ width: ListView.view ? ListView.view.width : undefined
+
+ color: Qt.alpha(Colours.tPalette.m3surfaceContainer, root.activeItem === modelData ? Colours.tPalette.m3surfaceContainer.a : 0)
+ radius: Appearance.rounding.normal
+
+ StateLayer {
+ function onClicked(): void {
+ root.session.network.active = modelData;
+ if (modelData && modelData.ssid) {
+ root.checkSavedProfileForNetwork(modelData.ssid);
+ }
+ }
+ }
+
+ RowLayout {
+ id: rowLayout
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.normal
+
+ spacing: Appearance.spacing.normal
+
+ StyledRect {
+ implicitWidth: implicitHeight
+ implicitHeight: icon.implicitHeight + Appearance.padding.normal * 2
+
+ radius: Appearance.rounding.normal
+ color: modelData.active ? Colours.palette.m3primaryContainer : Colours.tPalette.m3surfaceContainerHigh
+
+ MaterialIcon {
+ id: icon
+
+ anchors.centerIn: parent
+ text: Icons.getNetworkIcon(modelData.strength, modelData.isSecure)
+ font.pointSize: Appearance.font.size.large
+ fill: modelData.active ? 1 : 0
+ color: modelData.active ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface
+ }
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+
+ spacing: 0
+
+ StyledText {
+ Layout.fillWidth: true
+ elide: Text.ElideRight
+ maximumLineCount: 1
+
+ text: modelData.ssid || qsTr("Unknown")
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.smaller
+
+ StyledText {
+ Layout.fillWidth: true
+ text: {
+ if (modelData.active)
+ return qsTr("Connected");
+ if (modelData.isSecure && modelData.security && modelData.security.length > 0) {
+ return modelData.security;
+ }
+ if (modelData.isSecure)
+ return qsTr("Secured");
+ return qsTr("Open");
+ }
+ color: modelData.active ? Colours.palette.m3primary : Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ font.weight: modelData.active ? 500 : 400
+ elide: Text.ElideRight
+ }
+ }
+ }
+
+ StyledRect {
+ implicitWidth: implicitHeight
+ implicitHeight: connectIcon.implicitHeight + Appearance.padding.smaller * 2
+
+ radius: Appearance.rounding.full
+ color: Qt.alpha(Colours.palette.m3primaryContainer, modelData.active ? 1 : 0)
+
+ StateLayer {
+ function onClicked(): void {
+ if (modelData.active) {
+ Nmcli.disconnectFromNetwork();
+ } else {
+ NetworkConnection.handleConnect(modelData, root.session, null);
+ }
+ }
+ }
+
+ MaterialIcon {
+ id: connectIcon
+
+ anchors.centerIn: parent
+ text: modelData.active ? "link_off" : "link"
+ color: modelData.active ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface
+ }
+ }
+ }
+
+ implicitHeight: rowLayout.implicitHeight + Appearance.padding.normal * 2
+ }
+ }
+
+ onItemSelected: function (item) {
+ session.network.active = item;
+ if (item && item.ssid) {
+ checkSavedProfileForNetwork(item.ssid);
+ }
+ }
+
+ function checkSavedProfileForNetwork(ssid: string): void {
+ if (ssid && ssid.length > 0) {
+ Nmcli.loadSavedConnections(() => {});
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/network/WirelessPane.qml b/.config/quickshell/caelestia/modules/controlcenter/network/WirelessPane.qml
new file mode 100644
index 0000000..8150af9
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/network/WirelessPane.qml
@@ -0,0 +1,57 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../components"
+import qs.components
+import qs.components.containers
+import qs.config
+import Quickshell.Widgets
+import QtQuick
+
+SplitPaneWithDetails {
+ id: root
+
+ required property Session session
+
+ anchors.fill: parent
+
+ activeItem: session.network.active
+ paneIdGenerator: function (item) {
+ return item ? (item.ssid || item.bssid || "") : "";
+ }
+
+ leftContent: Component {
+ WirelessList {
+ session: root.session
+ }
+ }
+
+ rightDetailsComponent: Component {
+ WirelessDetails {
+ session: root.session
+ }
+ }
+
+ rightSettingsComponent: Component {
+ StyledFlickable {
+ flickableDirection: Flickable.VerticalFlick
+ contentHeight: settingsInner.height
+ clip: true
+
+ WirelessSettings {
+ id: settingsInner
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ session: root.session
+ }
+ }
+ }
+
+ overlayComponent: Component {
+ WirelessPasswordDialog {
+ anchors.fill: parent
+ session: root.session
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/network/WirelessPasswordDialog.qml b/.config/quickshell/caelestia/modules/controlcenter/network/WirelessPasswordDialog.qml
new file mode 100644
index 0000000..7ad5204
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/network/WirelessPasswordDialog.qml
@@ -0,0 +1,511 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "."
+import qs.components
+import qs.components.controls
+import qs.components.effects
+import qs.components.containers
+import qs.services
+import qs.config
+import qs.utils
+import Quickshell
+import QtQuick
+import QtQuick.Layouts
+
+Item {
+ id: root
+
+ required property Session session
+
+ readonly property var network: {
+ if (session.network.pendingNetwork) {
+ return session.network.pendingNetwork;
+ }
+ if (session.network.active) {
+ return session.network.active;
+ }
+ return null;
+ }
+
+ property bool isClosing: false
+ visible: session.network.showPasswordDialog || isClosing
+ enabled: session.network.showPasswordDialog && !isClosing
+ focus: enabled
+
+ Keys.onEscapePressed: {
+ closeDialog();
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ color: Qt.rgba(0, 0, 0, 0.5)
+ opacity: root.session.network.showPasswordDialog && !root.isClosing ? 1 : 0
+
+ Behavior on opacity {
+ Anim {}
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ onClicked: closeDialog()
+ }
+ }
+
+ StyledRect {
+ id: dialog
+
+ anchors.centerIn: parent
+
+ implicitWidth: 400
+ implicitHeight: content.implicitHeight + Appearance.padding.large * 2
+
+ radius: Appearance.rounding.normal
+ color: Colours.tPalette.m3surface
+ opacity: root.session.network.showPasswordDialog && !root.isClosing ? 1 : 0
+ scale: root.session.network.showPasswordDialog && !root.isClosing ? 1 : 0.7
+
+ Behavior on opacity {
+ Anim {}
+ }
+
+ Behavior on scale {
+ Anim {}
+ }
+
+ ParallelAnimation {
+ running: root.isClosing
+ onFinished: {
+ if (root.isClosing) {
+ root.session.network.showPasswordDialog = false;
+ root.isClosing = false;
+ }
+ }
+
+ Anim {
+ target: dialog
+ property: "opacity"
+ to: 0
+ }
+ Anim {
+ target: dialog
+ property: "scale"
+ to: 0.7
+ }
+ }
+
+ Keys.onEscapePressed: closeDialog()
+
+ ColumnLayout {
+ id: content
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.large
+
+ spacing: Appearance.spacing.normal
+
+ MaterialIcon {
+ Layout.alignment: Qt.AlignHCenter
+ text: "lock"
+ font.pointSize: Appearance.font.size.extraLarge * 2
+ }
+
+ StyledText {
+ Layout.alignment: Qt.AlignHCenter
+ text: qsTr("Enter password")
+ font.pointSize: Appearance.font.size.large
+ font.weight: 500
+ }
+
+ StyledText {
+ Layout.alignment: Qt.AlignHCenter
+ text: root.network ? qsTr("Network: %1").arg(root.network.ssid) : ""
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ }
+
+ StyledText {
+ id: statusText
+
+ Layout.alignment: Qt.AlignHCenter
+ Layout.topMargin: Appearance.spacing.small
+ visible: connectButton.connecting || connectButton.hasError
+ text: {
+ if (connectButton.hasError) {
+ return qsTr("Connection failed. Please check your password and try again.");
+ }
+ if (connectButton.connecting) {
+ return qsTr("Connecting...");
+ }
+ return "";
+ }
+ color: connectButton.hasError ? Colours.palette.m3error : Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.small
+ font.weight: 400
+ wrapMode: Text.WordWrap
+ Layout.maximumWidth: parent.width - Appearance.padding.large * 2
+ }
+
+ Item {
+ id: passwordContainer
+ Layout.topMargin: Appearance.spacing.large
+ Layout.fillWidth: true
+ implicitHeight: Math.max(48, charList.implicitHeight + Appearance.padding.normal * 2)
+
+ focus: true
+ Keys.onPressed: event => {
+ if (!activeFocus) {
+ forceActiveFocus();
+ }
+
+ if (connectButton.hasError && event.text && event.text.length > 0) {
+ connectButton.hasError = false;
+ }
+
+ if (event.key === Qt.Key_Enter || event.key === Qt.Key_Return) {
+ if (connectButton.enabled) {
+ connectButton.clicked();
+ }
+ event.accepted = true;
+ } else if (event.key === Qt.Key_Backspace) {
+ if (event.modifiers & Qt.ControlModifier) {
+ passwordBuffer = "";
+ } else {
+ passwordBuffer = passwordBuffer.slice(0, -1);
+ }
+ event.accepted = true;
+ } else if (event.text && event.text.length > 0) {
+ passwordBuffer += event.text;
+ event.accepted = true;
+ }
+ }
+
+ property string passwordBuffer: ""
+
+ Connections {
+ target: root.session.network
+ function onShowPasswordDialogChanged(): void {
+ if (root.session.network.showPasswordDialog) {
+ Qt.callLater(() => {
+ passwordContainer.forceActiveFocus();
+ passwordContainer.passwordBuffer = "";
+ connectButton.hasError = false;
+ });
+ }
+ }
+ }
+
+ Connections {
+ target: root
+ function onVisibleChanged(): void {
+ if (root.visible) {
+ Qt.callLater(() => {
+ passwordContainer.forceActiveFocus();
+ });
+ }
+ }
+ }
+
+ StyledRect {
+ anchors.fill: parent
+ radius: Appearance.rounding.normal
+ color: passwordContainer.activeFocus ? Qt.lighter(Colours.tPalette.m3surfaceContainer, 1.05) : Colours.tPalette.m3surfaceContainer
+ border.width: passwordContainer.activeFocus || connectButton.hasError ? 4 : (root.visible ? 1 : 0)
+ border.color: {
+ if (connectButton.hasError) {
+ return Colours.palette.m3error;
+ }
+ if (passwordContainer.activeFocus) {
+ return Colours.palette.m3primary;
+ }
+ return root.visible ? Colours.palette.m3outline : "transparent";
+ }
+
+ Behavior on border.color {
+ CAnim {}
+ }
+
+ Behavior on border.width {
+ CAnim {}
+ }
+
+ Behavior on color {
+ CAnim {}
+ }
+ }
+
+ StateLayer {
+ hoverEnabled: false
+ cursorShape: Qt.IBeamCursor
+
+ function onClicked(): void {
+ passwordContainer.forceActiveFocus();
+ }
+ }
+
+ StyledText {
+ id: placeholder
+ anchors.centerIn: parent
+ text: qsTr("Password")
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.normal
+ font.family: Appearance.font.family.mono
+ opacity: passwordContainer.passwordBuffer ? 0 : 1
+
+ Behavior on opacity {
+ Anim {}
+ }
+ }
+
+ ListView {
+ id: charList
+
+ readonly property int fullWidth: count * (implicitHeight + spacing) - spacing
+
+ anchors.centerIn: parent
+ implicitWidth: fullWidth
+ implicitHeight: Appearance.font.size.normal
+
+ orientation: Qt.Horizontal
+ spacing: Appearance.spacing.small / 2
+ interactive: false
+
+ model: ScriptModel {
+ values: passwordContainer.passwordBuffer.split("")
+ }
+
+ delegate: StyledRect {
+ id: ch
+
+ implicitWidth: implicitHeight
+ implicitHeight: charList.implicitHeight
+
+ color: Colours.palette.m3onSurface
+ radius: Appearance.rounding.small / 2
+
+ opacity: 0
+ scale: 0
+ Component.onCompleted: {
+ opacity = 1;
+ scale = 1;
+ }
+ ListView.onRemove: removeAnim.start()
+
+ SequentialAnimation {
+ id: removeAnim
+
+ PropertyAction {
+ target: ch
+ property: "ListView.delayRemove"
+ value: true
+ }
+ ParallelAnimation {
+ Anim {
+ target: ch
+ property: "opacity"
+ to: 0
+ }
+ Anim {
+ target: ch
+ property: "scale"
+ to: 0.5
+ }
+ }
+ PropertyAction {
+ target: ch
+ property: "ListView.delayRemove"
+ value: false
+ }
+ }
+
+ Behavior on opacity {
+ Anim {}
+ }
+
+ Behavior on scale {
+ Anim {
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
+ }
+ }
+ }
+
+ Behavior on implicitWidth {
+ Anim {}
+ }
+ }
+ }
+
+ RowLayout {
+ Layout.topMargin: Appearance.spacing.normal
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+
+ TextButton {
+ id: cancelButton
+
+ Layout.fillWidth: true
+ Layout.minimumHeight: Appearance.font.size.normal + Appearance.padding.normal * 2
+ inactiveColour: Colours.palette.m3secondaryContainer
+ inactiveOnColour: Colours.palette.m3onSecondaryContainer
+ text: qsTr("Cancel")
+
+ onClicked: root.closeDialog()
+ }
+
+ TextButton {
+ id: connectButton
+
+ property bool connecting: false
+ property bool hasError: false
+
+ Layout.fillWidth: true
+ Layout.minimumHeight: Appearance.font.size.normal + Appearance.padding.normal * 2
+ inactiveColour: Colours.palette.m3primary
+ inactiveOnColour: Colours.palette.m3onPrimary
+ text: qsTr("Connect")
+ enabled: passwordContainer.passwordBuffer.length > 0 && !connecting
+
+ onClicked: {
+ if (!root.network || connecting) {
+ return;
+ }
+
+ const password = passwordContainer.passwordBuffer;
+ if (!password || password.length === 0) {
+ return;
+ }
+
+ hasError = false;
+ connecting = true;
+ enabled = false;
+ text = qsTr("Connecting...");
+
+ NetworkConnection.connectWithPassword(root.network, password, result => {
+ if (result && result.success) {} else if (result && result.needsPassword) {
+ connectionMonitor.stop();
+ connecting = false;
+ hasError = true;
+ enabled = true;
+ text = qsTr("Connect");
+ passwordContainer.passwordBuffer = "";
+ if (root.network && root.network.ssid) {
+ Nmcli.forgetNetwork(root.network.ssid);
+ }
+ } else {
+ connectionMonitor.stop();
+ connecting = false;
+ hasError = true;
+ enabled = true;
+ text = qsTr("Connect");
+ passwordContainer.passwordBuffer = "";
+ if (root.network && root.network.ssid) {
+ Nmcli.forgetNetwork(root.network.ssid);
+ }
+ }
+ });
+
+ connectionMonitor.start();
+ }
+ }
+ }
+ }
+ }
+
+ function checkConnectionStatus(): void {
+ if (!root.visible || !connectButton.connecting) {
+ return;
+ }
+
+ const isConnected = root.network && Nmcli.active && Nmcli.active.ssid && Nmcli.active.ssid.toLowerCase().trim() === root.network.ssid.toLowerCase().trim();
+
+ if (isConnected) {
+ connectionSuccessTimer.start();
+ return;
+ }
+
+ if (Nmcli.pendingConnection === null && connectButton.connecting) {
+ if (connectionMonitor.repeatCount > 10) {
+ connectionMonitor.stop();
+ connectButton.connecting = false;
+ connectButton.hasError = true;
+ connectButton.enabled = true;
+ connectButton.text = qsTr("Connect");
+ passwordContainer.passwordBuffer = "";
+ if (root.network && root.network.ssid) {
+ Nmcli.forgetNetwork(root.network.ssid);
+ }
+ }
+ }
+ }
+
+ Timer {
+ id: connectionMonitor
+ interval: 1000
+ repeat: true
+ triggeredOnStart: false
+ property int repeatCount: 0
+
+ onTriggered: {
+ repeatCount++;
+ checkConnectionStatus();
+ }
+
+ onRunningChanged: {
+ if (!running) {
+ repeatCount = 0;
+ }
+ }
+ }
+
+ Timer {
+ id: connectionSuccessTimer
+ interval: 500
+ onTriggered: {
+ if (root.visible && Nmcli.active && Nmcli.active.ssid) {
+ const stillConnected = Nmcli.active.ssid.toLowerCase().trim() === root.network.ssid.toLowerCase().trim();
+ if (stillConnected) {
+ connectionMonitor.stop();
+ connectButton.connecting = false;
+ connectButton.text = qsTr("Connect");
+ closeDialog();
+ }
+ }
+ }
+ }
+
+ Connections {
+ target: Nmcli
+ function onActiveChanged() {
+ if (root.visible) {
+ checkConnectionStatus();
+ }
+ }
+ function onConnectionFailed(ssid: string) {
+ if (root.visible && root.network && root.network.ssid === ssid && connectButton.connecting) {
+ connectionMonitor.stop();
+ connectButton.connecting = false;
+ connectButton.hasError = true;
+ connectButton.enabled = true;
+ connectButton.text = qsTr("Connect");
+ passwordContainer.passwordBuffer = "";
+ Nmcli.forgetNetwork(ssid);
+ }
+ }
+ }
+
+ function closeDialog(): void {
+ if (isClosing) {
+ return;
+ }
+
+ isClosing = true;
+ passwordContainer.passwordBuffer = "";
+ connectButton.connecting = false;
+ connectButton.hasError = false;
+ connectButton.text = qsTr("Connect");
+ connectionMonitor.stop();
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/network/WirelessSettings.qml b/.config/quickshell/caelestia/modules/controlcenter/network/WirelessSettings.qml
new file mode 100644
index 0000000..b4eb391
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/network/WirelessSettings.qml
@@ -0,0 +1,73 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../components"
+import qs.components
+import qs.components.controls
+import qs.components.effects
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ required property Session session
+
+ spacing: Appearance.spacing.normal
+
+ SettingsHeader {
+ icon: "wifi"
+ title: qsTr("Network settings")
+ }
+
+ SectionHeader {
+ Layout.topMargin: Appearance.spacing.large
+ title: qsTr("WiFi status")
+ description: qsTr("General WiFi settings")
+ }
+
+ SectionContainer {
+ ToggleRow {
+ label: qsTr("WiFi enabled")
+ checked: Nmcli.wifiEnabled
+ toggle.onToggled: {
+ Nmcli.enableWifi(checked);
+ }
+ }
+ }
+
+ SectionHeader {
+ Layout.topMargin: Appearance.spacing.large
+ title: qsTr("Network information")
+ description: qsTr("Current network connection")
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.small / 2
+
+ PropertyRow {
+ label: qsTr("Connected network")
+ value: Nmcli.active ? Nmcli.active.ssid : qsTr("Not connected")
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ label: qsTr("Signal strength")
+ value: Nmcli.active ? qsTr("%1%").arg(Nmcli.active.strength) : qsTr("N/A")
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ label: qsTr("Security")
+ value: Nmcli.active ? (Nmcli.active.isSecure ? qsTr("Secured") : qsTr("Open")) : qsTr("N/A")
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ label: qsTr("Frequency")
+ value: Nmcli.active ? qsTr("%1 MHz").arg(Nmcli.active.frequency) : qsTr("N/A")
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/state/BluetoothState.qml b/.config/quickshell/caelestia/modules/controlcenter/state/BluetoothState.qml
new file mode 100644
index 0000000..8678672
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/state/BluetoothState.qml
@@ -0,0 +1,12 @@
+import Quickshell.Bluetooth
+import QtQuick
+
+QtObject {
+ id: root
+
+ property BluetoothDevice active: null
+ property BluetoothAdapter currentAdapter: Bluetooth.defaultAdapter
+ property bool editingAdapterName: false
+ property bool fabMenuOpen: false
+ property bool editingDeviceName: false
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/state/EthernetState.qml b/.config/quickshell/caelestia/modules/controlcenter/state/EthernetState.qml
new file mode 100644
index 0000000..58f5fc8
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/state/EthernetState.qml
@@ -0,0 +1,7 @@
+import QtQuick
+
+QtObject {
+ id: root
+
+ property var active: null
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/state/LauncherState.qml b/.config/quickshell/caelestia/modules/controlcenter/state/LauncherState.qml
new file mode 100644
index 0000000..58f5fc8
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/state/LauncherState.qml
@@ -0,0 +1,7 @@
+import QtQuick
+
+QtObject {
+ id: root
+
+ property var active: null
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/state/NetworkState.qml b/.config/quickshell/caelestia/modules/controlcenter/state/NetworkState.qml
new file mode 100644
index 0000000..f9324c8
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/state/NetworkState.qml
@@ -0,0 +1,9 @@
+import QtQuick
+
+QtObject {
+ id: root
+
+ property var active: null
+ property bool showPasswordDialog: false
+ property var pendingNetwork: null
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/state/VpnState.qml b/.config/quickshell/caelestia/modules/controlcenter/state/VpnState.qml
new file mode 100644
index 0000000..aa911f1
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/state/VpnState.qml
@@ -0,0 +1,5 @@
+import QtQuick
+
+QtObject {
+ property var active: null
+}
diff --git a/.config/quickshell/caelestia/modules/controlcenter/taskbar/TaskbarPane.qml b/.config/quickshell/caelestia/modules/controlcenter/taskbar/TaskbarPane.qml
new file mode 100644
index 0000000..d12d174
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/controlcenter/taskbar/TaskbarPane.qml
@@ -0,0 +1,648 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../components"
+import qs.components
+import qs.components.controls
+import qs.components.effects
+import qs.components.containers
+import qs.services
+import qs.config
+import qs.utils
+import Quickshell
+import Quickshell.Widgets
+import QtQuick
+import QtQuick.Layouts
+
+Item {
+ id: root
+
+ required property Session session
+
+ property bool clockShowIcon: Config.bar.clock.showIcon ?? true
+ property bool persistent: Config.bar.persistent ?? true
+ property bool showOnHover: Config.bar.showOnHover ?? true
+ property int dragThreshold: Config.bar.dragThreshold ?? 20
+ property bool showAudio: Config.bar.status.showAudio ?? true
+ property bool showMicrophone: Config.bar.status.showMicrophone ?? true
+ property bool showKbLayout: Config.bar.status.showKbLayout ?? false
+ property bool showNetwork: Config.bar.status.showNetwork ?? true
+ property bool showWifi: Config.bar.status.showWifi ?? true
+ property bool showBluetooth: Config.bar.status.showBluetooth ?? true
+ property bool showBattery: Config.bar.status.showBattery ?? true
+ property bool showLockStatus: Config.bar.status.showLockStatus ?? true
+ property bool trayBackground: Config.bar.tray.background ?? false
+ property bool trayCompact: Config.bar.tray.compact ?? false
+ property bool trayRecolour: Config.bar.tray.recolour ?? false
+ property int workspacesShown: Config.bar.workspaces.shown ?? 5
+ property bool workspacesActiveIndicator: Config.bar.workspaces.activeIndicator ?? true
+ property bool workspacesOccupiedBg: Config.bar.workspaces.occupiedBg ?? false
+ property bool workspacesShowWindows: Config.bar.workspaces.showWindows ?? false
+ property bool workspacesPerMonitor: Config.bar.workspaces.perMonitorWorkspaces ?? true
+ property bool scrollWorkspaces: Config.bar.scrollActions.workspaces ?? true
+ property bool scrollVolume: Config.bar.scrollActions.volume ?? true
+ property bool scrollBrightness: Config.bar.scrollActions.brightness ?? true
+ property bool popoutActiveWindow: Config.bar.popouts.activeWindow ?? true
+ property bool popoutTray: Config.bar.popouts.tray ?? true
+ property bool popoutStatusIcons: Config.bar.popouts.statusIcons ?? true
+
+ anchors.fill: parent
+
+ Component.onCompleted: {
+ if (Config.bar.entries) {
+ entriesModel.clear();
+ for (let i = 0; i < Config.bar.entries.length; i++) {
+ const entry = Config.bar.entries[i];
+ entriesModel.append({
+ id: entry.id,
+ enabled: entry.enabled !== false
+ });
+ }
+ }
+ }
+
+ function saveConfig(entryIndex, entryEnabled) {
+ Config.bar.clock.showIcon = root.clockShowIcon;
+ Config.bar.persistent = root.persistent;
+ Config.bar.showOnHover = root.showOnHover;
+ Config.bar.dragThreshold = root.dragThreshold;
+ Config.bar.status.showAudio = root.showAudio;
+ Config.bar.status.showMicrophone = root.showMicrophone;
+ Config.bar.status.showKbLayout = root.showKbLayout;
+ Config.bar.status.showNetwork = root.showNetwork;
+ Config.bar.status.showWifi = root.showWifi;
+ Config.bar.status.showBluetooth = root.showBluetooth;
+ Config.bar.status.showBattery = root.showBattery;
+ Config.bar.status.showLockStatus = root.showLockStatus;
+ Config.bar.tray.background = root.trayBackground;
+ Config.bar.tray.compact = root.trayCompact;
+ Config.bar.tray.recolour = root.trayRecolour;
+ Config.bar.workspaces.shown = root.workspacesShown;
+ Config.bar.workspaces.activeIndicator = root.workspacesActiveIndicator;
+ Config.bar.workspaces.occupiedBg = root.workspacesOccupiedBg;
+ Config.bar.workspaces.showWindows = root.workspacesShowWindows;
+ Config.bar.workspaces.perMonitorWorkspaces = root.workspacesPerMonitor;
+ Config.bar.scrollActions.workspaces = root.scrollWorkspaces;
+ Config.bar.scrollActions.volume = root.scrollVolume;
+ Config.bar.scrollActions.brightness = root.scrollBrightness;
+ Config.bar.popouts.activeWindow = root.popoutActiveWindow;
+ Config.bar.popouts.tray = root.popoutTray;
+ Config.bar.popouts.statusIcons = root.popoutStatusIcons;
+
+ const entries = [];
+ for (let i = 0; i < entriesModel.count; i++) {
+ const entry = entriesModel.get(i);
+ let enabled = entry.enabled;
+ if (entryIndex !== undefined && i === entryIndex) {
+ enabled = entryEnabled;
+ }
+ entries.push({
+ id: entry.id,
+ enabled: enabled
+ });
+ }
+ Config.bar.entries = entries;
+ Config.save();
+ }
+
+ ListModel {
+ id: entriesModel
+ }
+
+ ClippingRectangle {
+ id: taskbarClippingRect
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.normal
+ anchors.leftMargin: 0
+ anchors.rightMargin: Appearance.padding.normal
+
+ radius: taskbarBorder.innerRadius
+ color: "transparent"
+
+ Loader {
+ id: taskbarLoader
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.large + Appearance.padding.normal
+ anchors.leftMargin: Appearance.padding.large
+ anchors.rightMargin: Appearance.padding.large
+
+ sourceComponent: taskbarContentComponent
+ }
+ }
+
+ InnerBorder {
+ id: taskbarBorder
+ leftThickness: 0
+ rightThickness: Appearance.padding.normal
+ }
+
+ Component {
+ id: taskbarContentComponent
+
+ StyledFlickable {
+ id: sidebarFlickable
+ flickableDirection: Flickable.VerticalFlick
+ contentHeight: sidebarLayout.height
+
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: sidebarFlickable
+ }
+
+ ColumnLayout {
+ id: sidebarLayout
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+
+ spacing: Appearance.spacing.normal
+
+ RowLayout {
+ spacing: Appearance.spacing.smaller
+
+ StyledText {
+ text: qsTr("Taskbar")
+ font.pointSize: Appearance.font.size.large
+ font.weight: 500
+ }
+ }
+
+ SectionContainer {
+ Layout.fillWidth: true
+ alignTop: true
+
+ StyledText {
+ text: qsTr("Status Icons")
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ ConnectedButtonGroup {
+ rootItem: root
+
+ options: [
+ {
+ label: qsTr("Speakers"),
+ propertyName: "showAudio",
+ onToggled: function (checked) {
+ root.showAudio = checked;
+ root.saveConfig();
+ }
+ },
+ {
+ label: qsTr("Microphone"),
+ propertyName: "showMicrophone",
+ onToggled: function (checked) {
+ root.showMicrophone = checked;
+ root.saveConfig();
+ }
+ },
+ {
+ label: qsTr("Keyboard"),
+ propertyName: "showKbLayout",
+ onToggled: function (checked) {
+ root.showKbLayout = checked;
+ root.saveConfig();
+ }
+ },
+ {
+ label: qsTr("Network"),
+ propertyName: "showNetwork",
+ onToggled: function (checked) {
+ root.showNetwork = checked;
+ root.saveConfig();
+ }
+ },
+ {
+ label: qsTr("Wifi"),
+ propertyName: "showWifi",
+ onToggled: function (checked) {
+ root.showWifi = checked;
+ root.saveConfig();
+ }
+ },
+ {
+ label: qsTr("Bluetooth"),
+ propertyName: "showBluetooth",
+ onToggled: function (checked) {
+ root.showBluetooth = checked;
+ root.saveConfig();
+ }
+ },
+ {
+ label: qsTr("Battery"),
+ propertyName: "showBattery",
+ onToggled: function (checked) {
+ root.showBattery = checked;
+ root.saveConfig();
+ }
+ },
+ {
+ label: qsTr("Capslock"),
+ propertyName: "showLockStatus",
+ onToggled: function (checked) {
+ root.showLockStatus = checked;
+ root.saveConfig();
+ }
+ }
+ ]
+ }
+ }
+
+ RowLayout {
+ id: mainRowLayout
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+
+ ColumnLayout {
+ id: leftColumnLayout
+ Layout.fillWidth: true
+ Layout.alignment: Qt.AlignTop
+ spacing: Appearance.spacing.normal
+
+ SectionContainer {
+ Layout.fillWidth: true
+ alignTop: true
+
+ StyledText {
+ text: qsTr("Workspaces")
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ StyledRect {
+ Layout.fillWidth: true
+ implicitHeight: workspacesShownRow.implicitHeight + Appearance.padding.large * 2
+ radius: Appearance.rounding.normal
+ color: Colours.layer(Colours.palette.m3surfaceContainer, 2)
+
+ Behavior on implicitHeight {
+ Anim {}
+ }
+
+ RowLayout {
+ id: workspacesShownRow
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.large
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ Layout.fillWidth: true
+ text: qsTr("Shown")
+ }
+
+ CustomSpinBox {
+ min: 1
+ max: 20
+ value: root.workspacesShown
+ onValueModified: value => {
+ root.workspacesShown = value;
+ root.saveConfig();
+ }
+ }
+ }
+ }
+
+ StyledRect {
+ Layout.fillWidth: true
+ implicitHeight: workspacesActiveIndicatorRow.implicitHeight + Appearance.padding.large * 2
+ radius: Appearance.rounding.normal
+ color: Colours.layer(Colours.palette.m3surfaceContainer, 2)
+
+ Behavior on implicitHeight {
+ Anim {}
+ }
+
+ RowLayout {
+ id: workspacesActiveIndicatorRow
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.large
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ Layout.fillWidth: true
+ text: qsTr("Active indicator")
+ }
+
+ StyledSwitch {
+ checked: root.workspacesActiveIndicator
+ onToggled: {
+ root.workspacesActiveIndicator = checked;
+ root.saveConfig();
+ }
+ }
+ }
+ }
+
+ StyledRect {
+ Layout.fillWidth: true
+ implicitHeight: workspacesOccupiedBgRow.implicitHeight + Appearance.padding.large * 2
+ radius: Appearance.rounding.normal
+ color: Colours.layer(Colours.palette.m3surfaceContainer, 2)
+
+ Behavior on implicitHeight {
+ Anim {}
+ }
+
+ RowLayout {
+ id: workspacesOccupiedBgRow
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.large
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ Layout.fillWidth: true
+ text: qsTr("Occupied background")
+ }
+
+ StyledSwitch {
+ checked: root.workspacesOccupiedBg
+ onToggled: {
+ root.workspacesOccupiedBg = checked;
+ root.saveConfig();
+ }
+ }
+ }
+ }
+
+ StyledRect {
+ Layout.fillWidth: true
+ implicitHeight: workspacesShowWindowsRow.implicitHeight + Appearance.padding.large * 2
+ radius: Appearance.rounding.normal
+ color: Colours.layer(Colours.palette.m3surfaceContainer, 2)
+
+ Behavior on implicitHeight {
+ Anim {}
+ }
+
+ RowLayout {
+ id: workspacesShowWindowsRow
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.large
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ Layout.fillWidth: true
+ text: qsTr("Show windows")
+ }
+
+ StyledSwitch {
+ checked: root.workspacesShowWindows
+ onToggled: {
+ root.workspacesShowWindows = checked;
+ root.saveConfig();
+ }
+ }
+ }
+ }
+
+ StyledRect {
+ Layout.fillWidth: true
+ implicitHeight: workspacesPerMonitorRow.implicitHeight + Appearance.padding.large * 2
+ radius: Appearance.rounding.normal
+ color: Colours.layer(Colours.palette.m3surfaceContainer, 2)
+
+ Behavior on implicitHeight {
+ Anim {}
+ }
+
+ RowLayout {
+ id: workspacesPerMonitorRow
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.large
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ Layout.fillWidth: true
+ text: qsTr("Per monitor workspaces")
+ }
+
+ StyledSwitch {
+ checked: root.workspacesPerMonitor
+ onToggled: {
+ root.workspacesPerMonitor = checked;
+ root.saveConfig();
+ }
+ }
+ }
+ }
+ }
+
+ SectionContainer {
+ Layout.fillWidth: true
+ alignTop: true
+
+ StyledText {
+ text: qsTr("Scroll Actions")
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ ConnectedButtonGroup {
+ rootItem: root
+
+ options: [
+ {
+ label: qsTr("Workspaces"),
+ propertyName: "scrollWorkspaces",
+ onToggled: function (checked) {
+ root.scrollWorkspaces = checked;
+ root.saveConfig();
+ }
+ },
+ {
+ label: qsTr("Volume"),
+ propertyName: "scrollVolume",
+ onToggled: function (checked) {
+ root.scrollVolume = checked;
+ root.saveConfig();
+ }
+ },
+ {
+ label: qsTr("Brightness"),
+ propertyName: "scrollBrightness",
+ onToggled: function (checked) {
+ root.scrollBrightness = checked;
+ root.saveConfig();
+ }
+ }
+ ]
+ }
+ }
+ }
+
+ ColumnLayout {
+ id: middleColumnLayout
+ Layout.fillWidth: true
+ Layout.alignment: Qt.AlignTop
+ spacing: Appearance.spacing.normal
+
+ SectionContainer {
+ Layout.fillWidth: true
+ alignTop: true
+
+ StyledText {
+ text: qsTr("Clock")
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ SwitchRow {
+ label: qsTr("Show clock icon")
+ checked: root.clockShowIcon
+ onToggled: checked => {
+ root.clockShowIcon = checked;
+ root.saveConfig();
+ }
+ }
+ }
+
+ SectionContainer {
+ Layout.fillWidth: true
+ alignTop: true
+
+ StyledText {
+ text: qsTr("Bar Behavior")
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ SwitchRow {
+ label: qsTr("Persistent")
+ checked: root.persistent
+ onToggled: checked => {
+ root.persistent = checked;
+ root.saveConfig();
+ }
+ }
+
+ SwitchRow {
+ label: qsTr("Show on hover")
+ checked: root.showOnHover
+ onToggled: checked => {
+ root.showOnHover = checked;
+ root.saveConfig();
+ }
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.normal
+
+ SliderInput {
+ Layout.fillWidth: true
+
+ label: qsTr("Drag threshold")
+ value: root.dragThreshold
+ from: 0
+ to: 100
+ suffix: "px"
+ validator: IntValidator {
+ bottom: 0
+ top: 100
+ }
+ formatValueFunction: val => Math.round(val).toString()
+ parseValueFunction: text => parseInt(text)
+
+ onValueModified: newValue => {
+ root.dragThreshold = Math.round(newValue);
+ root.saveConfig();
+ }
+ }
+ }
+ }
+ }
+
+ ColumnLayout {
+ id: rightColumnLayout
+ Layout.fillWidth: true
+ Layout.alignment: Qt.AlignTop
+ spacing: Appearance.spacing.normal
+
+ SectionContainer {
+ Layout.fillWidth: true
+ alignTop: true
+
+ StyledText {
+ text: qsTr("Popouts")
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ SwitchRow {
+ label: qsTr("Active window")
+ checked: root.popoutActiveWindow
+ onToggled: checked => {
+ root.popoutActiveWindow = checked;
+ root.saveConfig();
+ }
+ }
+
+ SwitchRow {
+ label: qsTr("Tray")
+ checked: root.popoutTray
+ onToggled: checked => {
+ root.popoutTray = checked;
+ root.saveConfig();
+ }
+ }
+
+ SwitchRow {
+ label: qsTr("Status icons")
+ checked: root.popoutStatusIcons
+ onToggled: checked => {
+ root.popoutStatusIcons = checked;
+ root.saveConfig();
+ }
+ }
+ }
+
+ SectionContainer {
+ Layout.fillWidth: true
+ alignTop: true
+
+ StyledText {
+ text: qsTr("Tray Settings")
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ ConnectedButtonGroup {
+ rootItem: root
+
+ options: [
+ {
+ label: qsTr("Background"),
+ propertyName: "trayBackground",
+ onToggled: function (checked) {
+ root.trayBackground = checked;
+ root.saveConfig();
+ }
+ },
+ {
+ label: qsTr("Compact"),
+ propertyName: "trayCompact",
+ onToggled: function (checked) {
+ root.trayCompact = checked;
+ root.saveConfig();
+ }
+ },
+ {
+ label: qsTr("Recolour"),
+ propertyName: "trayRecolour",
+ onToggled: function (checked) {
+ root.trayRecolour = checked;
+ root.saveConfig();
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/dashboard/Background.qml b/.config/quickshell/caelestia/modules/dashboard/Background.qml
new file mode 100644
index 0000000..e2a91f7
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/dashboard/Background.qml
@@ -0,0 +1,66 @@
+import qs.components
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Shapes
+
+ShapePath {
+ id: root
+
+ required property Wrapper wrapper
+ readonly property real rounding: Config.border.rounding
+ readonly property bool flatten: wrapper.height < rounding * 2
+ readonly property real roundingY: flatten ? wrapper.height / 2 : rounding
+
+ strokeWidth: -1
+ fillColor: Colours.palette.m3surface
+
+ 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 {
+ CAnim {}
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/dashboard/Content.qml b/.config/quickshell/caelestia/modules/dashboard/Content.qml
new file mode 100644
index 0000000..1cc960a
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/dashboard/Content.qml
@@ -0,0 +1,152 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.components.filedialog
+import qs.config
+import Quickshell
+import Quickshell.Widgets
+import QtQuick
+import QtQuick.Layouts
+
+Item {
+ id: root
+
+ required property PersistentProperties visibilities
+ required property PersistentProperties state
+ required property FileDialog facePicker
+ readonly property real nonAnimWidth: view.implicitWidth + viewWrapper.anchors.margins * 2
+ readonly property real nonAnimHeight: tabs.implicitHeight + tabs.anchors.topMargin + view.implicitHeight + viewWrapper.anchors.margins * 2
+
+ implicitWidth: nonAnimWidth
+ implicitHeight: nonAnimHeight
+
+ 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 - anchors.margins * 2
+ state: root.state
+ }
+
+ 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: root.state.currentTab
+ 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)
+ root.state.currentTab = Math.min(root.state.currentTab + 1, tabs.count - 1);
+ else if (x < -currentItem.implicitWidth / 2)
+ root.state.currentTab = Math.max(root.state.currentTab - 1, 0);
+ }
+
+ onDragEnded: {
+ const x = contentX - currentItem.x;
+ if (x > currentItem.implicitWidth / 10)
+ root.state.currentTab = Math.min(root.state.currentTab + 1, tabs.count - 1);
+ else if (x < -currentItem.implicitWidth / 10)
+ root.state.currentTab = Math.max(root.state.currentTab - 1, 0);
+ else
+ contentX = Qt.binding(() => currentItem.x);
+ }
+
+ RowLayout {
+ id: row
+
+ Pane {
+ index: 0
+ sourceComponent: Dash {
+ visibilities: root.visibilities
+ state: root.state
+ facePicker: root.facePicker
+ }
+ }
+
+ Pane {
+ index: 1
+ sourceComponent: Media {
+ visibilities: root.visibilities
+ }
+ }
+
+ Pane {
+ index: 2
+ sourceComponent: Performance {}
+ }
+
+ Pane {
+ index: 3
+ sourceComponent: Weather {}
+ }
+ }
+
+ Behavior on contentX {
+ Anim {}
+ }
+ }
+ }
+
+ Behavior on implicitWidth {
+ Anim {
+ duration: Appearance.anim.durations.large
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+
+ Behavior on implicitHeight {
+ Anim {
+ duration: Appearance.anim.durations.large
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+
+ component Pane: Loader {
+ id: pane
+
+ required property int index
+
+ Layout.alignment: Qt.AlignTop
+
+ Component.onCompleted: active = Qt.binding(() => {
+ // Always keep current tab loaded
+ if (pane.index === view.currentIndex)
+ return true;
+ const vx = Math.floor(view.visibleArea.xPosition * view.contentWidth);
+ const vex = Math.floor(vx + view.visibleArea.widthRatio * view.contentWidth);
+ return (vx >= x && vx <= x + implicitWidth) || (vex >= x && vex <= x + implicitWidth);
+ })
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/dashboard/Dash.qml b/.config/quickshell/caelestia/modules/dashboard/Dash.qml
new file mode 100644
index 0000000..71e224f
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/dashboard/Dash.qml
@@ -0,0 +1,105 @@
+import qs.components
+import qs.components.filedialog
+import qs.services
+import qs.config
+import "dash"
+import Quickshell
+import QtQuick.Layouts
+
+GridLayout {
+ id: root
+
+ required property PersistentProperties visibilities
+ required property PersistentProperties state
+ required property FileDialog facePicker
+
+ rowSpacing: Appearance.spacing.normal
+ columnSpacing: Appearance.spacing.normal
+
+ Rect {
+ Layout.column: 2
+ Layout.columnSpan: 3
+ Layout.preferredWidth: user.implicitWidth
+ Layout.preferredHeight: user.implicitHeight
+
+ radius: Appearance.rounding.large
+
+ User {
+ id: user
+
+ visibilities: root.visibilities
+ state: root.state
+ facePicker: root.facePicker
+ }
+ }
+
+ Rect {
+ Layout.row: 0
+ Layout.columnSpan: 2
+ Layout.preferredWidth: Config.dashboard.sizes.weatherWidth
+ Layout.fillHeight: true
+
+ radius: Appearance.rounding.large * 1.5
+
+ Weather {}
+ }
+
+ Rect {
+ Layout.row: 1
+ Layout.preferredWidth: dateTime.implicitWidth
+ Layout.fillHeight: true
+
+ radius: Appearance.rounding.normal
+
+ DateTime {
+ id: dateTime
+ }
+ }
+
+ Rect {
+ Layout.row: 1
+ Layout.column: 1
+ Layout.columnSpan: 3
+ Layout.fillWidth: true
+ Layout.preferredHeight: calendar.implicitHeight
+
+ radius: Appearance.rounding.large
+
+ Calendar {
+ id: calendar
+
+ state: root.state
+ }
+ }
+
+ Rect {
+ Layout.row: 1
+ Layout.column: 4
+ Layout.preferredWidth: resources.implicitWidth
+ Layout.fillHeight: true
+
+ radius: Appearance.rounding.normal
+
+ Resources {
+ id: resources
+ }
+ }
+
+ Rect {
+ Layout.row: 0
+ Layout.column: 5
+ Layout.rowSpan: 2
+ Layout.preferredWidth: media.implicitWidth
+ Layout.fillHeight: true
+
+ radius: Appearance.rounding.large * 2
+
+ Media {
+ id: media
+ }
+ }
+
+ component Rect: StyledRect {
+ color: Colours.tPalette.m3surfaceContainer
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/dashboard/Media.qml b/.config/quickshell/caelestia/modules/dashboard/Media.qml
new file mode 100644
index 0000000..722bc93
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/dashboard/Media.qml
@@ -0,0 +1,403 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.components.controls
+import qs.services
+import qs.utils
+import qs.config
+import Caelestia.Services
+import Quickshell
+import Quickshell.Services.Mpris
+import QtQuick
+import QtQuick.Layouts
+import QtQuick.Shapes
+
+Item {
+ id: root
+
+ 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";
+
+ const hours = Math.floor(length / 3600);
+ const mins = Math.floor((length % 3600) / 60);
+ const secs = Math.floor(length % 60).toString().padStart(2, "0");
+
+ if (hours > 0)
+ return `${hours}:${mins.toString().padStart(2, "0")}:${secs}`;
+ return `${mins}:${secs}`;
+ }
+
+ implicitWidth: cover.implicitWidth + Config.dashboard.sizes.mediaVisualiserSize * 2 + details.implicitWidth + details.anchors.leftMargin + bongocat.implicitWidth + bongocat.anchors.leftMargin * 2 + Appearance.padding.large * 2
+ implicitHeight: Math.max(cover.implicitHeight + Config.dashboard.sizes.mediaVisualiserSize * 2, details.implicitHeight, bongocat.implicitHeight) + Appearance.padding.large * 2
+
+ Behavior on playerProgress {
+ Anim {
+ duration: Appearance.anim.durations.large
+ }
+ }
+
+ Timer {
+ running: Players.active?.isPlaying ?? false
+ interval: Config.dashboard.mediaUpdateInterval
+ triggeredOnStart: true
+ repeat: true
+ onTriggered: Players.active?.positionChanged()
+ }
+
+ ServiceRef {
+ service: Audio.cava
+ }
+
+ ServiceRef {
+ service: Audio.beatTracker
+ }
+
+ Shape {
+ 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: -Config.dashboard.sizes.mediaVisualiserSize
+
+ asynchronous: true
+ preferredRendererType: Shape.CurveRenderer
+ data: visualiserBars.instances
+ }
+
+ Variants {
+ id: visualiserBars
+
+ model: Array.from({
+ length: Config.services.visualiserBars
+ }, (_, i) => i)
+
+ ShapePath {
+ id: visualiserBar
+
+ required property int modelData
+ readonly property real value: Math.max(1e-3, Math.min(1, Audio.cava.values[modelData]))
+
+ readonly property real angle: modelData * 2 * Math.PI / Config.services.visualiserBars
+ readonly property real magnitude: value * Config.dashboard.sizes.mediaVisualiserSize
+ readonly property real cos: Math.cos(angle)
+ readonly property real sin: Math.sin(angle)
+
+ capStyle: Appearance.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap
+ strokeWidth: 360 / Config.services.visualiserBars - Appearance.spacing.small / 4
+ strokeColor: Colours.palette.m3primary
+
+ startX: visualiser.centerX + (visualiser.innerX + strokeWidth / 2) * cos
+ startY: visualiser.centerY + (visualiser.innerY + strokeWidth / 2) * sin
+
+ PathLine {
+ x: visualiser.centerX + (visualiser.innerX + visualiserBar.strokeWidth / 2 + visualiserBar.magnitude) * visualiserBar.cos
+ y: visualiser.centerY + (visualiser.innerY + visualiserBar.strokeWidth / 2 + visualiserBar.magnitude) * visualiserBar.sin
+ }
+
+ Behavior on strokeColor {
+ CAnim {}
+ }
+ }
+ }
+
+ StyledClippingRect {
+ id: cover
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: parent.left
+ anchors.leftMargin: Appearance.padding.large + Config.dashboard.sizes.mediaVisualiserSize
+
+ implicitWidth: Config.dashboard.sizes.mediaCoverArtSize
+ implicitHeight: Config.dashboard.sizes.mediaCoverArtSize
+
+ color: Colours.tPalette.m3surfaceContainerHigh
+ radius: Infinity
+
+ MaterialIcon {
+ anchors.centerIn: parent
+
+ grade: 200
+ text: "art_track"
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: (parent.width * 0.4) || 1
+ }
+
+ Image {
+ id: image
+
+ anchors.fill: parent
+
+ source: Players.active?.trackArtUrl ?? "" // qmllint disable incompatible-type
+ asynchronous: true
+ fillMode: Image.PreserveAspectCrop
+ sourceSize.width: width
+ sourceSize.height: height
+ }
+ }
+
+ ColumnLayout {
+ id: details
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: visualiser.right
+ anchors.leftMargin: Appearance.spacing.normal
+
+ spacing: Appearance.spacing.small
+
+ StyledText {
+ id: title
+
+ Layout.fillWidth: true
+ Layout.maximumWidth: parent.implicitWidth
+
+ animate: true
+ horizontalAlignment: Text.AlignHCenter
+ text: (Players.active?.trackTitle ?? qsTr("No media")) || qsTr("Unknown title")
+ color: Players.active ? Colours.palette.m3primary : Colours.palette.m3onSurface
+ font.pointSize: Appearance.font.size.normal
+ elide: Text.ElideRight
+ }
+
+ StyledText {
+ id: album
+
+ Layout.fillWidth: true
+ Layout.maximumWidth: parent.implicitWidth
+
+ animate: true
+ horizontalAlignment: Text.AlignHCenter
+ visible: !!Players.active
+ text: Players.active?.trackAlbum || qsTr("Unknown album")
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ elide: Text.ElideRight
+ }
+
+ StyledText {
+ id: artist
+
+ Layout.fillWidth: true
+ Layout.maximumWidth: parent.implicitWidth
+
+ animate: true
+ horizontalAlignment: Text.AlignHCenter
+ text: (Players.active?.trackArtist ?? qsTr("Play some music for stuff to show up here!")) || qsTr("Unknown artist")
+ color: Players.active ? Colours.palette.m3secondary : Colours.palette.m3outline
+ elide: Text.ElideRight
+ wrapMode: Players.active ? Text.NoWrap : Text.WordWrap
+ }
+
+ RowLayout {
+ id: controls
+
+ Layout.alignment: Qt.AlignHCenter
+ Layout.topMargin: Appearance.spacing.small
+ Layout.bottomMargin: Appearance.spacing.smaller
+
+ spacing: Appearance.spacing.small
+
+ PlayerControl {
+ type: IconButton.Text
+ icon: "skip_previous"
+ font.pointSize: Math.round(Appearance.font.size.large * 1.5)
+ disabled: !Players.active?.canGoPrevious
+ onClicked: Players.active?.previous()
+ }
+
+ PlayerControl {
+ icon: Players.active?.isPlaying ? "pause" : "play_arrow"
+ label.animate: true
+ toggle: true
+ padding: Appearance.padding.small / 2
+ checked: Players.active?.isPlaying ?? false
+ font.pointSize: Math.round(Appearance.font.size.large * 1.5)
+ disabled: !Players.active?.canTogglePlaying
+ onClicked: Players.active?.togglePlaying()
+ }
+
+ PlayerControl {
+ type: IconButton.Text
+ icon: "skip_next"
+ font.pointSize: Math.round(Appearance.font.size.large * 1.5)
+ disabled: !Players.active?.canGoNext
+ onClicked: Players.active?.next()
+ }
+ }
+
+ StyledSlider {
+ id: slider
+
+ enabled: !!Players.active
+ implicitWidth: 280
+ implicitHeight: Appearance.padding.normal * 3
+
+ onMoved: {
+ const active = Players.active;
+ if (active?.canSeek && active?.positionSupported)
+ active.position = value * active.length;
+ }
+
+ Binding {
+ target: slider
+ property: "value"
+ value: root.playerProgress
+ when: !slider.pressed
+ }
+
+ CustomMouseArea {
+ anchors.fill: parent
+ acceptedButtons: Qt.NoButton
+
+ function onWheel(event: WheelEvent) {
+ const active = Players.active;
+ if (!active?.canSeek || !active?.positionSupported)
+ return;
+
+ event.accepted = true;
+ const delta = event.angleDelta.y > 0 ? 10 : -10; // Time 10 seconds
+ Qt.callLater(() => {
+ active.position = Math.max(0, Math.min(active.length, active.position + delta));
+ });
+ }
+ }
+ }
+
+ Item {
+ Layout.fillWidth: true
+ 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
+ }
+ }
+
+ RowLayout {
+ Layout.alignment: Qt.AlignHCenter
+ spacing: Appearance.spacing.small
+
+ PlayerControl {
+ type: IconButton.Text
+ icon: "move_up"
+ inactiveOnColour: Colours.palette.m3secondary
+ padding: Appearance.padding.small
+ font.pointSize: Appearance.font.size.large
+ disabled: !Players.active?.canRaise
+ onClicked: {
+ Players.active?.raise();
+ root.visibilities.dashboard = false;
+ }
+ }
+
+ SplitButton {
+ id: playerSelector
+
+ disabled: !Players.list.length
+ active: menuItems.find(m => m.modelData === Players.active) ?? menuItems[0] ?? null
+ menu.onItemSelected: item => Players.manualActive = (item as PlayerItem).modelData
+
+ menuItems: playerList.instances
+ fallbackIcon: "music_off"
+ fallbackText: qsTr("No players")
+
+ label.Layout.maximumWidth: slider.implicitWidth * 0.28
+ label.elide: Text.ElideRight
+
+ stateLayer.disabled: true
+ menuOnTop: true
+
+ Variants {
+ id: playerList
+
+ model: Players.list
+
+ PlayerItem {}
+ }
+ }
+
+ PlayerControl {
+ type: IconButton.Text
+ icon: "delete"
+ inactiveOnColour: Colours.palette.m3error
+ padding: Appearance.padding.small
+ font.pointSize: Appearance.font.size.large
+ disabled: !Players.active?.canQuit
+ onClicked: 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: Players.active?.isPlaying ?? false
+ speed: Audio.beatTracker.bpm / Appearance.anim.mediaGifSpeedAdjustment // qmllint disable unresolved-type
+ source: Paths.absolutePath(Config.paths.mediaGif)
+ asynchronous: true
+ fillMode: AnimatedImage.PreserveAspectFit
+ }
+ }
+
+ component PlayerItem: MenuItem {
+ required property MprisPlayer modelData
+
+ icon: modelData === Players.active ? "check" : ""
+ text: Players.getIdentity(modelData)
+ activeIcon: "animated_images"
+ }
+
+ component PlayerControl: IconButton {
+ Layout.preferredWidth: implicitWidth + (stateLayer.pressed ? Appearance.padding.large : internalChecked ? Appearance.padding.smaller : 0)
+ radius: stateLayer.pressed ? Appearance.rounding.small / 2 : internalChecked ? Appearance.rounding.small : implicitHeight / 2
+ radiusAnim.duration: Appearance.anim.durations.expressiveFastSpatial
+ radiusAnim.easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
+
+ Behavior on Layout.preferredWidth {
+ Anim {
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/dashboard/Performance.qml b/.config/quickshell/caelestia/modules/dashboard/Performance.qml
new file mode 100644
index 0000000..e73d8ed
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/dashboard/Performance.qml
@@ -0,0 +1,967 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import Quickshell.Services.UPower
+import qs.components
+import qs.components.misc
+import qs.config
+import qs.services
+
+Item {
+ id: root
+
+ readonly property int minWidth: 400 + 400 + Appearance.spacing.normal + 120 + Appearance.padding.large * 2
+
+ function displayTemp(temp: real): string {
+ return `${Math.ceil(Config.services.useFahrenheitPerformance ? temp * 1.8 + 32 : temp)}°${Config.services.useFahrenheitPerformance ? "F" : "C"}`;
+ }
+
+ implicitWidth: Math.max(minWidth, content.implicitWidth)
+ implicitHeight: placeholder.visible ? placeholder.height : content.implicitHeight
+
+ StyledRect {
+ id: placeholder
+
+ anchors.centerIn: parent
+ width: 400
+ height: 350
+ radius: Appearance.rounding.large
+ color: Colours.tPalette.m3surfaceContainer
+ visible: !Config.dashboard.performance.showCpu && !(Config.dashboard.performance.showGpu && SystemUsage.gpuType !== "NONE") && !Config.dashboard.performance.showMemory && !Config.dashboard.performance.showStorage && !Config.dashboard.performance.showNetwork && !(UPower.displayDevice.isLaptopBattery && Config.dashboard.performance.showBattery)
+
+ ColumnLayout {
+ anchors.centerIn: parent
+ spacing: Appearance.spacing.normal
+
+ MaterialIcon {
+ Layout.alignment: Qt.AlignHCenter
+ text: "tune"
+ font.pointSize: Appearance.font.size.extraLarge * 2
+ color: Colours.palette.m3onSurfaceVariant
+ }
+
+ StyledText {
+ Layout.alignment: Qt.AlignHCenter
+ text: qsTr("No widgets enabled")
+ font.pointSize: Appearance.font.size.large
+ color: Colours.palette.m3onSurface
+ }
+
+ StyledText {
+ Layout.alignment: Qt.AlignHCenter
+ text: qsTr("Enable widgets in dashboard settings")
+ font.pointSize: Appearance.font.size.small
+ color: Colours.palette.m3onSurfaceVariant
+ }
+ }
+ }
+
+ RowLayout {
+ id: content
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ spacing: Appearance.spacing.normal
+ visible: !placeholder.visible
+
+ Ref {
+ service: SystemUsage
+ }
+
+ ColumnLayout {
+ id: mainColumn
+
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+ visible: Config.dashboard.performance.showCpu || (Config.dashboard.performance.showGpu && SystemUsage.gpuType !== "NONE")
+
+ HeroCard {
+ Layout.fillWidth: true
+ Layout.minimumWidth: 400
+ Layout.preferredHeight: 150
+ visible: Config.dashboard.performance.showCpu
+ icon: "memory"
+ title: SystemUsage.cpuName ? `CPU - ${SystemUsage.cpuName}` : qsTr("CPU")
+ mainValue: `${Math.round(SystemUsage.cpuPerc * 100)}%`
+ mainLabel: qsTr("Usage")
+ secondaryValue: root.displayTemp(SystemUsage.cpuTemp)
+ secondaryLabel: qsTr("Temp")
+ usage: SystemUsage.cpuPerc
+ temperature: SystemUsage.cpuTemp
+ accentColor: Colours.palette.m3primary
+ }
+
+ HeroCard {
+ Layout.fillWidth: true
+ Layout.minimumWidth: 400
+ Layout.preferredHeight: 150
+ visible: Config.dashboard.performance.showGpu && SystemUsage.gpuType !== "NONE"
+ icon: "desktop_windows"
+ title: SystemUsage.gpuName ? `GPU - ${SystemUsage.gpuName}` : qsTr("GPU")
+ mainValue: `${Math.round(SystemUsage.gpuPerc * 100)}%`
+ mainLabel: qsTr("Usage")
+ secondaryValue: root.displayTemp(SystemUsage.gpuTemp)
+ secondaryLabel: qsTr("Temp")
+ usage: SystemUsage.gpuPerc
+ temperature: SystemUsage.gpuTemp
+ accentColor: Colours.palette.m3secondary
+ }
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+ visible: Config.dashboard.performance.showMemory || Config.dashboard.performance.showStorage || Config.dashboard.performance.showNetwork
+
+ GaugeCard {
+ Layout.minimumWidth: 250
+ Layout.preferredHeight: 220
+ Layout.fillWidth: !Config.dashboard.performance.showStorage && !Config.dashboard.performance.showNetwork
+ icon: "memory_alt"
+ title: qsTr("Memory")
+ percentage: SystemUsage.memPerc
+ subtitle: {
+ const usedFmt = SystemUsage.formatKib(SystemUsage.memUsed);
+ const totalFmt = SystemUsage.formatKib(SystemUsage.memTotal);
+ return `${usedFmt.value.toFixed(1)} / ${Math.floor(totalFmt.value)} ${totalFmt.unit}`;
+ }
+ accentColor: Colours.palette.m3tertiary
+ visible: Config.dashboard.performance.showMemory
+ }
+
+ StorageGaugeCard {
+ Layout.minimumWidth: 250
+ Layout.preferredHeight: 220
+ Layout.fillWidth: !Config.dashboard.performance.showNetwork
+ visible: Config.dashboard.performance.showStorage
+ }
+
+ NetworkCard {
+ Layout.fillWidth: true
+ Layout.minimumWidth: 200
+ Layout.preferredHeight: 220
+ visible: Config.dashboard.performance.showNetwork
+ }
+ }
+ }
+
+ BatteryTank {
+ Layout.preferredWidth: 120
+ Layout.preferredHeight: mainColumn.implicitHeight
+ visible: UPower.displayDevice.isLaptopBattery && Config.dashboard.performance.showBattery
+ }
+ }
+
+ component BatteryTank: StyledClippingRect {
+ id: batteryTank
+
+ property real percentage: UPower.displayDevice.percentage
+ property bool isCharging: UPower.displayDevice.state === UPowerDeviceState.Charging
+ property color accentColor: Colours.palette.m3primary
+ property real animatedPercentage: 0
+
+ color: Colours.tPalette.m3surfaceContainer
+ radius: Appearance.rounding.large
+ Component.onCompleted: animatedPercentage = percentage
+ onPercentageChanged: animatedPercentage = percentage
+
+ // Background Fill
+ StyledRect {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ height: parent.height * batteryTank.animatedPercentage
+ color: Qt.alpha(batteryTank.accentColor, 0.15)
+ }
+
+ ColumnLayout {
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.large
+ spacing: Appearance.spacing.small
+
+ // Header Section
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.small
+
+ MaterialIcon {
+ text: {
+ if (!UPower.displayDevice.isLaptopBattery) {
+ if (PowerProfiles.profile === PowerProfile.PowerSaver)
+ return "energy_savings_leaf";
+
+ if (PowerProfiles.profile === PowerProfile.Performance)
+ return "rocket_launch";
+
+ return "balance";
+ }
+ if (UPower.displayDevice.state === UPowerDeviceState.FullyCharged)
+ return "battery_full";
+
+ const perc = UPower.displayDevice.percentage;
+ const charging = [UPowerDeviceState.Charging, UPowerDeviceState.PendingCharge].includes(UPower.displayDevice.state);
+ if (perc >= 0.99)
+ return "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`;
+ }
+ font.pointSize: Appearance.font.size.large
+ color: batteryTank.accentColor
+ }
+
+ StyledText {
+ Layout.fillWidth: true
+ text: qsTr("Battery")
+ font.pointSize: Appearance.font.size.normal
+ color: Colours.palette.m3onSurface
+ }
+ }
+
+ Item {
+ Layout.fillHeight: true
+ }
+
+ // Bottom Info Section
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: -4
+
+ StyledText {
+ Layout.alignment: Qt.AlignRight
+ text: `${Math.round(batteryTank.percentage * 100)}%`
+ font.pointSize: Appearance.font.size.extraLarge
+ font.weight: Font.Medium
+ color: batteryTank.accentColor
+ }
+
+ StyledText {
+ Layout.alignment: Qt.AlignRight
+ text: {
+ if (UPower.displayDevice.state === UPowerDeviceState.FullyCharged)
+ return qsTr("Full");
+
+ if (batteryTank.isCharging)
+ return qsTr("Charging");
+
+ const s = UPower.displayDevice.timeToEmpty;
+ if (s === 0)
+ return qsTr("...");
+
+ const hr = Math.floor(s / 3600);
+ const min = Math.floor((s % 3600) / 60);
+ if (hr > 0)
+ return `${hr}h ${min}m`;
+
+ return `${min}m`;
+ }
+ font.pointSize: Appearance.font.size.smaller
+ color: Colours.palette.m3onSurfaceVariant
+ }
+ }
+ }
+
+ Behavior on animatedPercentage {
+ Anim {
+ duration: Appearance.anim.durations.large
+ }
+ }
+ }
+
+ component CardHeader: RowLayout {
+ property string icon
+ property string title
+ property color accentColor: Colours.palette.m3primary
+
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.small
+
+ MaterialIcon {
+ text: parent.icon
+ fill: 1
+ color: parent.accentColor
+ font.pointSize: Appearance.spacing.large
+ }
+
+ StyledText {
+ Layout.fillWidth: true
+ text: parent.title
+ font.pointSize: Appearance.font.size.normal
+ elide: Text.ElideRight
+ }
+ }
+
+ component ProgressBar: StyledRect {
+ id: progressBar
+
+ property real value: 0
+ property color fgColor: Colours.palette.m3primary
+ property color bgColor: Colours.layer(Colours.palette.m3surfaceContainerHigh, 2)
+ property real animatedValue: 0
+
+ color: bgColor
+ radius: Appearance.rounding.full
+ Component.onCompleted: animatedValue = value
+ onValueChanged: animatedValue = value
+
+ StyledRect {
+ anchors.left: parent.left
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ width: parent.width * progressBar.animatedValue
+ color: progressBar.fgColor
+ radius: Appearance.rounding.full
+ }
+
+ Behavior on animatedValue {
+ Anim {
+ duration: Appearance.anim.durations.large
+ }
+ }
+ }
+
+ component HeroCard: StyledClippingRect {
+ id: heroCard
+
+ property string icon
+ property string title
+ property string mainValue
+ property string mainLabel
+ property string secondaryValue
+ property string secondaryLabel
+ property real usage: 0
+ property real temperature: 0
+ property color accentColor: Colours.palette.m3primary
+ readonly property real maxTemp: 100
+ readonly property real tempProgress: Math.min(1, Math.max(0, temperature / maxTemp))
+ property real animatedUsage: 0
+ property real animatedTemp: 0
+
+ color: Colours.tPalette.m3surfaceContainer
+ radius: Appearance.rounding.large
+ Component.onCompleted: {
+ animatedUsage = usage;
+ animatedTemp = tempProgress;
+ }
+ onUsageChanged: animatedUsage = usage
+ onTempProgressChanged: animatedTemp = tempProgress
+
+ StyledRect {
+ anchors.left: parent.left
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ width: parent.width * heroCard.animatedUsage
+ color: Qt.alpha(heroCard.accentColor, 0.15)
+ }
+
+ ColumnLayout {
+ anchors.fill: parent
+ anchors.leftMargin: Appearance.padding.large
+ anchors.rightMargin: Appearance.padding.large
+ anchors.topMargin: Appearance.padding.normal
+ anchors.bottomMargin: Appearance.padding.normal
+ spacing: Appearance.spacing.small
+
+ CardHeader {
+ icon: heroCard.icon
+ title: heroCard.title
+ accentColor: heroCard.accentColor
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ spacing: Appearance.spacing.normal
+
+ Column {
+ Layout.alignment: Qt.AlignBottom
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.small
+
+ Row {
+ spacing: Appearance.spacing.small
+
+ StyledText {
+ text: heroCard.secondaryValue
+ font.pointSize: Appearance.font.size.normal
+ font.weight: Font.Medium
+ }
+
+ StyledText {
+ text: heroCard.secondaryLabel
+ font.pointSize: Appearance.font.size.small
+ color: Colours.palette.m3onSurfaceVariant
+ anchors.baseline: parent.children[0].baseline
+ }
+ }
+
+ ProgressBar {
+ width: parent.width * 0.5
+ height: 6
+ value: heroCard.tempProgress
+ fgColor: heroCard.accentColor
+ bgColor: Qt.alpha(heroCard.accentColor, 0.2)
+ }
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+ }
+ }
+
+ Column {
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.large
+ anchors.rightMargin: 32
+ spacing: 0
+
+ StyledText {
+ anchors.right: parent.right
+ text: heroCard.mainLabel
+ font.pointSize: Appearance.font.size.normal
+ color: Colours.palette.m3onSurfaceVariant
+ }
+
+ StyledText {
+ anchors.right: parent.right
+ text: heroCard.mainValue
+ font.pointSize: Appearance.font.size.extraLarge
+ font.weight: Font.Medium
+ color: heroCard.accentColor
+ }
+ }
+
+ Behavior on animatedUsage {
+ Anim {
+ duration: Appearance.anim.durations.large
+ }
+ }
+
+ Behavior on animatedTemp {
+ Anim {
+ duration: Appearance.anim.durations.large
+ }
+ }
+ }
+
+ component GaugeCard: StyledRect {
+ id: gaugeCard
+
+ property string icon
+ property string title
+ property real percentage: 0
+ property string subtitle
+ property color accentColor: Colours.palette.m3primary
+ readonly property real arcStartAngle: 0.75 * Math.PI
+ readonly property real arcSweep: 1.5 * Math.PI
+ property real animatedPercentage: 0
+
+ color: Colours.tPalette.m3surfaceContainer
+ radius: Appearance.rounding.large
+ clip: true
+ Component.onCompleted: animatedPercentage = percentage
+ onPercentageChanged: animatedPercentage = percentage
+
+ ColumnLayout {
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.large
+ spacing: Appearance.spacing.smaller
+
+ CardHeader {
+ icon: gaugeCard.icon
+ title: gaugeCard.title
+ accentColor: gaugeCard.accentColor
+ }
+
+ Item {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+
+ Canvas {
+ id: gaugeCanvas
+
+ anchors.centerIn: parent
+ width: Math.min(parent.width, parent.height)
+ height: width
+ onPaint: {
+ const ctx = getContext("2d");
+ ctx.reset();
+ const cx = width / 2;
+ const cy = height / 2;
+ const radius = (Math.min(width, height) - 12) / 2;
+ const lineWidth = 10;
+ ctx.beginPath();
+ ctx.arc(cx, cy, radius, gaugeCard.arcStartAngle, gaugeCard.arcStartAngle + gaugeCard.arcSweep);
+ ctx.lineWidth = lineWidth;
+ ctx.lineCap = "round";
+ ctx.strokeStyle = Colours.layer(Colours.palette.m3surfaceContainerHigh, 2);
+ ctx.stroke();
+ if (gaugeCard.animatedPercentage > 0) {
+ ctx.beginPath();
+ ctx.arc(cx, cy, radius, gaugeCard.arcStartAngle, gaugeCard.arcStartAngle + gaugeCard.arcSweep * gaugeCard.animatedPercentage);
+ ctx.lineWidth = lineWidth;
+ ctx.lineCap = "round";
+ ctx.strokeStyle = gaugeCard.accentColor;
+ ctx.stroke();
+ }
+ }
+ Component.onCompleted: requestPaint()
+
+ Connections {
+ function onAnimatedPercentageChanged() {
+ gaugeCanvas.requestPaint();
+ }
+
+ target: gaugeCard
+ }
+
+ Connections {
+ function onPaletteChanged() {
+ gaugeCanvas.requestPaint();
+ }
+
+ target: Colours
+ }
+ }
+
+ StyledText {
+ anchors.centerIn: parent
+ text: `${Math.round(gaugeCard.percentage * 100)}%`
+ font.pointSize: Appearance.font.size.extraLarge
+ font.weight: Font.Medium
+ color: gaugeCard.accentColor
+ }
+ }
+
+ StyledText {
+ Layout.alignment: Qt.AlignHCenter
+ text: gaugeCard.subtitle
+ font.pointSize: Appearance.font.size.smaller
+ color: Colours.palette.m3onSurfaceVariant
+ }
+ }
+
+ Behavior on animatedPercentage {
+ Anim {
+ duration: Appearance.anim.durations.large
+ }
+ }
+ }
+
+ component StorageGaugeCard: StyledRect {
+ id: storageGaugeCard
+
+ property int currentDiskIndex: 0
+ readonly property var currentDisk: SystemUsage.disks.length > 0 ? SystemUsage.disks[currentDiskIndex] : null
+ property int diskCount: 0
+ readonly property real arcStartAngle: 0.75 * Math.PI
+ readonly property real arcSweep: 1.5 * Math.PI
+ property real animatedPercentage: 0
+ property color accentColor: Colours.palette.m3secondary
+
+ color: Colours.tPalette.m3surfaceContainer
+ radius: Appearance.rounding.large
+ clip: true
+ Component.onCompleted: {
+ diskCount = SystemUsage.disks.length;
+ if (currentDisk)
+ animatedPercentage = currentDisk.perc;
+ }
+ onCurrentDiskChanged: {
+ if (currentDisk)
+ animatedPercentage = currentDisk.perc;
+ }
+
+ // Update diskCount and animatedPercentage when disks data changes
+ Connections {
+ function onDisksChanged() {
+ if (SystemUsage.disks.length !== storageGaugeCard.diskCount)
+ storageGaugeCard.diskCount = SystemUsage.disks.length;
+
+ // Update animated percentage when disk data refreshes
+ if (storageGaugeCard.currentDisk)
+ storageGaugeCard.animatedPercentage = storageGaugeCard.currentDisk.perc;
+ }
+
+ target: SystemUsage
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ onWheel: wheel => {
+ if (wheel.angleDelta.y > 0)
+ storageGaugeCard.currentDiskIndex = (storageGaugeCard.currentDiskIndex - 1 + storageGaugeCard.diskCount) % storageGaugeCard.diskCount;
+ else if (wheel.angleDelta.y < 0)
+ storageGaugeCard.currentDiskIndex = (storageGaugeCard.currentDiskIndex + 1) % storageGaugeCard.diskCount;
+ }
+ }
+
+ ColumnLayout {
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.large
+ spacing: Appearance.spacing.smaller
+
+ CardHeader {
+ icon: "hard_disk"
+ title: {
+ const base = qsTr("Storage");
+ if (!storageGaugeCard.currentDisk)
+ return base;
+
+ return `${base} - ${storageGaugeCard.currentDisk.mount}`;
+ }
+ accentColor: storageGaugeCard.accentColor
+
+ // Scroll hint icon
+ MaterialIcon {
+ text: "unfold_more"
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.normal
+ visible: storageGaugeCard.diskCount > 1
+ opacity: 0.7
+ ToolTip.visible: hintHover.hovered
+ ToolTip.text: qsTr("Scroll to switch disks")
+ ToolTip.delay: 500
+
+ HoverHandler {
+ id: hintHover
+ }
+ }
+ }
+
+ Item {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+
+ Canvas {
+ id: storageGaugeCanvas
+
+ anchors.centerIn: parent
+ width: Math.min(parent.width, parent.height)
+ height: width
+ onPaint: {
+ const ctx = getContext("2d");
+ ctx.reset();
+ const cx = width / 2;
+ const cy = height / 2;
+ const radius = (Math.min(width, height) - 12) / 2;
+ const lineWidth = 10;
+ ctx.beginPath();
+ ctx.arc(cx, cy, radius, storageGaugeCard.arcStartAngle, storageGaugeCard.arcStartAngle + storageGaugeCard.arcSweep);
+ ctx.lineWidth = lineWidth;
+ ctx.lineCap = "round";
+ ctx.strokeStyle = Colours.layer(Colours.palette.m3surfaceContainerHigh, 2);
+ ctx.stroke();
+ if (storageGaugeCard.animatedPercentage > 0) {
+ ctx.beginPath();
+ ctx.arc(cx, cy, radius, storageGaugeCard.arcStartAngle, storageGaugeCard.arcStartAngle + storageGaugeCard.arcSweep * storageGaugeCard.animatedPercentage);
+ ctx.lineWidth = lineWidth;
+ ctx.lineCap = "round";
+ ctx.strokeStyle = storageGaugeCard.accentColor;
+ ctx.stroke();
+ }
+ }
+ Component.onCompleted: requestPaint()
+
+ Connections {
+ function onAnimatedPercentageChanged() {
+ storageGaugeCanvas.requestPaint();
+ }
+
+ target: storageGaugeCard
+ }
+
+ Connections {
+ function onPaletteChanged() {
+ storageGaugeCanvas.requestPaint();
+ }
+
+ target: Colours
+ }
+ }
+
+ StyledText {
+ anchors.centerIn: parent
+ text: storageGaugeCard.currentDisk ? `${Math.round(storageGaugeCard.currentDisk.perc * 100)}%` : "—"
+ font.pointSize: Appearance.font.size.extraLarge
+ font.weight: Font.Medium
+ color: storageGaugeCard.accentColor
+ }
+ }
+
+ StyledText {
+ Layout.alignment: Qt.AlignHCenter
+ text: {
+ if (!storageGaugeCard.currentDisk)
+ return "—";
+
+ const usedFmt = SystemUsage.formatKib(storageGaugeCard.currentDisk.used);
+ const totalFmt = SystemUsage.formatKib(storageGaugeCard.currentDisk.total);
+ return `${usedFmt.value.toFixed(1)} / ${Math.floor(totalFmt.value)} ${totalFmt.unit}`;
+ }
+ font.pointSize: Appearance.font.size.smaller
+ color: Colours.palette.m3onSurfaceVariant
+ }
+ }
+
+ Behavior on animatedPercentage {
+ Anim {
+ duration: Appearance.anim.durations.large
+ }
+ }
+ }
+
+ component NetworkCard: StyledRect {
+ id: networkCard
+
+ property color accentColor: Colours.palette.m3primary
+
+ color: Colours.tPalette.m3surfaceContainer
+ radius: Appearance.rounding.large
+ clip: true
+
+ Ref {
+ service: NetworkUsage
+ }
+
+ ColumnLayout {
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.large
+ spacing: Appearance.spacing.small
+
+ CardHeader {
+ icon: "swap_vert"
+ title: qsTr("Network")
+ accentColor: networkCard.accentColor
+ }
+
+ // Sparkline graph
+ Item {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+
+ Canvas {
+ id: sparklineCanvas
+
+ property var downHistory: NetworkUsage.downloadHistory
+ property var upHistory: NetworkUsage.uploadHistory
+ property real targetMax: 1024
+ property real smoothMax: targetMax
+ property real slideProgress: 0
+ property int _tickCount: 0
+ property int _lastTickCount: -1
+
+ function checkAndAnimate(): void {
+ const currentLength = (downHistory || []).length;
+ if (currentLength > 0 && _tickCount !== _lastTickCount) {
+ _lastTickCount = _tickCount;
+ updateMax();
+ }
+ }
+
+ function updateMax(): void {
+ const downHist = downHistory || [];
+ const upHist = upHistory || [];
+ const allValues = downHist.concat(upHist);
+ targetMax = Math.max(...allValues, 1024);
+ requestPaint();
+ }
+
+ anchors.fill: parent
+ onDownHistoryChanged: checkAndAnimate()
+ onUpHistoryChanged: checkAndAnimate()
+ onSmoothMaxChanged: requestPaint()
+ onSlideProgressChanged: requestPaint()
+
+ onPaint: {
+ const ctx = getContext("2d");
+ ctx.reset();
+ const w = width;
+ const h = height;
+ const downHist = downHistory || [];
+ const upHist = upHistory || [];
+ if (downHist.length < 2 && upHist.length < 2)
+ return;
+
+ const maxVal = smoothMax;
+
+ const drawLine = (history, color, fillAlpha) => {
+ if (history.length < 2)
+ return;
+
+ const len = history.length;
+ const stepX = w / (NetworkUsage.historyLength - 1);
+ const startX = w - (len - 1) * stepX - stepX * slideProgress + stepX;
+ ctx.beginPath();
+ ctx.moveTo(startX, h - (history[0] / maxVal) * h);
+ for (let i = 1; i < len; i++) {
+ const x = startX + i * stepX;
+ const y = h - (history[i] / maxVal) * h;
+ ctx.lineTo(x, y);
+ }
+ ctx.strokeStyle = color;
+ ctx.lineWidth = 2;
+ ctx.lineCap = "round";
+ ctx.lineJoin = "round";
+ ctx.stroke();
+ ctx.lineTo(startX + (len - 1) * stepX, h);
+ ctx.lineTo(startX, h);
+ ctx.closePath();
+ ctx.fillStyle = Qt.rgba(Qt.color(color).r, Qt.color(color).g, Qt.color(color).b, fillAlpha);
+ ctx.fill();
+ };
+
+ drawLine(upHist, Colours.palette.m3secondary.toString(), 0.15);
+ drawLine(downHist, Colours.palette.m3tertiary.toString(), 0.2);
+ }
+
+ Component.onCompleted: updateMax()
+
+ Connections {
+ function onPaletteChanged() {
+ sparklineCanvas.requestPaint();
+ }
+
+ target: Colours
+ }
+
+ Timer {
+ interval: Config.dashboard.resourceUpdateInterval
+ running: true
+ repeat: true
+ onTriggered: sparklineCanvas._tickCount++
+ }
+
+ NumberAnimation on slideProgress {
+ from: 0
+ to: 1
+ duration: Config.dashboard.resourceUpdateInterval
+ loops: Animation.Infinite
+ running: true
+ }
+
+ Behavior on smoothMax {
+ Anim {
+ duration: Appearance.anim.durations.large
+ }
+ }
+ }
+
+ // "No data" placeholder
+ StyledText {
+ anchors.centerIn: parent
+ text: qsTr("Collecting data...")
+ font.pointSize: Appearance.font.size.small
+ color: Colours.palette.m3onSurfaceVariant
+ visible: NetworkUsage.downloadHistory.length < 2
+ opacity: 0.6
+ }
+ }
+
+ // Download row
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+
+ MaterialIcon {
+ text: "download"
+ color: Colours.palette.m3tertiary
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ StyledText {
+ text: qsTr("Download")
+ font.pointSize: Appearance.font.size.small
+ color: Colours.palette.m3onSurfaceVariant
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ StyledText {
+ text: {
+ const fmt = NetworkUsage.formatBytes(NetworkUsage.downloadSpeed ?? 0);
+ return fmt ? `${fmt.value.toFixed(1)} ${fmt.unit}` : "0.0 B/s";
+ }
+ font.pointSize: Appearance.font.size.normal
+ font.weight: Font.Medium
+ color: Colours.palette.m3tertiary
+ }
+ }
+
+ // Upload row
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+
+ MaterialIcon {
+ text: "upload"
+ color: Colours.palette.m3secondary
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ StyledText {
+ text: qsTr("Upload")
+ font.pointSize: Appearance.font.size.small
+ color: Colours.palette.m3onSurfaceVariant
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ StyledText {
+ text: {
+ const fmt = NetworkUsage.formatBytes(NetworkUsage.uploadSpeed ?? 0);
+ return fmt ? `${fmt.value.toFixed(1)} ${fmt.unit}` : "0.0 B/s";
+ }
+ font.pointSize: Appearance.font.size.normal
+ font.weight: Font.Medium
+ color: Colours.palette.m3secondary
+ }
+ }
+
+ // Session totals
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+
+ MaterialIcon {
+ text: "history"
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ StyledText {
+ text: qsTr("Total")
+ font.pointSize: Appearance.font.size.small
+ color: Colours.palette.m3onSurfaceVariant
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ StyledText {
+ text: {
+ const down = NetworkUsage.formatBytesTotal(NetworkUsage.downloadTotal ?? 0);
+ const up = NetworkUsage.formatBytesTotal(NetworkUsage.uploadTotal ?? 0);
+ return (down && up) ? `↓${down.value.toFixed(1)}${down.unit} ↑${up.value.toFixed(1)}${up.unit}` : "↓0.0B ↑0.0B";
+ }
+ font.pointSize: Appearance.font.size.small
+ color: Colours.palette.m3onSurfaceVariant
+ }
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/dashboard/Tabs.qml b/.config/quickshell/caelestia/modules/dashboard/Tabs.qml
new file mode 100644
index 0000000..1d50d26
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/dashboard/Tabs.qml
@@ -0,0 +1,247 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.components.controls
+import qs.services
+import qs.config
+import Quickshell
+import Quickshell.Widgets
+import QtQuick
+import QtQuick.Controls
+
+Item {
+ id: root
+
+ required property real nonAnimWidth
+ required property PersistentProperties state
+ readonly property alias count: bar.count
+
+ 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
+
+ currentIndex: root.state.currentTab
+ background: null
+
+ onCurrentIndexChanged: root.state.currentTab = currentIndex
+
+ Tab {
+ iconName: "dashboard"
+ text: qsTr("Dashboard")
+ }
+
+ Tab {
+ iconName: "queue_music"
+ text: qsTr("Media")
+ }
+
+ Tab {
+ iconName: "speed"
+ text: qsTr("Performance")
+ }
+
+ Tab {
+ iconName: "cloud"
+ text: qsTr("Weather")
+ }
+
+ // Tab {
+ // iconName: "workspaces"
+ // text: qsTr("Workspaces")
+ // }
+ }
+
+ Item {
+ id: indicator
+
+ anchors.top: bar.bottom
+ anchors.topMargin: 5
+
+ implicitWidth: bar.currentItem.implicitWidth
+ implicitHeight: 3
+
+ x: {
+ const tab = bar.currentItem;
+ const width = (root.nonAnimWidth - bar.spacing * (bar.count - 1)) / 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: CustomMouseArea {
+ id: mouse
+
+ implicitWidth: Math.max(icon.width, label.width)
+ implicitHeight: icon.height + label.height
+
+ cursorShape: Qt.PointingHandCursor
+
+ onPressed: event => {
+ root.state.currentTab = tab.TabBar.index;
+
+ const stateY = stateWrapper.y;
+ rippleAnim.x = event.x;
+ rippleAnim.y = event.y - stateY;
+
+ const dist = (ox, oy) => ox * ox + oy * oy;
+ rippleAnim.radius = Math.sqrt(Math.max(dist(event.x, event.y + stateY), dist(event.x, stateWrapper.height - event.y), dist(width - event.x, event.y + stateY), dist(width - event.x, stateWrapper.height - event.y)));
+
+ rippleAnim.restart();
+ }
+
+ function onWheel(event: WheelEvent): void {
+ if (event.angleDelta.y < 0)
+ root.state.currentTab = Math.min(root.state.currentTab + 1, bar.count - 1);
+ else if (event.angleDelta.y > 0)
+ root.state.currentTab = Math.max(root.state.currentTab - 1, 0);
+ }
+
+ 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.08
+ }
+ Anim {
+ target: ripple
+ properties: "implicitWidth,implicitHeight"
+ from: 0
+ to: rippleAnim.radius * 2
+ duration: Appearance.anim.durations.normal
+ easing.bezierCurve: Appearance.anim.curves.standardDecel
+ }
+ Anim {
+ target: ripple
+ property: "opacity"
+ to: 0
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+
+ ClippingRectangle {
+ id: stateWrapper
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ implicitHeight: parent.height + Config.dashboard.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 {
+ Anim {}
+ }
+ }
+
+ StyledText {
+ id: label
+
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.bottom: parent.bottom
+
+ text: tab.text
+ color: tab.current ? Colours.palette.m3primary : Colours.palette.m3onSurfaceVariant
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/dashboard/Weather.qml b/.config/quickshell/caelestia/modules/dashboard/Weather.qml
new file mode 100644
index 0000000..3981633
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/dashboard/Weather.qml
@@ -0,0 +1,280 @@
+import qs.components
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+Item {
+ id: root
+
+ implicitWidth: layout.implicitWidth > 800 ? layout.implicitWidth : 840
+ implicitHeight: layout.implicitHeight
+
+ readonly property var today: Weather.forecast && Weather.forecast.length > 0 ? Weather.forecast[0] : null
+
+ Component.onCompleted: Weather.reload()
+
+ ColumnLayout {
+ id: layout
+
+ anchors.fill: parent
+ spacing: Appearance.spacing.smaller
+
+ RowLayout {
+ Layout.leftMargin: Appearance.padding.large
+ Layout.rightMargin: Appearance.padding.large
+ Layout.fillWidth: true
+
+ Column {
+ spacing: Appearance.spacing.small / 2
+
+ StyledText {
+ text: Weather.city || qsTr("Loading...")
+ font.pointSize: Appearance.font.size.extraLarge
+ font.weight: 600
+ color: Colours.palette.m3onSurface
+ }
+
+ StyledText {
+ text: new Date().toLocaleDateString(Qt.locale(), "dddd, MMMM d")
+ font.pointSize: Appearance.font.size.small
+ color: Colours.palette.m3onSurfaceVariant
+ }
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ Row {
+ spacing: Appearance.spacing.large
+
+ WeatherStat {
+ icon: "wb_twilight"
+ label: "Sunrise"
+ value: Weather.sunrise
+ colour: Colours.palette.m3tertiary
+ }
+
+ WeatherStat {
+ icon: "bedtime"
+ label: "Sunset"
+ value: Weather.sunset
+ colour: Colours.palette.m3tertiary
+ }
+ }
+ }
+
+ StyledRect {
+ Layout.fillWidth: true
+ implicitHeight: bigInfoRow.implicitHeight + Appearance.padding.small * 2
+
+ radius: Appearance.rounding.large * 2
+ color: Colours.tPalette.m3surfaceContainer
+
+ RowLayout {
+ id: bigInfoRow
+
+ anchors.centerIn: parent
+ spacing: Appearance.spacing.large
+
+ MaterialIcon {
+ Layout.alignment: Qt.AlignVCenter
+ text: Weather.icon
+ font.pointSize: Appearance.font.size.extraLarge * 3
+ color: Colours.palette.m3secondary
+ animate: true
+ }
+
+ ColumnLayout {
+ Layout.alignment: Qt.AlignVCenter
+ spacing: -Appearance.spacing.small
+
+ StyledText {
+ text: Weather.temp
+ font.pointSize: Appearance.font.size.extraLarge * 2
+ font.weight: 500
+ color: Colours.palette.m3primary
+ }
+
+ StyledText {
+ Layout.leftMargin: Appearance.padding.small
+ text: Weather.description
+ font.pointSize: Appearance.font.size.normal
+ color: Colours.palette.m3onSurfaceVariant
+ }
+ }
+ }
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.smaller
+
+ DetailCard {
+ icon: "water_drop"
+ label: "Humidity"
+ value: Weather.humidity + "%"
+ colour: Colours.palette.m3secondary
+ }
+ DetailCard {
+ icon: "thermostat"
+ label: "Feels Like"
+ value: Weather.feelsLike
+ colour: Colours.palette.m3primary
+ }
+ DetailCard {
+ icon: "air"
+ label: "Wind"
+ value: Weather.windSpeed ? Weather.windSpeed + " km/h" : "--"
+ colour: Colours.palette.m3tertiary
+ }
+ }
+
+ StyledText {
+ Layout.topMargin: Appearance.spacing.normal
+ Layout.leftMargin: Appearance.padding.normal
+ visible: forecastRepeater.count > 0
+ text: qsTr("7-Day Forecast")
+ font.pointSize: Appearance.font.size.normal
+ font.weight: 600
+ color: Colours.palette.m3onSurface
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.smaller
+
+ Repeater {
+ id: forecastRepeater
+
+ model: Weather.forecast
+
+ StyledRect {
+ id: forecastItem
+
+ required property int index
+ required property var modelData
+
+ Layout.fillWidth: true
+ implicitHeight: forecastItemColumn.implicitHeight + Appearance.padding.normal * 2
+
+ radius: Appearance.rounding.normal
+ color: Colours.tPalette.m3surfaceContainer
+
+ ColumnLayout {
+ id: forecastItemColumn
+
+ anchors.centerIn: parent
+ spacing: Appearance.spacing.small
+
+ StyledText {
+ Layout.alignment: Qt.AlignHCenter
+ text: forecastItem.index === 0 ? qsTr("Today") : new Date(forecastItem.modelData.date).toLocaleDateString(Qt.locale(), "ddd")
+ font.pointSize: Appearance.font.size.normal
+ font.weight: 600
+ color: Colours.palette.m3primary
+ }
+
+ StyledText {
+ Layout.topMargin: -Appearance.spacing.small / 2
+ Layout.alignment: Qt.AlignHCenter
+ text: new Date(forecastItem.modelData.date).toLocaleDateString(Qt.locale(), "MMM d")
+ font.pointSize: Appearance.font.size.small
+ opacity: 0.7
+ color: Colours.palette.m3onSurfaceVariant
+ }
+
+ MaterialIcon {
+ Layout.alignment: Qt.AlignHCenter
+ text: forecastItem.modelData.icon
+ font.pointSize: Appearance.font.size.extraLarge
+ color: Colours.palette.m3secondary
+ }
+
+ StyledText {
+ Layout.alignment: Qt.AlignHCenter
+ text: Config.services.useFahrenheit ? forecastItem.modelData.maxTempF + "°" + " / " + forecastItem.modelData.minTempF + "°" : forecastItem.modelData.maxTempC + "°" + " / " + forecastItem.modelData.minTempC + "°"
+ font.weight: 600
+ color: Colours.palette.m3tertiary
+ }
+ }
+ }
+ }
+ }
+ }
+
+ component DetailCard: StyledRect {
+ id: detailRoot
+
+ property string icon
+ property string label
+ property string value
+ property color colour
+
+ Layout.fillWidth: true
+ Layout.preferredHeight: 60
+ radius: Appearance.rounding.small
+ color: Colours.tPalette.m3surfaceContainer
+
+ Row {
+ anchors.centerIn: parent
+ spacing: Appearance.spacing.normal
+
+ MaterialIcon {
+ text: detailRoot.icon
+ color: detailRoot.colour
+ font.pointSize: Appearance.font.size.large
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Column {
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 0
+
+ StyledText {
+ text: detailRoot.label
+ font.pointSize: Appearance.font.size.smaller
+ opacity: 0.7
+ horizontalAlignment: Text.AlignLeft
+ }
+ StyledText {
+ text: detailRoot.value
+ font.weight: 600
+ horizontalAlignment: Text.AlignLeft
+ }
+ }
+ }
+ }
+
+ component WeatherStat: Row {
+ id: weatherStat
+
+ property string icon
+ property string label
+ property string value
+ property color colour
+
+ spacing: Appearance.spacing.small
+
+ MaterialIcon {
+ text: weatherStat.icon
+ font.pointSize: Appearance.font.size.extraLarge
+ color: weatherStat.colour
+ }
+
+ Column {
+ StyledText {
+ text: weatherStat.label
+ font.pointSize: Appearance.font.size.smaller
+ color: Colours.palette.m3onSurfaceVariant
+ }
+ StyledText {
+ text: weatherStat.value
+ font.pointSize: Appearance.font.size.small
+ font.weight: 600
+ color: Colours.palette.m3onSurface
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/dashboard/Wrapper.qml b/.config/quickshell/caelestia/modules/dashboard/Wrapper.qml
new file mode 100644
index 0000000..0e37909
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/dashboard/Wrapper.qml
@@ -0,0 +1,105 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.components.filedialog
+import qs.config
+import qs.utils
+import Caelestia
+import Quickshell
+import QtQuick
+
+Item {
+ id: root
+
+ required property PersistentProperties visibilities
+ readonly property PersistentProperties dashState: PersistentProperties {
+ property int currentTab
+ property date currentDate: new Date()
+
+ reloadableId: "dashboardState"
+ }
+ readonly property FileDialog facePicker: FileDialog {
+ title: qsTr("Select a profile picture")
+ filterLabel: qsTr("Image files")
+ filters: Images.validImageExtensions
+ onAccepted: path => {
+ if (CUtils.copyFile(Qt.resolvedUrl(path), Qt.resolvedUrl(`${Paths.home}/.face`)))
+ Quickshell.execDetached(["notify-send", "-a", "caelestia-shell", "-u", "low", "-h", `STRING:image-path:${path}`, "Profile picture changed", `Profile picture changed to ${Paths.shortenHome(path)}`]);
+ else
+ Quickshell.execDetached(["notify-send", "-a", "caelestia-shell", "-u", "critical", "Unable to change profile picture", `Failed to change profile picture to ${Paths.shortenHome(path)}`]);
+ }
+ }
+
+ readonly property real nonAnimHeight: state === "visible" ? (content.item?.nonAnimHeight ?? 0) : 0
+
+ visible: height > 0
+ implicitHeight: 0
+ implicitWidth: content.implicitWidth
+
+ onStateChanged: {
+ if (state === "visible" && timer.running) {
+ timer.triggered();
+ timer.stop();
+ }
+ }
+
+ states: State {
+ name: "visible"
+ when: root.visibilities.dashboard && Config.dashboard.enabled
+
+ PropertyChanges {
+ root.implicitHeight: content.implicitHeight
+ }
+ }
+
+ transitions: [
+ Transition {
+ from: ""
+ to: "visible"
+
+ Anim {
+ target: root
+ property: "implicitHeight"
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ },
+ Transition {
+ from: "visible"
+ to: ""
+
+ Anim {
+ target: root
+ property: "implicitHeight"
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+ ]
+
+ Timer {
+ id: timer
+
+ running: true
+ interval: Appearance.anim.durations.extraLarge
+ onTriggered: {
+ content.active = Qt.binding(() => (root.visibilities.dashboard && Config.dashboard.enabled) || root.visible);
+ content.visible = true;
+ }
+ }
+
+ Loader {
+ id: content
+
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.bottom: parent.bottom
+
+ visible: false
+ active: true
+
+ sourceComponent: Content {
+ visibilities: root.visibilities
+ state: root.dashState
+ facePicker: root.facePicker
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/dashboard/dash/Calendar.qml b/.config/quickshell/caelestia/modules/dashboard/dash/Calendar.qml
new file mode 100644
index 0000000..56c0493
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/dashboard/dash/Calendar.qml
@@ -0,0 +1,253 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.components.effects
+import qs.components.controls
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+
+CustomMouseArea {
+ id: root
+
+ required property var state
+
+ readonly property int currMonth: state.currentDate.getMonth()
+ readonly property int currYear: state.currentDate.getFullYear()
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ implicitHeight: inner.implicitHeight + inner.anchors.margins * 2
+
+ acceptedButtons: Qt.MiddleButton
+ onClicked: root.state.currentDate = new Date()
+
+ function onWheel(event: WheelEvent): void {
+ if (event.angleDelta.y > 0)
+ root.state.currentDate = new Date(root.currYear, root.currMonth - 1, 1);
+ else if (event.angleDelta.y < 0)
+ root.state.currentDate = new Date(root.currYear, root.currMonth + 1, 1);
+ }
+
+ ColumnLayout {
+ id: inner
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.large
+ spacing: Appearance.spacing.small
+
+ RowLayout {
+ id: monthNavigationRow
+
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.small
+
+ Item {
+ implicitWidth: implicitHeight
+ implicitHeight: prevMonthText.implicitHeight + Appearance.padding.small * 2
+
+ StateLayer {
+ id: prevMonthStateLayer
+
+ radius: Appearance.rounding.full
+
+ function onClicked(): void {
+ root.state.currentDate = new Date(root.currYear, root.currMonth - 1, 1);
+ }
+ }
+
+ MaterialIcon {
+ id: prevMonthText
+
+ anchors.centerIn: parent
+ text: "chevron_left"
+ color: Colours.palette.m3tertiary
+ font.pointSize: Appearance.font.size.normal
+ font.weight: 700
+ }
+ }
+
+ Item {
+ Layout.fillWidth: true
+
+ implicitWidth: monthYearDisplay.implicitWidth + Appearance.padding.small * 2
+ implicitHeight: monthYearDisplay.implicitHeight + Appearance.padding.small * 2
+
+ StateLayer {
+ anchors.fill: monthYearDisplay
+ anchors.margins: -Appearance.padding.small
+ anchors.leftMargin: -Appearance.padding.normal
+ anchors.rightMargin: -Appearance.padding.normal
+
+ radius: Appearance.rounding.full
+ disabled: {
+ const now = new Date();
+ return root.currMonth === now.getMonth() && root.currYear === now.getFullYear();
+ }
+
+ function onClicked(): void {
+ root.state.currentDate = new Date();
+ }
+ }
+
+ StyledText {
+ id: monthYearDisplay
+
+ anchors.centerIn: parent
+ text: grid.title
+ color: Colours.palette.m3primary
+ font.pointSize: Appearance.font.size.normal
+ font.weight: 500
+ font.capitalization: Font.Capitalize
+ }
+ }
+
+ Item {
+ implicitWidth: implicitHeight
+ implicitHeight: nextMonthText.implicitHeight + Appearance.padding.small * 2
+
+ StateLayer {
+ id: nextMonthStateLayer
+
+ radius: Appearance.rounding.full
+
+ function onClicked(): void {
+ root.state.currentDate = new Date(root.currYear, root.currMonth + 1, 1);
+ }
+ }
+
+ MaterialIcon {
+ id: nextMonthText
+
+ anchors.centerIn: parent
+ text: "chevron_right"
+ color: Colours.palette.m3tertiary
+ font.pointSize: Appearance.font.size.normal
+ font.weight: 700
+ }
+ }
+ }
+
+ DayOfWeekRow {
+ id: daysRow
+
+ Layout.fillWidth: true
+ locale: grid.locale
+
+ delegate: StyledText {
+ required property var model
+
+ horizontalAlignment: Text.AlignHCenter
+ text: model.shortName
+ font.weight: 500
+ color: (model.day === 0 || model.day === 6) ? Colours.palette.m3secondary : Colours.palette.m3onSurfaceVariant
+ }
+ }
+
+ Item {
+ Layout.fillWidth: true
+ implicitHeight: grid.implicitHeight
+
+ MonthGrid {
+ id: grid
+
+ month: root.currMonth
+ year: root.currYear
+
+ anchors.fill: parent
+
+ spacing: 3
+ locale: Qt.locale()
+
+ delegate: Item {
+ id: dayItem
+
+ required property var model
+
+ implicitWidth: implicitHeight
+ implicitHeight: text.implicitHeight + Appearance.padding.small * 2
+
+ StyledText {
+ id: text
+
+ anchors.centerIn: parent
+
+ horizontalAlignment: Text.AlignHCenter
+ text: grid.locale.toString(dayItem.model.day)
+ color: {
+ const dayOfWeek = dayItem.model.date.getUTCDay();
+ if (dayOfWeek === 0 || dayOfWeek === 6)
+ return Colours.palette.m3secondary;
+
+ return Colours.palette.m3onSurfaceVariant;
+ }
+ opacity: dayItem.model.today || dayItem.model.month === grid.month ? 1 : 0.4
+ font.pointSize: Appearance.font.size.normal
+ font.weight: 500
+ }
+ }
+ }
+
+ StyledRect {
+ id: todayIndicator
+
+ readonly property Item todayItem: grid.contentItem.children.find(c => c.model.today) ?? null
+ property Item today
+
+ onTodayItemChanged: {
+ if (todayItem)
+ today = todayItem;
+ }
+
+ x: today ? today.x + (today.width - implicitWidth) / 2 : 0
+ y: today?.y ?? 0
+
+ implicitWidth: today?.implicitWidth ?? 0
+ implicitHeight: today?.implicitHeight ?? 0
+
+ clip: true
+ radius: Appearance.rounding.full
+ color: Colours.palette.m3primary
+
+ opacity: todayItem ? 1 : 0
+ scale: todayItem ? 1 : 0.7
+
+ Colouriser {
+ x: -todayIndicator.x
+ y: -todayIndicator.y
+
+ implicitWidth: grid.width
+ implicitHeight: grid.height
+
+ source: grid
+ sourceColor: Colours.palette.m3onSurface
+ colorizationColor: Colours.palette.m3onPrimary
+ }
+
+ Behavior on opacity {
+ Anim {}
+ }
+
+ Behavior on scale {
+ Anim {}
+ }
+
+ Behavior on x {
+ Anim {
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ }
+
+ Behavior on y {
+ Anim {
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/dashboard/dash/DateTime.qml b/.config/quickshell/caelestia/modules/dashboard/dash/DateTime.qml
new file mode 100644
index 0000000..e740448
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/dashboard/dash/DateTime.qml
@@ -0,0 +1,65 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+Item {
+ id: root
+
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ implicitWidth: Config.dashboard.sizes.dateTimeWidth
+
+ ColumnLayout {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 0
+
+ StyledText {
+ Layout.bottomMargin: -(font.pointSize * 0.4)
+ Layout.alignment: Qt.AlignHCenter
+ text: Time.hourStr
+ color: Colours.palette.m3secondary
+ font.pointSize: Appearance.font.size.extraLarge
+ font.family: Appearance.font.family.clock
+ font.weight: 600
+ }
+
+ StyledText {
+ Layout.alignment: Qt.AlignHCenter
+ text: "•••"
+ color: Colours.palette.m3primary
+ font.pointSize: Appearance.font.size.extraLarge * 0.9
+ font.family: Appearance.font.family.clock
+ }
+
+ StyledText {
+ Layout.topMargin: -(font.pointSize * 0.4)
+ Layout.alignment: Qt.AlignHCenter
+ text: Time.minuteStr
+ color: Colours.palette.m3secondary
+ font.pointSize: Appearance.font.size.extraLarge
+ font.family: Appearance.font.family.clock
+ font.weight: 600
+ }
+
+ Loader {
+ Layout.alignment: Qt.AlignHCenter
+
+ active: Config.services.useTwelveHourClock
+ visible: active
+
+ sourceComponent: StyledText {
+ text: Time.amPmStr
+ color: Colours.palette.m3primary
+ font.pointSize: Appearance.font.size.large
+ font.family: Appearance.font.family.clock
+ font.weight: 600
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/dashboard/dash/Media.qml b/.config/quickshell/caelestia/modules/dashboard/dash/Media.qml
new file mode 100644
index 0000000..d650669
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/dashboard/dash/Media.qml
@@ -0,0 +1,254 @@
+import qs.components
+import qs.services
+import qs.config
+import qs.utils
+import Caelestia.Services
+import QtQuick
+import QtQuick.Shapes
+
+Item {
+ id: root
+
+ property real playerProgress: {
+ const active = Players.active;
+ return active?.length ? active.position / active.length : 0;
+ }
+
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ implicitWidth: Config.dashboard.sizes.mediaWidth
+
+ Behavior on playerProgress {
+ Anim {
+ duration: Appearance.anim.durations.large
+ }
+ }
+
+ Timer {
+ running: Players.active?.isPlaying ?? false
+ interval: Config.dashboard.mediaUpdateInterval
+ triggeredOnStart: true
+ repeat: true
+ onTriggered: Players.active?.positionChanged()
+ }
+
+ ServiceRef {
+ service: Audio.beatTracker
+ }
+
+ Shape {
+ preferredRendererType: Shape.CurveRenderer
+
+ ShapePath {
+ fillColor: "transparent"
+ strokeColor: Colours.layer(Colours.palette.m3surfaceContainerHigh, 2)
+ strokeWidth: Config.dashboard.sizes.mediaProgressThickness
+ capStyle: Appearance.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap
+
+ PathAngleArc {
+ centerX: cover.x + cover.width / 2
+ centerY: cover.y + cover.height / 2
+ radiusX: (cover.width + Config.dashboard.sizes.mediaProgressThickness) / 2 + Appearance.spacing.small
+ radiusY: (cover.height + Config.dashboard.sizes.mediaProgressThickness) / 2 + Appearance.spacing.small
+ startAngle: -90 - Config.dashboard.sizes.mediaProgressSweep / 2
+ sweepAngle: Config.dashboard.sizes.mediaProgressSweep
+ }
+
+ Behavior on strokeColor {
+ CAnim {}
+ }
+ }
+
+ ShapePath {
+ fillColor: "transparent"
+ strokeColor: Colours.palette.m3primary
+ strokeWidth: Config.dashboard.sizes.mediaProgressThickness
+ capStyle: Appearance.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap
+
+ PathAngleArc {
+ centerX: cover.x + cover.width / 2
+ centerY: cover.y + cover.height / 2
+ radiusX: (cover.width + Config.dashboard.sizes.mediaProgressThickness) / 2 + Appearance.spacing.small
+ radiusY: (cover.height + Config.dashboard.sizes.mediaProgressThickness) / 2 + Appearance.spacing.small
+ startAngle: -90 - Config.dashboard.sizes.mediaProgressSweep / 2
+ sweepAngle: Config.dashboard.sizes.mediaProgressSweep * root.playerProgress
+ }
+
+ Behavior on strokeColor {
+ CAnim {}
+ }
+ }
+ }
+
+ StyledClippingRect {
+ id: cover
+
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.margins: Appearance.padding.large + Config.dashboard.sizes.mediaProgressThickness + Appearance.spacing.small
+
+ implicitHeight: width
+ color: Colours.tPalette.m3surfaceContainerHigh
+ radius: Infinity
+
+ MaterialIcon {
+ anchors.centerIn: parent
+
+ grade: 200
+ text: "art_track"
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: (parent.width * 0.4) || 1
+ }
+
+ Image {
+ id: image
+
+ anchors.fill: parent
+
+ source: Players.active?.trackArtUrl ?? "" // qmllint disable incompatible-type
+ 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: Players.active?.isPlaying ?? false
+ speed: Audio.beatTracker.bpm / Appearance.anim.mediaGifSpeedAdjustment
+ source: Paths.absolutePath(Config.paths.mediaGif)
+ 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/.config/quickshell/caelestia/modules/dashboard/dash/Resources.qml b/.config/quickshell/caelestia/modules/dashboard/dash/Resources.qml
new file mode 100644
index 0000000..7f44a9d
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/dashboard/dash/Resources.qml
@@ -0,0 +1,87 @@
+import qs.components
+import qs.components.misc
+import qs.services
+import qs.config
+import QtQuick
+
+Row {
+ id: root
+
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+
+ padding: Appearance.padding.large
+ spacing: Appearance.spacing.normal
+
+ Ref {
+ service: SystemUsage
+ }
+
+ 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: Config.dashboard.sizes.resourceProgessThickness
+
+ color: Colours.layer(Colours.palette.m3surfaceContainerHigh, 2)
+ 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 {
+ Anim {
+ duration: Appearance.anim.durations.large
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/dashboard/dash/User.qml b/.config/quickshell/caelestia/modules/dashboard/dash/User.qml
new file mode 100644
index 0000000..b66b1f9
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/dashboard/dash/User.qml
@@ -0,0 +1,195 @@
+import qs.components
+import qs.components.effects
+import qs.components.images
+import qs.components.filedialog
+import qs.services
+import qs.config
+import qs.utils
+import Quickshell
+import QtQuick
+
+Row {
+ id: root
+
+ required property PersistentProperties visibilities
+ required property PersistentProperties state
+ required property FileDialog facePicker
+
+ padding: Appearance.padding.large
+ spacing: Appearance.spacing.normal
+
+ StyledClippingRect {
+ implicitWidth: info.implicitHeight
+ implicitHeight: info.implicitHeight
+
+ radius: Appearance.rounding.large
+ color: Colours.layer(Colours.palette.m3surfaceContainerHigh, 2)
+
+ MaterialIcon {
+ anchors.centerIn: parent
+
+ text: "person"
+ fill: 1
+ grade: 200
+ font.pointSize: Math.floor(info.implicitHeight / 2) || 1
+ }
+
+ CachingImage {
+ id: pfp
+
+ anchors.fill: parent
+ path: `${Paths.home}/.face`
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+
+ StyledRect {
+ anchors.fill: parent
+
+ color: Qt.alpha(Colours.palette.m3scrim, 0.5)
+ opacity: parent.containsMouse ? 1 : 0
+
+ Behavior on opacity {
+ Anim {
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ }
+ }
+ }
+
+ StyledRect {
+ anchors.centerIn: parent
+
+ implicitWidth: selectIcon.implicitHeight + Appearance.padding.small * 2
+ implicitHeight: selectIcon.implicitHeight + Appearance.padding.small * 2
+
+ radius: Appearance.rounding.normal
+ color: Colours.palette.m3primary
+ scale: parent.containsMouse ? 1 : 0.5
+ opacity: parent.containsMouse ? 1 : 0
+
+ StateLayer {
+ color: Colours.palette.m3onPrimary
+
+ function onClicked(): void {
+ root.visibilities.launcher = false;
+ root.facePicker.open();
+ }
+ }
+
+ MaterialIcon {
+ id: selectIcon
+
+ anchors.centerIn: parent
+ anchors.horizontalCenterOffset: -font.pointSize * 0.02
+
+ text: "frame_person"
+ color: Colours.palette.m3onPrimary
+ font.pointSize: Appearance.font.size.extraLarge
+ }
+
+ Behavior on scale {
+ Anim {
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
+ }
+ }
+
+ Behavior on opacity {
+ Anim {
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ }
+ }
+ }
+ }
+ }
+
+ Column {
+ id: info
+
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Appearance.spacing.normal
+
+ Item {
+ id: line
+
+ implicitWidth: icon.implicitWidth + text.width + text.anchors.leftMargin
+ implicitHeight: Math.max(icon.implicitHeight, text.implicitHeight)
+
+ ColouredIcon {
+ id: icon
+
+ anchors.left: parent.left
+ anchors.leftMargin: (Config.dashboard.sizes.infoIconSize - implicitWidth) / 2
+
+ source: SysInfo.osLogo
+ implicitSize: Math.floor(Appearance.font.size.normal * 1.34)
+ colour: Colours.palette.m3primary
+ }
+
+ StyledText {
+ id: text
+
+ anchors.verticalCenter: icon.verticalCenter
+ anchors.left: icon.right
+ anchors.leftMargin: icon.anchors.leftMargin
+ text: `: ${SysInfo.osPrettyName || SysInfo.osName}`
+ font.pointSize: Appearance.font.size.normal
+
+ width: Config.dashboard.sizes.infoWidth
+ elide: Text.ElideRight
+ }
+ }
+
+ InfoLine {
+ icon: "select_window_2"
+ text: SysInfo.wm
+ colour: Colours.palette.m3secondary
+ }
+
+ InfoLine {
+ id: uptime
+
+ icon: "timer"
+ text: qsTr("up %1").arg(SysInfo.uptime)
+ colour: Colours.palette.m3tertiary
+ }
+ }
+
+ 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: (Config.dashboard.sizes.infoIconSize - implicitWidth) / 2
+
+ fill: 1
+ text: line.icon
+ color: line.colour
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ 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: Config.dashboard.sizes.infoWidth
+ elide: Text.ElideRight
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/dashboard/dash/Weather.qml b/.config/quickshell/caelestia/modules/dashboard/dash/Weather.qml
new file mode 100644
index 0000000..c90ccf0
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/dashboard/dash/Weather.qml
@@ -0,0 +1,57 @@
+import qs.components
+import qs.services
+import qs.config
+import qs.utils
+import QtQuick
+
+Item {
+ id: root
+
+ anchors.centerIn: parent
+
+ implicitWidth: icon.implicitWidth + info.implicitWidth + info.anchors.leftMargin
+
+ Component.onCompleted: Weather.reload()
+
+ MaterialIcon {
+ id: icon
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: parent.left
+
+ animate: true
+ text: Weather.icon
+ color: Colours.palette.m3secondary
+ font.pointSize: Appearance.font.size.extraLarge * 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.temp
+ color: Colours.palette.m3primary
+ font.pointSize: Appearance.font.size.extraLarge
+ font.weight: 500
+ }
+
+ StyledText {
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ animate: true
+ text: Weather.description
+
+ elide: Text.ElideRight
+ width: Math.min(implicitWidth, root.parent.width - icon.implicitWidth - info.anchors.leftMargin - Appearance.padding.large * 2)
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/drawers/Backgrounds.qml b/.config/quickshell/caelestia/modules/drawers/Backgrounds.qml
new file mode 100644
index 0000000..7fa2ca1
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/drawers/Backgrounds.qml
@@ -0,0 +1,86 @@
+import qs.services
+import qs.config
+import qs.modules.osd as Osd
+import qs.modules.notifications as Notifications
+import qs.modules.session as Session
+import qs.modules.launcher as Launcher
+import qs.modules.dashboard as Dashboard
+import qs.modules.bar.popouts as BarPopouts
+import qs.modules.utilities as Utilities
+import qs.modules.sidebar as Sidebar
+import QtQuick
+import QtQuick.Shapes
+
+Shape {
+ id: root
+
+ required property Panels panels
+ required property Item bar
+
+ anchors.fill: parent
+ anchors.margins: Config.border.thickness
+ anchors.leftMargin: bar.implicitWidth
+ preferredRendererType: Shape.CurveRenderer
+
+ Osd.Background {
+ wrapper: root.panels.osd
+
+ startX: root.width - root.panels.session.width - root.panels.sidebar.width
+ startY: (root.height - wrapper.height) / 2 - rounding
+ }
+
+ Notifications.Background {
+ wrapper: root.panels.notifications
+ sidebar: sidebar
+
+ startX: root.width
+ startY: 0
+ }
+
+ Session.Background {
+ wrapper: root.panels.session
+
+ startX: root.width - root.panels.sidebar.width
+ startY: (root.height - wrapper.height) / 2 - rounding
+ }
+
+ Launcher.Background {
+ wrapper: root.panels.launcher
+
+ startX: (root.width - wrapper.width) / 2 - rounding
+ startY: root.height
+ }
+
+ Dashboard.Background {
+ wrapper: root.panels.dashboard
+
+ startX: (root.width - wrapper.width) / 2 - rounding
+ startY: 0
+ }
+
+ BarPopouts.Background {
+ wrapper: root.panels.popouts
+ invertBottomRounding: wrapper.y + wrapper.height + 1 >= root.height
+
+ startX: wrapper.x
+ startY: wrapper.y - rounding * sideRounding
+ }
+
+ Utilities.Background {
+ wrapper: root.panels.utilities
+ sidebar: sidebar
+
+ startX: root.width
+ startY: root.height
+ }
+
+ Sidebar.Background {
+ id: sidebar
+
+ wrapper: root.panels.sidebar
+ panels: root.panels
+
+ startX: root.width
+ startY: root.panels.notifications.height
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/drawers/Border.qml b/.config/quickshell/caelestia/modules/drawers/Border.qml
new file mode 100644
index 0000000..6fdd73b
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/drawers/Border.qml
@@ -0,0 +1,44 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Effects
+
+Item {
+ id: root
+
+ required property Item bar
+
+ anchors.fill: parent
+
+ StyledRect {
+ anchors.fill: parent
+ color: Colours.palette.m3surface
+
+ layer.enabled: true
+ layer.effect: MultiEffect {
+ maskSource: mask
+ maskEnabled: true
+ maskInverted: true
+ maskThresholdMin: 0.5
+ maskSpreadAtMin: 1
+ }
+ }
+
+ Item {
+ id: mask
+
+ anchors.fill: parent
+ layer.enabled: true
+ visible: false
+
+ Rectangle {
+ anchors.fill: parent
+ anchors.margins: Config.border.thickness
+ anchors.leftMargin: root.bar.implicitWidth
+ radius: Config.border.rounding
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/drawers/Drawers.qml b/.config/quickshell/caelestia/modules/drawers/Drawers.qml
new file mode 100644
index 0000000..93534ec
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/drawers/Drawers.qml
@@ -0,0 +1,181 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.components.containers
+import qs.services
+import qs.config
+import qs.utils
+import qs.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
+ readonly property bool barDisabled: Strings.testRegexList(Config.bar.excludedScreens, modelData.name)
+
+ Exclusions {
+ screen: scope.modelData
+ bar: bar
+ }
+
+ StyledWindow {
+ id: win
+
+ readonly property bool hasFullscreen: Hypr.monitorFor(screen)?.activeWorkspace?.toplevels.values.some(t => t.lastIpcObject.fullscreen === 2) ?? false
+ readonly property int dragMaskPadding: {
+ if (focusGrab.active || panels.popouts.isDetached)
+ return 0;
+
+ const mon = Hypr.monitorFor(screen);
+ if (mon?.lastIpcObject?.specialWorkspace?.name || mon?.activeWorkspace?.lastIpcObject?.windows > 0)
+ return 0;
+
+ const thresholds = [];
+ for (const panel of ["dashboard", "launcher", "session", "sidebar"])
+ if (Config[panel].enabled)
+ thresholds.push(Config[panel].dragThreshold);
+ return Math.max(...thresholds);
+ }
+
+ onHasFullscreenChanged: {
+ visibilities.launcher = false;
+ visibilities.session = false;
+ visibilities.dashboard = false;
+ }
+
+ screen: scope.modelData
+ name: "drawers"
+ WlrLayershell.exclusionMode: ExclusionMode.Ignore
+ WlrLayershell.keyboardFocus: visibilities.launcher || visibilities.session ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None
+
+ mask: Region {
+ x: bar.implicitWidth + win.dragMaskPadding
+ y: Config.border.thickness + win.dragMaskPadding
+ width: win.width - bar.implicitWidth - Config.border.thickness - win.dragMaskPadding * 2
+ height: win.height - Config.border.thickness * 2 - win.dragMaskPadding * 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 + Config.border.thickness
+ width: modelData.width
+ height: modelData.height
+ intersection: Intersection.Subtract
+ }
+ }
+
+ HyprlandFocusGrab {
+ id: focusGrab
+
+ active: (visibilities.launcher && Config.launcher.enabled) || (visibilities.session && Config.session.enabled) || (visibilities.sidebar && Config.sidebar.enabled) || (!Config.dashboard.showOnHover && visibilities.dashboard && Config.dashboard.enabled) || (panels.popouts.currentName.startsWith("traymenu") && panels.popouts.current?.depth > 1)
+ windows: [win]
+ onCleared: {
+ visibilities.launcher = false;
+ visibilities.session = false;
+ visibilities.sidebar = false;
+ visibilities.dashboard = false;
+ panels.popouts.hasCurrent = false;
+ bar.closeTray();
+ }
+ }
+
+ StyledRect {
+ anchors.fill: parent
+ opacity: visibilities.session && Config.session.enabled ? 0.5 : 0
+ color: Colours.palette.m3scrim
+
+ Behavior on opacity {
+ Anim {}
+ }
+ }
+
+ Item {
+ anchors.fill: parent
+ opacity: Colours.transparency.enabled ? Colours.transparency.base : 1
+ layer.enabled: true
+ layer.effect: MultiEffect {
+ shadowEnabled: true
+ blurMax: 15
+ shadowColor: Qt.alpha(Colours.palette.m3shadow, 0.7)
+ }
+
+ Border {
+ bar: bar
+ }
+
+ Backgrounds {
+ panels: panels
+ bar: bar
+ }
+ }
+
+ PersistentProperties {
+ id: visibilities
+
+ property bool bar
+ property bool osd
+ property bool session
+ property bool launcher
+ property bool dashboard
+ property bool utilities
+ property bool sidebar
+
+ Component.onCompleted: Visibilities.load(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
+ }
+
+ BarWrapper {
+ id: bar
+
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+
+ screen: scope.modelData
+ visibilities: visibilities
+ popouts: panels.popouts
+
+ disabled: scope.barDisabled
+
+ Component.onCompleted: Visibilities.bars.set(scope.modelData, this)
+ }
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/drawers/Exclusions.qml b/.config/quickshell/caelestia/modules/drawers/Exclusions.qml
new file mode 100644
index 0000000..e4015c8
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/drawers/Exclusions.qml
@@ -0,0 +1,39 @@
+pragma ComponentBehavior: Bound
+
+import qs.components.containers
+import qs.config
+import Quickshell
+import QtQuick
+
+Scope {
+ id: root
+
+ required property ShellScreen screen
+ required property Item bar
+
+ ExclusionZone {
+ anchors.left: true
+ exclusiveZone: root.bar.exclusiveZone
+ }
+
+ ExclusionZone {
+ anchors.top: true
+ }
+
+ ExclusionZone {
+ anchors.right: true
+ }
+
+ ExclusionZone {
+ anchors.bottom: true
+ }
+
+ component ExclusionZone: StyledWindow {
+ screen: root.screen
+ name: "border-exclusion"
+ exclusiveZone: Config.border.thickness
+ mask: Region {}
+ implicitWidth: 1
+ implicitHeight: 1
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/drawers/Interactions.qml b/.config/quickshell/caelestia/modules/drawers/Interactions.qml
new file mode 100644
index 0000000..9579b15
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/drawers/Interactions.qml
@@ -0,0 +1,274 @@
+import qs.components.controls
+import qs.config
+import qs.modules.bar.popouts as BarPopouts
+import Quickshell
+import QtQuick
+
+CustomMouseArea {
+ id: root
+
+ required property ShellScreen screen
+ required property BarPopouts.Wrapper popouts
+ required property PersistentProperties visibilities
+ required property Panels panels
+ required property Item bar
+
+ property point dragStart
+ property bool dashboardShortcutActive
+ property bool osdShortcutActive
+ property bool utilitiesShortcutActive
+
+ function withinPanelHeight(panel: Item, x: real, y: real): bool {
+ const panelY = Config.border.thickness + panel.y;
+ return y >= panelY - Config.border.rounding && y <= panelY + panel.height + Config.border.rounding;
+ }
+
+ function withinPanelWidth(panel: Item, x: real, y: real): bool {
+ const panelX = bar.implicitWidth + panel.x;
+ return x >= panelX - Config.border.rounding && x <= panelX + panel.width + Config.border.rounding;
+ }
+
+ function inLeftPanel(panel: Item, x: real, y: real): bool {
+ return x < bar.implicitWidth + panel.x + panel.width && withinPanelHeight(panel, x, y);
+ }
+
+ 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 {
+ return y < Config.border.thickness + panel.y + panel.height && withinPanelWidth(panel, x, y);
+ }
+
+ function inBottomPanel(panel: Item, x: real, y: real): bool {
+ return y > root.height - Config.border.thickness - panel.height - Config.border.rounding && withinPanelWidth(panel, x, y);
+ }
+
+ function onWheel(event: WheelEvent): void {
+ if (event.x < bar.implicitWidth) {
+ bar.handleWheel(event.y, event.angleDelta);
+ }
+ }
+
+ anchors.fill: parent
+ hoverEnabled: true
+
+ onPressed: event => dragStart = Qt.point(event.x, event.y)
+ onContainsMouseChanged: {
+ if (!containsMouse) {
+ // Only hide if not activated by shortcut
+ if (!osdShortcutActive) {
+ visibilities.osd = false;
+ root.panels.osd.hovered = false;
+ }
+
+ if (!dashboardShortcutActive)
+ visibilities.dashboard = false;
+
+ if (!utilitiesShortcutActive)
+ visibilities.utilities = false;
+
+ if (!popouts.currentName.startsWith("traymenu") || (popouts.current?.depth ?? 0) <= 1) {
+ popouts.hasCurrent = false;
+ bar.closeTray();
+ }
+
+ if (Config.bar.showOnHover)
+ bar.isHovered = false;
+ }
+ }
+
+ onPositionChanged: event => {
+ if (popouts.isDetached)
+ return;
+
+ const x = event.x;
+ const y = event.y;
+ const dragX = x - dragStart.x;
+ const dragY = y - dragStart.y;
+
+ // Show bar in non-exclusive mode on hover
+ if (!visibilities.bar && Config.bar.showOnHover && x < bar.implicitWidth)
+ bar.isHovered = true;
+
+ // Show/hide bar on drag
+ if (pressed && dragStart.x < bar.implicitWidth) {
+ if (dragX > Config.bar.dragThreshold)
+ visibilities.bar = true;
+ else if (dragX < -Config.bar.dragThreshold)
+ visibilities.bar = false;
+ }
+
+ if (panels.sidebar.width === 0) {
+ // Show osd on hover
+ const showOsd = inRightPanel(panels.osd, x, y);
+
+ // Always update visibility based on hover if not in shortcut mode
+ if (!osdShortcutActive) {
+ visibilities.osd = showOsd;
+ root.panels.osd.hovered = showOsd;
+ } else if (showOsd) {
+ // If hovering over OSD area while in shortcut mode, transition to hover control
+ osdShortcutActive = false;
+ root.panels.osd.hovered = true;
+ }
+
+ const showSidebar = pressed && dragStart.x > bar.implicitWidth + panels.sidebar.x;
+
+ // Show/hide session on drag
+ if (pressed && inRightPanel(panels.session, dragStart.x, dragStart.y) && withinPanelHeight(panels.session, x, y)) {
+ if (dragX < -Config.session.dragThreshold)
+ visibilities.session = true;
+ else if (dragX > Config.session.dragThreshold)
+ visibilities.session = false;
+
+ // Show sidebar on drag if in session area and session is nearly fully visible
+ if (showSidebar && panels.session.width >= panels.session.nonAnimWidth && dragX < -Config.sidebar.dragThreshold)
+ visibilities.sidebar = true;
+ } else if (showSidebar && dragX < -Config.sidebar.dragThreshold) {
+ // Show sidebar on drag if not in session area
+ visibilities.sidebar = true;
+ }
+ } else {
+ const outOfSidebar = x < width - panels.sidebar.width;
+ // Show osd on hover
+ const showOsd = outOfSidebar && inRightPanel(panels.osd, x, y);
+
+ // Always update visibility based on hover if not in shortcut mode
+ if (!osdShortcutActive) {
+ visibilities.osd = showOsd;
+ root.panels.osd.hovered = showOsd;
+ } else if (showOsd) {
+ // If hovering over OSD area while in shortcut mode, transition to hover control
+ osdShortcutActive = false;
+ root.panels.osd.hovered = true;
+ }
+
+ // Show/hide session on drag
+ if (pressed && outOfSidebar && inRightPanel(panels.session, dragStart.x, dragStart.y) && withinPanelHeight(panels.session, x, y)) {
+ if (dragX < -Config.session.dragThreshold)
+ visibilities.session = true;
+ else if (dragX > Config.session.dragThreshold)
+ visibilities.session = false;
+ }
+
+ // Hide sidebar on drag
+ if (pressed && inRightPanel(panels.sidebar, dragStart.x, 0) && dragX > Config.sidebar.dragThreshold)
+ visibilities.sidebar = false;
+ }
+
+ // Show launcher on hover, or show/hide on drag if hover is disabled
+ if (Config.launcher.showOnHover) {
+ if (!visibilities.launcher && inBottomPanel(panels.launcher, x, y))
+ visibilities.launcher = true;
+ } else if (pressed && inBottomPanel(panels.launcher, dragStart.x, dragStart.y) && withinPanelWidth(panels.launcher, x, y)) {
+ if (dragY < -Config.launcher.dragThreshold)
+ visibilities.launcher = true;
+ else if (dragY > Config.launcher.dragThreshold)
+ visibilities.launcher = false;
+ }
+
+ // Show dashboard on hover
+ const showDashboard = Config.dashboard.showOnHover && inTopPanel(panels.dashboard, x, y);
+
+ // Always update visibility based on hover if not in shortcut mode
+ if (!dashboardShortcutActive) {
+ visibilities.dashboard = showDashboard;
+ } else if (showDashboard) {
+ // If hovering over dashboard area while in shortcut mode, transition to hover control
+ dashboardShortcutActive = false;
+ }
+
+ // Show/hide dashboard on drag (for touchscreen devices)
+ if (pressed && inTopPanel(panels.dashboard, dragStart.x, dragStart.y) && withinPanelWidth(panels.dashboard, x, y)) {
+ if (dragY > Config.dashboard.dragThreshold)
+ visibilities.dashboard = true;
+ else if (dragY < -Config.dashboard.dragThreshold)
+ visibilities.dashboard = false;
+ }
+
+ // Show utilities on hover
+ const showUtilities = inBottomPanel(panels.utilities, x, y);
+
+ // Always update visibility based on hover if not in shortcut mode
+ if (!utilitiesShortcutActive) {
+ visibilities.utilities = showUtilities;
+ } else if (showUtilities) {
+ // If hovering over utilities area while in shortcut mode, transition to hover control
+ utilitiesShortcutActive = false;
+ }
+
+ // Show popouts on hover
+ if (x < bar.implicitWidth) {
+ bar.checkPopout(y);
+ } else if ((!popouts.currentName.startsWith("traymenu") || (popouts.current?.depth ?? 0) <= 1) && !inLeftPanel(panels.popouts, x, y)) {
+ popouts.hasCurrent = false;
+ bar.closeTray();
+ }
+ }
+
+ // Monitor individual visibility changes
+ Connections {
+ target: root.visibilities
+
+ function onLauncherChanged() {
+ // If launcher is hidden, clear shortcut flags for dashboard and OSD
+ if (!root.visibilities.launcher) {
+ root.dashboardShortcutActive = false;
+ root.osdShortcutActive = false;
+ root.utilitiesShortcutActive = false;
+
+ // Also hide dashboard and OSD if they're not being hovered
+ const inDashboardArea = root.inTopPanel(root.panels.dashboard, root.mouseX, root.mouseY);
+ const inOsdArea = root.inRightPanel(root.panels.osd, root.mouseX, root.mouseY);
+
+ if (!inDashboardArea) {
+ root.visibilities.dashboard = false;
+ }
+ if (!inOsdArea) {
+ root.visibilities.osd = false;
+ root.panels.osd.hovered = false;
+ }
+ }
+ }
+
+ function onDashboardChanged() {
+ if (root.visibilities.dashboard) {
+ // Dashboard became visible, immediately check if this should be shortcut mode
+ const inDashboardArea = root.inTopPanel(root.panels.dashboard, root.mouseX, root.mouseY);
+ if (!inDashboardArea) {
+ root.dashboardShortcutActive = true;
+ }
+ } else {
+ // Dashboard hidden, clear shortcut flag
+ root.dashboardShortcutActive = false;
+ }
+ }
+
+ function onOsdChanged() {
+ if (root.visibilities.osd) {
+ // OSD became visible, immediately check if this should be shortcut mode
+ const inOsdArea = root.inRightPanel(root.panels.osd, root.mouseX, root.mouseY);
+ if (!inOsdArea) {
+ root.osdShortcutActive = true;
+ }
+ } else {
+ // OSD hidden, clear shortcut flag
+ root.osdShortcutActive = false;
+ }
+ }
+
+ function onUtilitiesChanged() {
+ if (root.visibilities.utilities) {
+ // Utilities became visible, immediately check if this should be shortcut mode
+ const inUtilitiesArea = root.inBottomPanel(root.panels.utilities, root.mouseX, root.mouseY);
+ if (!inUtilitiesArea) {
+ root.utilitiesShortcutActive = true;
+ }
+ } else {
+ // Utilities hidden, clear shortcut flag
+ root.utilitiesShortcutActive = false;
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/drawers/Panels.qml b/.config/quickshell/caelestia/modules/drawers/Panels.qml
new file mode 100644
index 0000000..7705732
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/drawers/Panels.qml
@@ -0,0 +1,136 @@
+import qs.config
+import qs.modules.osd as Osd
+import qs.modules.notifications as Notifications
+import qs.modules.session as Session
+import qs.modules.launcher as Launcher
+import qs.modules.dashboard as Dashboard
+import qs.modules.bar.popouts as BarPopouts
+import qs.modules.utilities as Utilities
+import qs.modules.utilities.toasts as Toasts
+import qs.modules.sidebar as Sidebar
+import Quickshell
+import QtQuick
+
+Item {
+ id: root
+
+ required property ShellScreen screen
+ required property PersistentProperties visibilities
+ required property Item bar
+
+ readonly property alias osd: osd
+ readonly property alias notifications: notifications
+ readonly property alias session: session
+ readonly property alias launcher: launcher
+ readonly property alias dashboard: dashboard
+ readonly property alias popouts: popouts
+ readonly property alias utilities: utilities
+ readonly property alias toasts: toasts
+ readonly property alias sidebar: sidebar
+
+ anchors.fill: parent
+ anchors.margins: Config.border.thickness
+ anchors.leftMargin: bar.implicitWidth
+
+ Osd.Wrapper {
+ id: osd
+
+ clip: session.width > 0 || sidebar.width > 0
+ screen: root.screen
+ visibilities: root.visibilities
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.right: parent.right
+ anchors.rightMargin: session.width + sidebar.width
+ }
+
+ Notifications.Wrapper {
+ id: notifications
+
+ visibilities: root.visibilities
+ panels: root
+
+ anchors.top: parent.top
+ anchors.right: parent.right
+ }
+
+ Session.Wrapper {
+ id: session
+
+ clip: sidebar.width > 0
+ visibilities: root.visibilities
+ panels: root
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.right: parent.right
+ anchors.rightMargin: sidebar.width
+ }
+
+ Launcher.Wrapper {
+ id: launcher
+
+ screen: root.screen
+ visibilities: root.visibilities
+ panels: root
+
+ 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
+
+ x: isDetached ? (root.width - nonAnimWidth) / 2 : 0
+ y: {
+ if (isDetached)
+ return (root.height - nonAnimHeight) / 2;
+
+ const off = currentCenter - Config.border.thickness - nonAnimHeight / 2;
+ const diff = root.height - Math.floor(off + nonAnimHeight);
+ if (diff < 0)
+ return off + diff;
+ return Math.max(off, 0);
+ }
+ }
+
+ Utilities.Wrapper {
+ id: utilities
+
+ visibilities: root.visibilities
+ sidebar: sidebar
+ popouts: popouts
+
+ anchors.bottom: parent.bottom
+ anchors.right: parent.right
+ }
+
+ Toasts.Toasts {
+ id: toasts
+
+ anchors.bottom: sidebar.visible ? parent.bottom : utilities.top
+ anchors.right: sidebar.left
+ anchors.margins: Appearance.padding.normal
+ }
+
+ Sidebar.Wrapper {
+ id: sidebar
+
+ visibilities: root.visibilities
+ panels: root
+
+ anchors.top: notifications.bottom
+ anchors.bottom: utilities.top
+ anchors.right: parent.right
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/launcher/AppList.qml b/.config/quickshell/caelestia/modules/launcher/AppList.qml
new file mode 100644
index 0000000..7f7b843
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/launcher/AppList.qml
@@ -0,0 +1,257 @@
+pragma ComponentBehavior: Bound
+
+import "items"
+import "services"
+import qs.components
+import qs.components.controls
+import qs.components.containers
+import qs.services
+import qs.config
+import Quickshell
+import QtQuick
+
+StyledListView {
+ id: root
+
+ required property StyledTextField search
+ required property PersistentProperties visibilities
+
+ model: ScriptModel {
+ id: model
+
+ onValuesChanged: root.currentIndex = 0
+ }
+
+ spacing: Appearance.spacing.small
+ orientation: Qt.Vertical
+ implicitHeight: (Config.launcher.sizes.itemHeight + spacing) * Math.min(Config.launcher.maxShown, count) - spacing
+
+ preferredHighlightBegin: 0
+ preferredHighlightEnd: height
+ highlightRangeMode: ListView.ApplyRange
+
+ highlightFollowsCurrentItem: false
+ highlight: StyledRect {
+ radius: Appearance.rounding.normal
+ color: Colours.palette.m3onSurface
+ opacity: 0.08
+
+ y: root.currentItem?.y ?? 0
+ implicitWidth: root.width
+ implicitHeight: root.currentItem?.implicitHeight ?? 0
+
+ Behavior on y {
+ Anim {
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ }
+ }
+
+ state: {
+ const text = search.text;
+ const prefix = Config.launcher.actionPrefix;
+ if (text.startsWith(prefix)) {
+ for (const action of ["calc", "scheme", "variant"])
+ if (text.startsWith(`${prefix}${action} `))
+ return action;
+
+ return "actions";
+ }
+
+ return "apps";
+ }
+
+ onStateChanged: {
+ if (state === "scheme" || state === "variant")
+ Schemes.reload();
+ }
+
+ states: [
+ State {
+ name: "apps"
+
+ PropertyChanges {
+ model.values: Apps.search(search.text)
+ root.delegate: appItem
+ }
+ },
+ State {
+ name: "actions"
+
+ PropertyChanges {
+ model.values: Actions.query(search.text)
+ root.delegate: actionItem
+ }
+ },
+ State {
+ name: "calc"
+
+ PropertyChanges {
+ model.values: [0]
+ root.delegate: calcItem
+ }
+ },
+ State {
+ name: "scheme"
+
+ PropertyChanges {
+ model.values: Schemes.query(search.text)
+ root.delegate: schemeItem
+ }
+ },
+ State {
+ name: "variant"
+
+ PropertyChanges {
+ model.values: M3Variants.query(search.text)
+ root.delegate: variantItem
+ }
+ }
+ ]
+
+ transitions: Transition {
+ 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 {
+ targets: [model, root]
+ properties: "values,delegate"
+ }
+ 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
+ }
+ }
+ PropertyAction {
+ targets: [root.add, root.remove]
+ property: "enabled"
+ value: true
+ }
+ }
+ }
+
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: root
+ }
+
+ add: Transition {
+ enabled: !root.state
+
+ Anim {
+ properties: "opacity,scale"
+ from: 0
+ to: 1
+ }
+ }
+
+ remove: Transition {
+ enabled: !root.state
+
+ 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
+ }
+ }
+
+ Component {
+ id: calcItem
+
+ CalcItem {
+ list: root
+ }
+ }
+
+ Component {
+ id: schemeItem
+
+ SchemeItem {
+ list: root
+ }
+ }
+
+ Component {
+ id: variantItem
+
+ VariantItem {
+ list: root
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/launcher/Background.qml b/.config/quickshell/caelestia/modules/launcher/Background.qml
new file mode 100644
index 0000000..709c7d0
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/launcher/Background.qml
@@ -0,0 +1,60 @@
+import qs.components
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Shapes
+
+ShapePath {
+ id: root
+
+ required property Wrapper wrapper
+ readonly property real rounding: Config.border.rounding
+ readonly property bool flatten: wrapper.height < rounding * 2
+ readonly property real roundingY: flatten ? wrapper.height / 2 : rounding
+
+ strokeWidth: -1
+ fillColor: Colours.palette.m3surface
+
+ 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 {
+ CAnim {}
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/launcher/Content.qml b/.config/quickshell/caelestia/modules/launcher/Content.qml
new file mode 100644
index 0000000..c085976
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/launcher/Content.qml
@@ -0,0 +1,191 @@
+pragma ComponentBehavior: Bound
+
+import "services"
+import qs.components
+import qs.components.controls
+import qs.services
+import qs.config
+import Quickshell
+import QtQuick
+
+Item {
+ id: root
+
+ required property PersistentProperties visibilities
+ required property var panels
+ required property real maxHeight
+
+ 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
+
+ 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
+
+ content: root
+ visibilities: root.visibilities
+ panels: root.panels
+ maxHeight: root.maxHeight - searchWrapper.implicitHeight - root.padding * 3
+ search: search
+ padding: root.padding
+ rounding: root.rounding
+ }
+ }
+
+ StyledRect {
+ id: searchWrapper
+
+ color: Colours.layer(Colours.palette.m3surfaceContainer, 2)
+ 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(Config.launcher.actionPrefix)
+
+ onAccepted: {
+ const currentItem = list.currentList?.currentItem;
+ if (currentItem) {
+ if (list.showWallpapers) {
+ if (Colours.scheme === "dynamic" && currentItem.modelData.path !== Wallpapers.actualCurrent)
+ Wallpapers.previewColourLock = true;
+ Wallpapers.setWallpaper(currentItem.modelData.path);
+ root.visibilities.launcher = false;
+ } else if (text.startsWith(Config.launcher.actionPrefix)) {
+ if (text.startsWith(`${Config.launcher.actionPrefix}calc `))
+ currentItem.onClicked();
+ else
+ 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
+
+ Keys.onPressed: event => {
+ if (!Config.launcher.vimKeybinds)
+ return;
+
+ if (event.modifiers & Qt.ControlModifier) {
+ if (event.key === Qt.Key_J) {
+ list.currentList?.incrementCurrentIndex();
+ event.accepted = true;
+ } else if (event.key === Qt.Key_K) {
+ list.currentList?.decrementCurrentIndex();
+ event.accepted = true;
+ }
+ } else if (event.key === Qt.Key_Tab) {
+ list.currentList?.incrementCurrentIndex();
+ event.accepted = true;
+ } else if (event.key === Qt.Key_Backtab || (event.key === Qt.Key_Tab && (event.modifiers & Qt.ShiftModifier))) {
+ list.currentList?.decrementCurrentIndex();
+ event.accepted = true;
+ }
+ }
+
+ Component.onCompleted: forceActiveFocus()
+
+ Connections {
+ target: root.visibilities
+
+ function onLauncherChanged(): void {
+ if (!root.visibilities.launcher)
+ search.text = "";
+ }
+
+ function onSessionChanged(): void {
+ if (!root.visibilities.session)
+ search.forceActiveFocus();
+ }
+ }
+ }
+
+ 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.containsMouse)
+ return 0.8;
+ return 1;
+ }
+
+ text: "close"
+ color: Colours.palette.m3onSurfaceVariant
+
+ MouseArea {
+ id: mouse
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: search.text ? Qt.PointingHandCursor : undefined
+
+ onClicked: search.text = ""
+ }
+
+ Behavior on width {
+ Anim {
+ duration: Appearance.anim.durations.small
+ }
+ }
+
+ Behavior on opacity {
+ Anim {
+ duration: Appearance.anim.durations.small
+ }
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/launcher/ContentList.qml b/.config/quickshell/caelestia/modules/launcher/ContentList.qml
new file mode 100644
index 0000000..b2a9c77
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/launcher/ContentList.qml
@@ -0,0 +1,170 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.components.controls
+import qs.services
+import qs.config
+import qs.utils
+import Quickshell
+import QtQuick
+
+Item {
+ id: root
+
+ required property var content
+ required property PersistentProperties visibilities
+ required property var panels
+ required property real maxHeight
+ required property StyledTextField search
+ required property int padding
+ required property int rounding
+
+ readonly property bool showWallpapers: search.text.startsWith(`${Config.launcher.actionPrefix}wallpaper `)
+ readonly property Item currentList: showWallpapers ? wallpaperList.item : appList.item
+
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.bottom: parent.bottom
+
+ clip: true
+ state: showWallpapers ? "wallpapers" : "apps"
+
+ states: [
+ State {
+ name: "apps"
+
+ PropertyChanges {
+ root.implicitWidth: Config.launcher.sizes.itemWidth
+ root.implicitHeight: Math.min(root.maxHeight, appList.implicitHeight > 0 ? appList.implicitHeight : empty.implicitHeight)
+ appList.active: true
+ }
+
+ AnchorChanges {
+ anchors.left: root.parent.left
+ anchors.right: root.parent.right
+ }
+ },
+ State {
+ name: "wallpapers"
+
+ PropertyChanges {
+ root.implicitWidth: Math.max(Config.launcher.sizes.itemWidth * 1.2, wallpaperList.implicitWidth)
+ root.implicitHeight: Config.launcher.sizes.wallpaperHeight
+ wallpaperList.active: true
+ }
+ }
+ ]
+
+ Behavior on state {
+ SequentialAnimation {
+ Anim {
+ target: root
+ property: "opacity"
+ from: 1
+ to: 0
+ duration: Appearance.anim.durations.small
+ }
+ PropertyAction {}
+ Anim {
+ target: root
+ property: "opacity"
+ from: 0
+ to: 1
+ duration: Appearance.anim.durations.small
+ }
+ }
+ }
+
+ Loader {
+ id: appList
+
+ active: false
+
+ anchors.fill: parent
+
+ sourceComponent: AppList {
+ search: root.search
+ visibilities: root.visibilities
+ }
+ }
+
+ Loader {
+ id: wallpaperList
+
+ active: false
+
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ sourceComponent: WallpaperList {
+ search: root.search
+ visibilities: root.visibilities
+ panels: root.panels
+ content: root.content
+ }
+ }
+
+ Row {
+ id: empty
+
+ opacity: root.currentList?.count === 0 ? 1 : 0
+ scale: root.currentList?.count === 0 ? 1 : 0.5
+
+ spacing: Appearance.spacing.normal
+ padding: Appearance.padding.large
+
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.verticalCenter: parent.verticalCenter
+
+ MaterialIcon {
+ text: root.state === "wallpapers" ? "wallpaper_slideshow" : "manage_search"
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.extraLarge
+
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Column {
+ anchors.verticalCenter: parent.verticalCenter
+
+ StyledText {
+ text: root.state === "wallpapers" ? qsTr("No wallpapers found") : qsTr("No results")
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.larger
+ font.weight: 500
+ }
+
+ StyledText {
+ text: root.state === "wallpapers" && Wallpapers.list.length === 0 ? qsTr("Try putting some wallpapers in %1").arg(Paths.shortenHome(Paths.wallsdir)) : qsTr("Try searching for something else")
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.normal
+ }
+ }
+
+ Behavior on opacity {
+ Anim {}
+ }
+
+ Behavior on scale {
+ Anim {}
+ }
+ }
+
+ Behavior on implicitWidth {
+ enabled: root.visibilities.launcher
+
+ Anim {
+ duration: Appearance.anim.durations.large
+ easing.bezierCurve: Appearance.anim.curves.emphasizedDecel
+ }
+ }
+
+ Behavior on implicitHeight {
+ enabled: root.visibilities.launcher
+
+ Anim {
+ duration: Appearance.anim.durations.large
+ easing.bezierCurve: Appearance.anim.curves.emphasizedDecel
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/launcher/WallpaperList.qml b/.config/quickshell/caelestia/modules/launcher/WallpaperList.qml
new file mode 100644
index 0000000..4aba436
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/launcher/WallpaperList.qml
@@ -0,0 +1,97 @@
+pragma ComponentBehavior: Bound
+
+import "items"
+import qs.components.controls
+import qs.services
+import qs.config
+import Quickshell
+import QtQuick
+
+PathView {
+ id: root
+
+ required property StyledTextField search
+ required property var visibilities
+ required property var panels
+ required property var content
+
+ readonly property int itemWidth: Config.launcher.sizes.wallpaperWidth * 0.8 + Appearance.padding.larger * 2
+
+ readonly property int numItems: {
+ const screen = QsWindow.window?.screen;
+ if (!screen)
+ return 0;
+
+ // Screen width - 4x outer rounding - 2x max side thickness (cause centered)
+ const barMargins = Math.max(Config.border.thickness, panels.bar.implicitWidth);
+ let outerMargins = 0;
+ if (panels.popouts.hasCurrent && panels.popouts.currentCenter + panels.popouts.nonAnimHeight / 2 > screen.height - content.implicitHeight - Config.border.thickness * 2)
+ outerMargins = panels.popouts.nonAnimWidth;
+ if ((visibilities.utilities || visibilities.sidebar) && panels.utilities.implicitWidth > outerMargins)
+ outerMargins = panels.utilities.implicitWidth;
+ const maxWidth = screen.width - Config.border.rounding * 4 - (barMargins + outerMargins) * 2;
+
+ if (maxWidth <= 0)
+ return 0;
+
+ const maxItemsOnScreen = Math.floor(maxWidth / itemWidth);
+ const visible = Math.min(maxItemsOnScreen, Config.launcher.maxWallpapers, scriptModel.values.length);
+
+ if (visible === 2)
+ return 1;
+ if (visible > 1 && visible % 2 === 0)
+ return visible - 1;
+ return visible;
+ }
+
+ model: ScriptModel {
+ id: scriptModel
+
+ readonly property string search: root.search.text.split(" ").slice(1).join(" ")
+
+ values: Wallpapers.query(search)
+ 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) * itemWidth
+ 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/.config/quickshell/caelestia/modules/launcher/Wrapper.qml b/.config/quickshell/caelestia/modules/launcher/Wrapper.qml
new file mode 100644
index 0000000..d62d726
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/launcher/Wrapper.qml
@@ -0,0 +1,130 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.config
+import Quickshell
+import QtQuick
+
+Item {
+ id: root
+
+ required property ShellScreen screen
+ required property PersistentProperties visibilities
+ required property var panels
+
+ readonly property bool shouldBeActive: visibilities.launcher && Config.launcher.enabled
+ property int contentHeight
+
+ readonly property real maxHeight: {
+ let max = screen.height - Config.border.thickness * 2 - Appearance.spacing.large;
+ if (visibilities.dashboard)
+ max -= panels.dashboard.nonAnimHeight;
+ return max;
+ }
+
+ onMaxHeightChanged: timer.start()
+
+ visible: height > 0
+ implicitHeight: 0
+ implicitWidth: content.implicitWidth
+
+ onShouldBeActiveChanged: {
+ if (shouldBeActive) {
+ timer.stop();
+ hideAnim.stop();
+ showAnim.start();
+ } else {
+ showAnim.stop();
+ hideAnim.start();
+ }
+ }
+
+ SequentialAnimation {
+ id: showAnim
+
+ Anim {
+ target: root
+ property: "implicitHeight"
+ to: root.contentHeight
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ ScriptAction {
+ script: root.implicitHeight = Qt.binding(() => content.implicitHeight)
+ }
+ }
+
+ SequentialAnimation {
+ id: hideAnim
+
+ ScriptAction {
+ script: root.implicitHeight = root.implicitHeight
+ }
+ Anim {
+ target: root
+ property: "implicitHeight"
+ to: 0
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+
+ Connections {
+ target: Config.launcher
+
+ function onEnabledChanged(): void {
+ timer.start();
+ }
+
+ function onMaxShownChanged(): void {
+ timer.start();
+ }
+ }
+
+ Connections {
+ target: DesktopEntries.applications
+
+ function onValuesChanged(): void {
+ if (DesktopEntries.applications.values.length < Config.launcher.maxShown)
+ timer.start();
+ }
+ }
+
+ Timer {
+ id: timer
+
+ interval: Appearance.anim.durations.extraLarge
+ onRunningChanged: {
+ if (running && !root.shouldBeActive) {
+ content.visible = false;
+ content.active = true;
+ } else {
+ root.contentHeight = Math.min(root.maxHeight, content.implicitHeight);
+ content.active = Qt.binding(() => root.shouldBeActive || root.visible);
+ content.visible = true;
+ if (showAnim.running) {
+ showAnim.stop();
+ showAnim.start();
+ }
+ }
+ }
+ }
+
+ Loader {
+ id: content
+
+ anchors.top: parent.top
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ visible: false
+ active: false
+ Component.onCompleted: timer.start()
+
+ sourceComponent: Content {
+ visibilities: root.visibilities
+ panels: root.panels
+ maxHeight: root.maxHeight
+
+ Component.onCompleted: root.contentHeight = implicitHeight
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/launcher/items/ActionItem.qml b/.config/quickshell/caelestia/modules/launcher/items/ActionItem.qml
new file mode 100644
index 0000000..e158029
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/launcher/items/ActionItem.qml
@@ -0,0 +1,70 @@
+import "../services"
+import qs.components
+import qs.services
+import qs.config
+import QtQuick
+
+Item {
+ id: root
+
+ required property var modelData
+ required property var list
+
+ implicitHeight: Config.launcher.sizes.itemHeight
+
+ anchors.left: parent?.left
+ anchors.right: parent?.right
+
+ StateLayer {
+ radius: Appearance.rounding.normal
+
+ 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.normal
+ 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.palette.m3outline
+
+ elide: Text.ElideRight
+ width: root.width - icon.width - Appearance.rounding.normal * 2
+
+ anchors.top: name.bottom
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/launcher/items/AppItem.qml b/.config/quickshell/caelestia/modules/launcher/items/AppItem.qml
new file mode 100644
index 0000000..2bd818d
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/launcher/items/AppItem.qml
@@ -0,0 +1,88 @@
+import "../services"
+import qs.components
+import qs.services
+import qs.config
+import qs.utils
+import Quickshell
+import Quickshell.Widgets
+import QtQuick
+
+Item {
+ id: root
+
+ required property DesktopEntry modelData
+ required property PersistentProperties visibilities
+
+ implicitHeight: Config.launcher.sizes.itemHeight
+
+ anchors.left: parent?.left
+ anchors.right: parent?.right
+
+ StateLayer {
+ radius: Appearance.rounding.normal
+
+ 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 - favouriteIcon.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.palette.m3outline
+
+ elide: Text.ElideRight
+ width: root.width - icon.width - favouriteIcon.width - Appearance.rounding.normal * 2
+
+ anchors.top: name.bottom
+ }
+ }
+
+ Loader {
+ id: favouriteIcon
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.right: parent.right
+ active: modelData && Strings.testRegexList(Config.launcher.favouriteApps, modelData.id)
+
+ sourceComponent: MaterialIcon {
+ text: "favorite"
+ fill: 1
+ color: Colours.palette.m3primary
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/launcher/items/CalcItem.qml b/.config/quickshell/caelestia/modules/launcher/items/CalcItem.qml
new file mode 100644
index 0000000..65489d9
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/launcher/items/CalcItem.qml
@@ -0,0 +1,123 @@
+import qs.components
+import qs.services
+import qs.config
+import Caelestia
+import Quickshell
+import QtQuick
+import QtQuick.Layouts
+
+Item {
+ id: root
+
+ required property var list
+ readonly property string math: list.search.text.slice(`${Config.launcher.actionPrefix}calc `.length)
+
+ function onClicked(): void {
+ Quickshell.execDetached(["wl-copy", Qalculator.eval(math, false)]);
+ root.list.visibilities.launcher = false;
+ }
+
+ implicitHeight: Config.launcher.sizes.itemHeight
+
+ anchors.left: parent?.left
+ anchors.right: parent?.right
+
+ StateLayer {
+ radius: Appearance.rounding.normal
+
+ function onClicked(): void {
+ root.onClicked();
+ }
+ }
+
+ RowLayout {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.larger
+
+ spacing: Appearance.spacing.normal
+
+ MaterialIcon {
+ text: "function"
+ font.pointSize: Appearance.font.size.extraLarge
+ Layout.alignment: Qt.AlignVCenter
+ }
+
+ StyledText {
+ id: result
+
+ color: {
+ if (text.includes("error: ") || text.includes("warning: "))
+ return Colours.palette.m3error;
+ if (!root.math)
+ return Colours.palette.m3onSurfaceVariant;
+ return Colours.palette.m3onSurface;
+ }
+
+ text: root.math.length > 0 ? Qalculator.eval(root.math) : qsTr("Type an expression to calculate")
+ elide: Text.ElideLeft
+
+ Layout.fillWidth: true
+ Layout.alignment: Qt.AlignVCenter
+ }
+
+ StyledRect {
+ color: Colours.palette.m3tertiary
+ radius: Appearance.rounding.normal
+ clip: true
+
+ implicitWidth: (stateLayer.containsMouse ? label.implicitWidth + label.anchors.rightMargin : 0) + icon.implicitWidth + Appearance.padding.normal * 2
+ implicitHeight: Math.max(label.implicitHeight, icon.implicitHeight) + Appearance.padding.small * 2
+
+ Layout.alignment: Qt.AlignVCenter
+
+ StateLayer {
+ id: stateLayer
+
+ color: Colours.palette.m3onTertiary
+
+ function onClicked(): void {
+ Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.terminal, "fish", "-C", `exec qalc -i '${root.math}'`]);
+ root.list.visibilities.launcher = false;
+ }
+ }
+
+ StyledText {
+ id: label
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.right: icon.left
+ anchors.rightMargin: Appearance.spacing.small
+
+ text: qsTr("Open in calculator")
+ color: Colours.palette.m3onTertiary
+ font.pointSize: Appearance.font.size.normal
+
+ opacity: stateLayer.containsMouse ? 1 : 0
+
+ Behavior on opacity {
+ Anim {}
+ }
+ }
+
+ MaterialIcon {
+ id: icon
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.right: parent.right
+ anchors.rightMargin: Appearance.padding.normal
+
+ text: "open_in_new"
+ color: Colours.palette.m3onTertiary
+ font.pointSize: Appearance.font.size.large
+ }
+
+ Behavior on implicitWidth {
+ Anim {
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/launcher/items/SchemeItem.qml b/.config/quickshell/caelestia/modules/launcher/items/SchemeItem.qml
new file mode 100644
index 0000000..3ff1846
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/launcher/items/SchemeItem.qml
@@ -0,0 +1,104 @@
+import "../services"
+import qs.components
+import qs.services
+import qs.config
+import QtQuick
+
+Item {
+ id: root
+
+ required property Schemes.Scheme modelData
+ required property var list
+
+ implicitHeight: Config.launcher.sizes.itemHeight
+
+ anchors.left: parent?.left
+ anchors.right: parent?.right
+
+ StateLayer {
+ radius: Appearance.rounding.normal
+
+ 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
+
+ StyledRect {
+ id: preview
+
+ anchors.verticalCenter: parent.verticalCenter
+
+ border.width: 1
+ border.color: Qt.alpha(`#${root.modelData?.colours?.outline}`, 0.5)
+
+ color: `#${root.modelData?.colours?.surface}`
+ radius: Appearance.rounding.full
+ implicitWidth: parent.height * 0.8
+ implicitHeight: parent.height * 0.8
+
+ Item {
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ anchors.right: parent.right
+
+ implicitWidth: parent.implicitWidth / 2
+ clip: true
+
+ StyledRect {
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ anchors.right: parent.right
+
+ implicitWidth: preview.implicitWidth
+ color: `#${root.modelData?.colours?.primary}`
+ radius: Appearance.rounding.full
+ }
+ }
+ }
+
+ Column {
+ anchors.left: preview.right
+ anchors.leftMargin: Appearance.spacing.normal
+ anchors.verticalCenter: parent.verticalCenter
+
+ width: parent.width - preview.width - anchors.leftMargin - (current.active ? current.width + Appearance.spacing.normal : 0)
+ spacing: 0
+
+ StyledText {
+ text: root.modelData?.flavour ?? ""
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ StyledText {
+ text: root.modelData?.name ?? ""
+ font.pointSize: Appearance.font.size.small
+ color: Colours.palette.m3outline
+
+ elide: Text.ElideRight
+ anchors.left: parent.left
+ anchors.right: parent.right
+ }
+ }
+
+ Loader {
+ id: current
+
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+
+ active: `${root.modelData?.name} ${root.modelData?.flavour}` === Schemes.currentScheme
+
+ sourceComponent: MaterialIcon {
+ text: "check"
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.large
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/launcher/items/VariantItem.qml b/.config/quickshell/caelestia/modules/launcher/items/VariantItem.qml
new file mode 100644
index 0000000..5c34fa8
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/launcher/items/VariantItem.qml
@@ -0,0 +1,80 @@
+import "../services"
+import qs.components
+import qs.services
+import qs.config
+import QtQuick
+
+Item {
+ id: root
+
+ required property M3Variants.Variant modelData
+ required property var list
+
+ implicitHeight: Config.launcher.sizes.itemHeight
+
+ anchors.left: parent?.left
+ anchors.right: parent?.right
+
+ StateLayer {
+ radius: Appearance.rounding.normal
+
+ 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
+ }
+
+ Column {
+ anchors.left: icon.right
+ anchors.leftMargin: Appearance.spacing.larger
+ anchors.verticalCenter: icon.verticalCenter
+
+ width: parent.width - icon.width - anchors.leftMargin - (current.active ? current.width + Appearance.spacing.normal : 0)
+ spacing: 0
+
+ StyledText {
+ text: root.modelData?.name ?? ""
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ StyledText {
+ text: root.modelData?.description ?? ""
+ font.pointSize: Appearance.font.size.small
+ color: Colours.palette.m3outline
+
+ elide: Text.ElideRight
+ anchors.left: parent.left
+ anchors.right: parent.right
+ }
+ }
+
+ Loader {
+ id: current
+
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+
+ active: root.modelData?.variant === Schemes.currentVariant
+
+ sourceComponent: MaterialIcon {
+ text: "check"
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.large
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/launcher/items/WallpaperItem.qml b/.config/quickshell/caelestia/modules/launcher/items/WallpaperItem.qml
new file mode 100644
index 0000000..9fdac3f
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/launcher/items/WallpaperItem.qml
@@ -0,0 +1,98 @@
+import qs.components
+import qs.components.effects
+import qs.components.images
+import qs.services
+import qs.config
+import Caelestia.Models
+import Quickshell
+import QtQuick
+
+Item {
+ id: root
+
+ required property FileSystemEntry 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;
+ }
+ }
+
+ Elevation {
+ anchors.fill: image
+ radius: image.radius
+ opacity: root.PathView.isCurrentItem ? 1 : 0
+ level: 4
+
+ Behavior on opacity {
+ Anim {}
+ }
+ }
+
+ StyledClippingRect {
+ id: image
+
+ anchors.horizontalCenter: parent.horizontalCenter
+ y: Appearance.padding.large
+ color: Colours.tPalette.m3surfaceContainer
+ radius: Appearance.rounding.normal
+
+ implicitWidth: Config.launcher.sizes.wallpaperWidth
+ implicitHeight: implicitWidth / 16 * 9
+
+ MaterialIcon {
+ anchors.centerIn: parent
+ text: "image"
+ color: Colours.tPalette.m3outline
+ font.pointSize: Appearance.font.size.extraLarge * 2
+ font.weight: 600
+ }
+
+ CachingImage {
+ path: root.modelData.path
+ smooth: !root.PathView.view.moving
+ cache: true
+
+ anchors.fill: parent
+ }
+ }
+
+ 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.relativePath
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ Behavior on scale {
+ Anim {}
+ }
+
+ Behavior on opacity {
+ Anim {}
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/launcher/services/Actions.qml b/.config/quickshell/caelestia/modules/launcher/services/Actions.qml
new file mode 100644
index 0000000..5c1cb6b
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/launcher/services/Actions.qml
@@ -0,0 +1,52 @@
+pragma Singleton
+
+import ".."
+import qs.services
+import qs.config
+import qs.utils
+import Quickshell
+import QtQuick
+
+Searcher {
+ id: root
+
+ function transformSearch(search: string): string {
+ return search.slice(Config.launcher.actionPrefix.length);
+ }
+
+ list: variants.instances
+ useFuzzy: Config.launcher.useFuzzy.actions
+
+ Variants {
+ id: variants
+
+ model: Config.launcher.actions.filter(a => (a.enabled ?? true) && (Config.launcher.enableDangerousActions || !(a.dangerous ?? false)))
+
+ Action {}
+ }
+
+ component Action: QtObject {
+ required property var modelData
+ readonly property string name: modelData.name ?? qsTr("Unnamed")
+ readonly property string desc: modelData.description ?? qsTr("No description")
+ readonly property string icon: modelData.icon ?? "help_outline"
+ readonly property list command: modelData.command ?? []
+ readonly property bool enabled: modelData.enabled ?? true
+ readonly property bool dangerous: modelData.dangerous ?? false
+
+ function onClicked(list: AppList): void {
+ if (command.length === 0)
+ return;
+
+ if (command[0] === "autocomplete" && command.length > 1) {
+ list.search.text = `${Config.launcher.actionPrefix}${command[1]} `;
+ } else if (command[0] === "setMode" && command.length > 1) {
+ list.visibilities.launcher = false;
+ Colours.setMode(command[1]);
+ } else {
+ list.visibilities.launcher = false;
+ Quickshell.execDetached(command);
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/launcher/services/Apps.qml b/.config/quickshell/caelestia/modules/launcher/services/Apps.qml
new file mode 100644
index 0000000..3002eb5
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/launcher/services/Apps.qml
@@ -0,0 +1,78 @@
+pragma Singleton
+
+import qs.config
+import qs.utils
+import Caelestia
+import Quickshell
+
+Searcher {
+ id: root
+
+ function launch(entry: DesktopEntry): void {
+ appDb.incrementFrequency(entry.id);
+
+ if (entry.runInTerminal)
+ Quickshell.execDetached({
+ command: ["app2unit", "--", ...Config.general.apps.terminal, `${Quickshell.shellDir}/assets/wrap_term_launch.sh`, ...entry.command],
+ workingDirectory: entry.workingDirectory
+ });
+ else
+ Quickshell.execDetached({
+ command: ["app2unit", "--", ...entry.command],
+ workingDirectory: entry.workingDirectory
+ });
+ }
+
+ function search(search: string): list {
+ const prefix = Config.launcher.specialPrefix;
+
+ if (search.startsWith(`${prefix}i `)) {
+ keys = ["id", "name"];
+ weights = [0.9, 0.1];
+ } else if (search.startsWith(`${prefix}c `)) {
+ keys = ["categories", "name"];
+ weights = [0.9, 0.1];
+ } else if (search.startsWith(`${prefix}d `)) {
+ keys = ["comment", "name"];
+ weights = [0.9, 0.1];
+ } else if (search.startsWith(`${prefix}e `)) {
+ keys = ["execString", "name"];
+ weights = [0.9, 0.1];
+ } else if (search.startsWith(`${prefix}w `)) {
+ keys = ["startupClass", "name"];
+ weights = [0.9, 0.1];
+ } else if (search.startsWith(`${prefix}g `)) {
+ keys = ["genericName", "name"];
+ weights = [0.9, 0.1];
+ } else if (search.startsWith(`${prefix}k `)) {
+ keys = ["keywords", "name"];
+ weights = [0.9, 0.1];
+ } else {
+ keys = ["name"];
+ weights = [1];
+
+ if (!search.startsWith(`${prefix}t `))
+ return query(search).map(e => e.entry);
+ }
+
+ const results = query(search.slice(prefix.length + 2)).map(e => e.entry);
+ if (search.startsWith(`${prefix}t `))
+ return results.filter(a => a.runInTerminal);
+ return results;
+ }
+
+ function selector(item: var): string {
+ return keys.map(k => item[k]).join(" ");
+ }
+
+ list: appDb.apps
+ useFuzzy: Config.launcher.useFuzzy.apps
+
+ AppDb {
+ id: appDb
+
+ path: `${Paths.state}/apps.sqlite`
+ // favouriteApps: Config.launcher.favouriteApps
+ entries: DesktopEntries.applications.values.filter(a => !Strings.testRegexList(Config.launcher.hiddenApps, a.id))
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/launcher/services/M3Variants.qml b/.config/quickshell/caelestia/modules/launcher/services/M3Variants.qml
new file mode 100644
index 0000000..963a4d4
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/launcher/services/M3Variants.qml
@@ -0,0 +1,85 @@
+pragma Singleton
+
+import ".."
+import qs.config
+import qs.utils
+import Quickshell
+import QtQuick
+
+Searcher {
+ id: root
+
+ function transformSearch(search: string): string {
+ return search.slice(`${Config.launcher.actionPrefix}variant `.length);
+ }
+
+ list: [
+ Variant {
+ variant: "vibrant"
+ icon: "sentiment_very_dissatisfied"
+ name: qsTr("Vibrant")
+ description: qsTr("A high chroma palette. The primary palette's chroma is at maximum.")
+ },
+ Variant {
+ variant: "tonalspot"
+ icon: "android"
+ name: qsTr("Tonal Spot")
+ description: qsTr("Default for Material theme colours. A pastel palette with a low chroma.")
+ },
+ Variant {
+ variant: "expressive"
+ icon: "compare_arrows"
+ name: qsTr("Expressive")
+ description: qsTr("A medium chroma palette. The primary palette's hue is different from the seed colour, for variety.")
+ },
+ Variant {
+ variant: "fidelity"
+ icon: "compare"
+ name: qsTr("Fidelity")
+ description: qsTr("Matches the seed colour, even if the seed colour is very bright (high chroma).")
+ },
+ Variant {
+ variant: "content"
+ icon: "sentiment_calm"
+ name: qsTr("Content")
+ description: qsTr("Almost identical to fidelity.")
+ },
+ Variant {
+ variant: "fruitsalad"
+ icon: "nutrition"
+ name: qsTr("Fruit Salad")
+ description: qsTr("A playful theme - the seed colour's hue does not appear in the theme.")
+ },
+ Variant {
+ variant: "rainbow"
+ icon: "looks"
+ name: qsTr("Rainbow")
+ description: qsTr("A playful theme - the seed colour's hue does not appear in the theme.")
+ },
+ Variant {
+ variant: "neutral"
+ icon: "contrast"
+ name: qsTr("Neutral")
+ description: qsTr("Close to grayscale, a hint of chroma.")
+ },
+ Variant {
+ variant: "monochrome"
+ icon: "filter_b_and_w"
+ name: qsTr("Monochrome")
+ description: qsTr("All colours are grayscale, no chroma.")
+ }
+ ]
+ useFuzzy: Config.launcher.useFuzzy.variants
+
+ component Variant: QtObject {
+ required property string variant
+ required property string icon
+ required property string name
+ required property string description
+
+ function onClicked(list: AppList): void {
+ list.visibilities.launcher = false;
+ Quickshell.execDetached(["caelestia", "scheme", "set", "-v", variant]);
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/launcher/services/Schemes.qml b/.config/quickshell/caelestia/modules/launcher/services/Schemes.qml
new file mode 100644
index 0000000..dbb2dac
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/launcher/services/Schemes.qml
@@ -0,0 +1,88 @@
+pragma Singleton
+
+import ".."
+import qs.config
+import qs.utils
+import Quickshell
+import Quickshell.Io
+import QtQuick
+
+Searcher {
+ id: root
+
+ property string currentScheme
+ property string currentVariant
+
+ function transformSearch(search: string): string {
+ return search.slice(`${Config.launcher.actionPrefix}scheme `.length);
+ }
+
+ function selector(item: var): string {
+ return `${item.name} ${item.flavour}`;
+ }
+
+ function reload(): void {
+ getCurrent.running = true;
+ }
+
+ list: schemes.instances
+ useFuzzy: Config.launcher.useFuzzy.schemes
+ keys: ["name", "flavour"]
+ weights: [0.9, 0.1]
+
+ Variants {
+ id: schemes
+
+ Scheme {}
+ }
+
+ Process {
+ id: getSchemes
+
+ running: true
+ command: ["caelestia", "scheme", "list"]
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const schemeData = JSON.parse(text);
+ const list = Object.entries(schemeData).map(([name, f]) => Object.entries(f).map(([flavour, colours]) => ({
+ name,
+ flavour,
+ colours
+ })));
+
+ const flat = [];
+ for (const s of list)
+ for (const f of s)
+ flat.push(f);
+
+ schemes.model = flat.sort((a, b) => (a.name + a.flavour).localeCompare((b.name + b.flavour)));
+ }
+ }
+ }
+
+ Process {
+ id: getCurrent
+
+ running: true
+ command: ["caelestia", "scheme", "get", "-nfv"]
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const [name, flavour, variant] = text.trim().split("\n");
+ root.currentScheme = `${name} ${flavour}`;
+ root.currentVariant = variant;
+ }
+ }
+ }
+
+ component Scheme: QtObject {
+ required property var modelData
+ readonly property string name: modelData.name
+ readonly property string flavour: modelData.flavour
+ readonly property var colours: modelData.colours
+
+ function onClicked(list: AppList): void {
+ list.visibilities.launcher = false;
+ Quickshell.execDetached(["caelestia", "scheme", "set", "-n", name, "-f", flavour]);
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/lock/Center.qml b/.config/quickshell/caelestia/modules/lock/Center.qml
new file mode 100644
index 0000000..19cf9d2
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/lock/Center.qml
@@ -0,0 +1,416 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.components.controls
+import qs.components.images
+import qs.services
+import qs.config
+import qs.utils
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ required property var lock
+ readonly property real centerScale: Math.min(1, (lock.screen?.height ?? 1440) / 1440)
+ readonly property int centerWidth: Config.lock.sizes.centerWidth * centerScale
+
+ Layout.preferredWidth: centerWidth
+ Layout.fillWidth: false
+ Layout.fillHeight: true
+
+ spacing: Appearance.spacing.large * 2
+
+ RowLayout {
+ Layout.alignment: Qt.AlignHCenter
+ spacing: Appearance.spacing.small
+
+ StyledText {
+ Layout.alignment: Qt.AlignVCenter
+ text: Time.hourStr
+ color: Colours.palette.m3secondary
+ font.pointSize: Math.floor(Appearance.font.size.extraLarge * 3 * root.centerScale)
+ font.family: Appearance.font.family.clock
+ font.bold: true
+ }
+
+ StyledText {
+ Layout.alignment: Qt.AlignVCenter
+ text: ":"
+ color: Colours.palette.m3primary
+ font.pointSize: Math.floor(Appearance.font.size.extraLarge * 3 * root.centerScale)
+ font.family: Appearance.font.family.clock
+ font.bold: true
+ }
+
+ StyledText {
+ Layout.alignment: Qt.AlignVCenter
+ text: Time.minuteStr
+ color: Colours.palette.m3secondary
+ font.pointSize: Math.floor(Appearance.font.size.extraLarge * 3 * root.centerScale)
+ font.family: Appearance.font.family.clock
+ font.bold: true
+ }
+
+ Loader {
+ Layout.leftMargin: Appearance.spacing.small
+ Layout.alignment: Qt.AlignVCenter
+
+ active: Config.services.useTwelveHourClock
+ visible: active
+
+ sourceComponent: StyledText {
+ text: Time.amPmStr
+ color: Colours.palette.m3primary
+ font.pointSize: Math.floor(Appearance.font.size.extraLarge * 2 * root.centerScale)
+ font.family: Appearance.font.family.clock
+ font.bold: true
+ }
+ }
+ }
+
+ StyledText {
+ Layout.alignment: Qt.AlignHCenter
+ Layout.topMargin: -Appearance.padding.large * 2
+
+ text: Time.format("dddd, d MMMM yyyy")
+ color: Colours.palette.m3tertiary
+ font.pointSize: Math.floor(Appearance.font.size.extraLarge * root.centerScale)
+ font.family: Appearance.font.family.mono
+ font.bold: true
+ }
+
+ StyledClippingRect {
+ Layout.topMargin: Appearance.spacing.large * 2
+ Layout.alignment: Qt.AlignHCenter
+
+ implicitWidth: root.centerWidth / 2
+ implicitHeight: root.centerWidth / 2
+
+ color: Colours.tPalette.m3surfaceContainer
+ radius: Appearance.rounding.full
+
+ MaterialIcon {
+ anchors.centerIn: parent
+
+ text: "person"
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Math.floor(root.centerWidth / 4)
+ }
+
+ CachingImage {
+ id: pfp
+
+ anchors.fill: parent
+ path: `${Paths.home}/.face`
+ }
+ }
+
+ StyledRect {
+ Layout.alignment: Qt.AlignHCenter
+
+ implicitWidth: root.centerWidth * 0.8
+ implicitHeight: input.implicitHeight + Appearance.padding.small * 2
+
+ color: Colours.tPalette.m3surfaceContainer
+ radius: Appearance.rounding.full
+
+ focus: true
+ onActiveFocusChanged: {
+ if (!activeFocus)
+ forceActiveFocus();
+ }
+
+ Keys.onPressed: event => {
+ if (root.lock.unlocking)
+ return;
+
+ if (event.key === Qt.Key_Enter || event.key === Qt.Key_Return)
+ inputField.placeholder.animate = false;
+
+ root.lock.pam.handleKey(event);
+ }
+
+ StateLayer {
+ hoverEnabled: false
+ cursorShape: Qt.IBeamCursor
+
+ function onClicked(): void {
+ parent.forceActiveFocus();
+ }
+ }
+
+ RowLayout {
+ id: input
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.small
+ spacing: Appearance.spacing.normal
+
+ Item {
+ implicitWidth: implicitHeight
+ implicitHeight: fprintIcon.implicitHeight + Appearance.padding.small * 2
+
+ MaterialIcon {
+ id: fprintIcon
+
+ anchors.centerIn: parent
+ animate: true
+ text: {
+ if (root.lock.pam.fprint.tries >= Config.lock.maxFprintTries)
+ return "fingerprint_off";
+ if (root.lock.pam.fprint.active)
+ return "fingerprint";
+ return "lock";
+ }
+ color: root.lock.pam.fprint.tries >= Config.lock.maxFprintTries ? Colours.palette.m3error : Colours.palette.m3onSurface
+ opacity: root.lock.pam.passwd.active ? 0 : 1
+
+ Behavior on opacity {
+ Anim {}
+ }
+ }
+
+ CircularIndicator {
+ anchors.fill: parent
+ running: root.lock.pam.passwd.active
+ }
+ }
+
+ InputField {
+ id: inputField
+
+ pam: root.lock.pam
+ }
+
+ StyledRect {
+ implicitWidth: implicitHeight
+ implicitHeight: enterIcon.implicitHeight + Appearance.padding.small * 2
+
+ color: root.lock.pam.buffer ? Colours.palette.m3primary : Colours.layer(Colours.palette.m3surfaceContainerHigh, 2)
+ radius: Appearance.rounding.full
+
+ StateLayer {
+ color: root.lock.pam.buffer ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface
+
+ function onClicked(): void {
+ root.lock.pam.passwd.start();
+ }
+ }
+
+ MaterialIcon {
+ id: enterIcon
+
+ anchors.centerIn: parent
+ text: "arrow_forward"
+ color: root.lock.pam.buffer ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface
+ font.weight: 500
+ }
+ }
+ }
+ }
+
+ Item {
+ Layout.fillWidth: true
+ Layout.topMargin: -Appearance.spacing.large
+
+ implicitHeight: Math.max(message.implicitHeight, stateMessage.implicitHeight)
+
+ Behavior on implicitHeight {
+ Anim {}
+ }
+
+ StyledText {
+ id: stateMessage
+
+ readonly property string msg: {
+ if (Hypr.kbLayout !== Hypr.defaultKbLayout) {
+ if (Hypr.capsLock && Hypr.numLock)
+ return qsTr("Caps lock and Num lock are ON.\nKeyboard layout: %1").arg(Hypr.kbLayoutFull);
+ if (Hypr.capsLock)
+ return qsTr("Caps lock is ON. Kb layout: %1").arg(Hypr.kbLayoutFull);
+ if (Hypr.numLock)
+ return qsTr("Num lock is ON. Kb layout: %1").arg(Hypr.kbLayoutFull);
+ return qsTr("Keyboard layout: %1").arg(Hypr.kbLayoutFull);
+ }
+
+ if (Hypr.capsLock && Hypr.numLock)
+ return qsTr("Caps lock and Num lock are ON.");
+ if (Hypr.capsLock)
+ return qsTr("Caps lock is ON.");
+ if (Hypr.numLock)
+ return qsTr("Num lock is ON.");
+
+ return "";
+ }
+
+ property bool shouldBeVisible
+
+ onMsgChanged: {
+ if (msg) {
+ if (opacity > 0) {
+ animate = true;
+ text = msg;
+ animate = false;
+ } else {
+ text = msg;
+ }
+ shouldBeVisible = true;
+ } else {
+ shouldBeVisible = false;
+ }
+ }
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+
+ scale: shouldBeVisible && !message.msg ? 1 : 0.7
+ opacity: shouldBeVisible && !message.msg ? 1 : 0
+ color: Colours.palette.m3onSurfaceVariant
+ animateProp: "opacity"
+
+ font.family: Appearance.font.family.mono
+ horizontalAlignment: Qt.AlignHCenter
+ wrapMode: Text.WrapAtWordBoundaryOrAnywhere
+ lineHeight: 1.2
+
+ Behavior on scale {
+ Anim {}
+ }
+
+ Behavior on opacity {
+ Anim {}
+ }
+ }
+
+ StyledText {
+ id: message
+
+ readonly property Pam pam: root.lock.pam
+ readonly property string msg: {
+ if (pam.fprintState === "error")
+ return qsTr("FP ERROR: %1").arg(pam.fprint.message);
+ if (pam.state === "error")
+ return qsTr("PW ERROR: %1").arg(pam.passwd.message);
+
+ if (pam.lockMessage)
+ return pam.lockMessage;
+
+ if (pam.state === "max" && pam.fprintState === "max")
+ return qsTr("Maximum password and fingerprint attempts reached.");
+ if (pam.state === "max") {
+ if (pam.fprint.available)
+ return qsTr("Maximum password attempts reached. Please use fingerprint.");
+ return qsTr("Maximum password attempts reached.");
+ }
+ if (pam.fprintState === "max")
+ return qsTr("Maximum fingerprint attempts reached. Please use password.");
+
+ if (pam.state === "fail") {
+ if (pam.fprint.available)
+ return qsTr("Incorrect password. Please try again or use fingerprint.");
+ return qsTr("Incorrect password. Please try again.");
+ }
+ if (pam.fprintState === "fail")
+ return qsTr("Fingerprint not recognized (%1/%2). Please try again or use password.").arg(pam.fprint.tries).arg(Config.lock.maxFprintTries);
+
+ return "";
+ }
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+
+ scale: 0.7
+ opacity: 0
+ color: Colours.palette.m3error
+
+ font.pointSize: Appearance.font.size.small
+ font.family: Appearance.font.family.mono
+ horizontalAlignment: Qt.AlignHCenter
+ wrapMode: Text.WrapAtWordBoundaryOrAnywhere
+
+ onMsgChanged: {
+ if (msg) {
+ if (opacity > 0) {
+ animate = true;
+ text = msg;
+ animate = false;
+
+ exitAnim.stop();
+ if (scale < 1)
+ appearAnim.restart();
+ else
+ flashAnim.restart();
+ } else {
+ text = msg;
+ exitAnim.stop();
+ appearAnim.restart();
+ }
+ } else {
+ appearAnim.stop();
+ flashAnim.stop();
+ exitAnim.start();
+ }
+ }
+
+ Connections {
+ target: root.lock.pam
+
+ function onFlashMsg(): void {
+ exitAnim.stop();
+ if (message.scale < 1)
+ appearAnim.restart();
+ else
+ flashAnim.restart();
+ }
+ }
+
+ Anim {
+ id: appearAnim
+
+ target: message
+ properties: "scale,opacity"
+ to: 1
+ onFinished: flashAnim.restart()
+ }
+
+ SequentialAnimation {
+ id: flashAnim
+
+ loops: 2
+
+ FlashAnim {
+ to: 0.3
+ }
+ FlashAnim {
+ to: 1
+ }
+ }
+
+ ParallelAnimation {
+ id: exitAnim
+
+ Anim {
+ target: message
+ property: "scale"
+ to: 0.7
+ duration: Appearance.anim.durations.large
+ }
+ Anim {
+ target: message
+ property: "opacity"
+ to: 0
+ duration: Appearance.anim.durations.large
+ }
+ }
+ }
+ }
+
+ component FlashAnim: NumberAnimation {
+ target: message
+ property: "opacity"
+ duration: Appearance.anim.durations.small
+ easing.type: Easing.Linear
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/lock/Content.qml b/.config/quickshell/caelestia/modules/lock/Content.qml
new file mode 100644
index 0000000..a024ddc
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/lock/Content.qml
@@ -0,0 +1,93 @@
+import qs.components
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+RowLayout {
+ id: root
+
+ required property var lock
+
+ spacing: Appearance.spacing.large * 2
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+
+ StyledRect {
+ Layout.fillWidth: true
+ implicitHeight: weather.implicitHeight
+
+ topLeftRadius: Appearance.rounding.large
+ radius: Appearance.rounding.small
+ color: Colours.tPalette.m3surfaceContainer
+
+ WeatherInfo {
+ id: weather
+
+ rootHeight: root.height
+ }
+ }
+
+ StyledRect {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+
+ radius: Appearance.rounding.small
+ color: Colours.tPalette.m3surfaceContainer
+
+ Fetch {}
+ }
+
+ StyledClippingRect {
+ Layout.fillWidth: true
+ implicitHeight: media.implicitHeight
+
+ bottomLeftRadius: Appearance.rounding.large
+ radius: Appearance.rounding.small
+ color: Colours.tPalette.m3surfaceContainer
+
+ Media {
+ id: media
+
+ lock: root.lock
+ }
+ }
+ }
+
+ Center {
+ lock: root.lock
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+
+ StyledRect {
+ Layout.fillWidth: true
+ implicitHeight: resources.implicitHeight
+
+ topRightRadius: Appearance.rounding.large
+ radius: Appearance.rounding.small
+ color: Colours.tPalette.m3surfaceContainer
+
+ Resources {
+ id: resources
+ }
+ }
+
+ StyledRect {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+
+ bottomRightRadius: Appearance.rounding.large
+ radius: Appearance.rounding.small
+ color: Colours.tPalette.m3surfaceContainer
+
+ NotifDock {
+ lock: root.lock
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/lock/Fetch.qml b/.config/quickshell/caelestia/modules/lock/Fetch.qml
new file mode 100644
index 0000000..55d6aa7
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/lock/Fetch.qml
@@ -0,0 +1,178 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.components.effects
+import qs.services
+import qs.config
+import qs.utils
+import Quickshell.Services.UPower
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.large * 2
+ anchors.topMargin: Appearance.padding.large
+
+ spacing: Appearance.spacing.small
+
+ RowLayout {
+ Layout.fillWidth: true
+ Layout.fillHeight: false
+ spacing: Appearance.spacing.normal
+
+ StyledRect {
+ implicitWidth: prompt.implicitWidth + Appearance.padding.normal * 2
+ implicitHeight: prompt.implicitHeight + Appearance.padding.normal * 2
+
+ color: Colours.palette.m3primary
+ radius: Appearance.rounding.small
+
+ MonoText {
+ id: prompt
+
+ anchors.centerIn: parent
+ text: ">"
+ font.pointSize: root.width > 400 ? Appearance.font.size.larger : Appearance.font.size.normal
+ color: Colours.palette.m3onPrimary
+ }
+ }
+
+ MonoText {
+ Layout.fillWidth: true
+ text: "caelestiafetch.sh"
+ font.pointSize: root.width > 400 ? Appearance.font.size.larger : Appearance.font.size.normal
+ elide: Text.ElideRight
+ }
+
+ WrappedLoader {
+ Layout.fillHeight: true
+ active: !iconLoader.active
+
+ sourceComponent: SysInfo.isDefaultLogo ? caelestiaLogo : distroIcon
+ }
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ Layout.fillHeight: false
+ spacing: height * 0.15
+
+ WrappedLoader {
+ id: iconLoader
+
+ Layout.fillHeight: true
+ active: root.width > 320
+
+ sourceComponent: SysInfo.isDefaultLogo ? caelestiaLogo : distroIcon
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ Layout.topMargin: Appearance.padding.normal
+ Layout.bottomMargin: Appearance.padding.normal
+ Layout.leftMargin: iconLoader.active ? 0 : width * 0.1
+ spacing: Appearance.spacing.normal
+
+ WrappedLoader {
+ Layout.fillWidth: true
+ active: !batLoader.active && root.height > 200
+
+ sourceComponent: FetchText {
+ text: `OS : ${SysInfo.osPrettyName || SysInfo.osName}`
+ }
+ }
+
+ WrappedLoader {
+ Layout.fillWidth: true
+ active: root.height > (batLoader.active ? 200 : 110)
+
+ sourceComponent: FetchText {
+ text: `WM : ${SysInfo.wm}`
+ }
+ }
+
+ WrappedLoader {
+ Layout.fillWidth: true
+ active: !batLoader.active || root.height > 110
+
+ sourceComponent: FetchText {
+ text: `USER: ${SysInfo.user}`
+ }
+ }
+
+ FetchText {
+ text: `UP : ${SysInfo.uptime}`
+ }
+
+ WrappedLoader {
+ id: batLoader
+
+ Layout.fillWidth: true
+ active: UPower.displayDevice.isLaptopBattery
+
+ sourceComponent: FetchText {
+ text: `BATT: ${[UPowerDeviceState.Charging, UPowerDeviceState.FullyCharged, UPowerDeviceState.PendingCharge].includes(UPower.displayDevice.state) ? "(+) " : ""}${Math.round(UPower.displayDevice.percentage * 100)}%`
+ }
+ }
+ }
+ }
+
+ WrappedLoader {
+ Layout.alignment: Qt.AlignHCenter
+ active: root.height > 180
+
+ sourceComponent: RowLayout {
+ spacing: Appearance.spacing.large
+
+ Repeater {
+ model: Math.max(0, Math.min(8, root.width / (Appearance.font.size.larger * 2 + Appearance.spacing.large)))
+
+ StyledRect {
+ required property int index
+
+ implicitWidth: implicitHeight
+ implicitHeight: Appearance.font.size.larger * 2
+ color: Colours.palette[`term${index}`]
+ radius: Appearance.rounding.small
+ }
+ }
+ }
+ }
+
+ Component {
+ id: caelestiaLogo
+
+ Logo {
+ width: height
+ height: height
+ }
+ }
+
+ Component {
+ id: distroIcon
+
+ ColouredIcon {
+ source: SysInfo.osLogo
+ implicitSize: height
+ colour: Colours.palette.m3primary
+ layer.enabled: Config.lock.recolourLogo
+ }
+ }
+
+ component WrappedLoader: Loader {
+ visible: active
+ }
+
+ component FetchText: MonoText {
+ Layout.fillWidth: true
+ font.pointSize: root.width > 400 ? Appearance.font.size.larger : Appearance.font.size.normal
+ elide: Text.ElideRight
+ }
+
+ component MonoText: StyledText {
+ font.family: Appearance.font.family.mono
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/lock/InputField.qml b/.config/quickshell/caelestia/modules/lock/InputField.qml
new file mode 100644
index 0000000..358093f
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/lock/InputField.qml
@@ -0,0 +1,149 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.services
+import qs.config
+import Quickshell
+import QtQuick
+import QtQuick.Layouts
+
+Item {
+ id: root
+
+ required property Pam pam
+ readonly property alias placeholder: placeholder
+ property string buffer
+
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+
+ clip: true
+
+ Connections {
+ target: root.pam
+
+ function onBufferChanged(): void {
+ if (root.pam.buffer.length > root.buffer.length) {
+ charList.bindImWidth();
+ } else if (root.pam.buffer.length === 0) {
+ charList.implicitWidth = charList.implicitWidth;
+ placeholder.animate = true;
+ }
+
+ root.buffer = root.pam.buffer;
+ }
+ }
+
+ StyledText {
+ id: placeholder
+
+ anchors.centerIn: parent
+
+ text: {
+ if (root.pam.passwd.active)
+ return qsTr("Loading...");
+ if (root.pam.state === "max")
+ return qsTr("You have reached the maximum number of tries");
+ return qsTr("Enter your password");
+ }
+
+ animate: true
+ color: root.pam.passwd.active ? Colours.palette.m3secondary : Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.normal
+ font.family: Appearance.font.family.mono
+
+ opacity: root.buffer ? 0 : 1
+
+ Behavior on opacity {
+ Anim {}
+ }
+ }
+
+ ListView {
+ id: charList
+
+ readonly property int fullWidth: count * (implicitHeight + spacing) - spacing
+
+ function bindImWidth(): void {
+ imWidthBehavior.enabled = false;
+ implicitWidth = Qt.binding(() => fullWidth);
+ imWidthBehavior.enabled = true;
+ }
+
+ anchors.centerIn: parent
+ anchors.horizontalCenterOffset: implicitWidth > root.width ? -(implicitWidth - root.width) / 2 : 0
+
+ implicitWidth: fullWidth
+ implicitHeight: Appearance.font.size.normal
+
+ orientation: Qt.Horizontal
+ spacing: Appearance.spacing.small / 2
+ interactive: false
+
+ model: ScriptModel {
+ values: root.buffer.split("")
+ }
+
+ delegate: StyledRect {
+ id: ch
+
+ implicitWidth: implicitHeight
+ implicitHeight: charList.implicitHeight
+
+ color: Colours.palette.m3onSurface
+ radius: Appearance.rounding.small / 2
+
+ opacity: 0
+ scale: 0
+ Component.onCompleted: {
+ opacity = 1;
+ scale = 1;
+ }
+ ListView.onRemove: removeAnim.start()
+
+ SequentialAnimation {
+ id: removeAnim
+
+ PropertyAction {
+ target: ch
+ property: "ListView.delayRemove"
+ value: true
+ }
+ ParallelAnimation {
+ Anim {
+ target: ch
+ property: "opacity"
+ to: 0
+ }
+ Anim {
+ target: ch
+ property: "scale"
+ to: 0.5
+ }
+ }
+ PropertyAction {
+ target: ch
+ property: "ListView.delayRemove"
+ value: false
+ }
+ }
+
+ Behavior on opacity {
+ Anim {}
+ }
+
+ Behavior on scale {
+ Anim {
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
+ }
+ }
+ }
+
+ Behavior on implicitWidth {
+ id: imWidthBehavior
+
+ Anim {}
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/lock/Lock.qml b/.config/quickshell/caelestia/modules/lock/Lock.qml
new file mode 100644
index 0000000..6fd5277
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/lock/Lock.qml
@@ -0,0 +1,55 @@
+pragma ComponentBehavior: Bound
+
+import qs.components.misc
+import Quickshell
+import Quickshell.Io
+import Quickshell.Wayland
+
+Scope {
+ property alias lock: lock
+
+ WlSessionLock {
+ id: lock
+
+ signal unlock
+
+ LockSurface {
+ lock: lock
+ pam: pam
+ }
+ }
+
+ Pam {
+ id: pam
+
+ lock: lock
+ }
+
+ CustomShortcut {
+ name: "lock"
+ description: "Lock the current session"
+ onPressed: lock.locked = true
+ }
+
+ CustomShortcut {
+ name: "unlock"
+ description: "Unlock the current session"
+ onPressed: lock.unlock()
+ }
+
+ IpcHandler {
+ target: "lock"
+
+ function lock(): void {
+ lock.locked = true;
+ }
+
+ function unlock(): void {
+ lock.unlock();
+ }
+
+ function isLocked(): bool {
+ return lock.locked;
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/lock/LockSurface.qml b/.config/quickshell/caelestia/modules/lock/LockSurface.qml
new file mode 100644
index 0000000..279c551
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/lock/LockSurface.qml
@@ -0,0 +1,230 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.services
+import qs.config
+import Quickshell.Wayland
+import QtQuick
+import QtQuick.Effects
+
+WlSessionLockSurface {
+ id: root
+
+ required property WlSessionLock lock
+ required property Pam pam
+
+ readonly property alias unlocking: unlockAnim.running
+
+ color: "transparent"
+
+ Connections {
+ target: root.lock
+
+ function onUnlock(): void {
+ unlockAnim.start();
+ }
+ }
+
+ SequentialAnimation {
+ id: unlockAnim
+
+ ParallelAnimation {
+ Anim {
+ target: lockContent
+ properties: "implicitWidth,implicitHeight"
+ to: lockContent.size
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ Anim {
+ target: lockBg
+ property: "radius"
+ to: lockContent.radius
+ }
+ Anim {
+ target: content
+ property: "scale"
+ to: 0
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ Anim {
+ target: content
+ property: "opacity"
+ to: 0
+ duration: Appearance.anim.durations.small
+ }
+ Anim {
+ target: lockIcon
+ property: "opacity"
+ to: 1
+ duration: Appearance.anim.durations.large
+ }
+ Anim {
+ target: background
+ property: "opacity"
+ to: 0
+ duration: Appearance.anim.durations.large
+ }
+ SequentialAnimation {
+ PauseAnimation {
+ duration: Appearance.anim.durations.small
+ }
+ Anim {
+ target: lockContent
+ property: "opacity"
+ to: 0
+ }
+ }
+ }
+ PropertyAction {
+ target: root.lock
+ property: "locked"
+ value: false
+ }
+ }
+
+ ParallelAnimation {
+ id: initAnim
+
+ running: true
+
+ Anim {
+ target: background
+ property: "opacity"
+ to: 1
+ duration: Appearance.anim.durations.large
+ }
+ SequentialAnimation {
+ ParallelAnimation {
+ Anim {
+ target: lockContent
+ property: "scale"
+ to: 1
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
+ }
+ Anim {
+ target: lockContent
+ property: "rotation"
+ to: 360
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ easing.bezierCurve: Appearance.anim.curves.standardAccel
+ }
+ }
+ ParallelAnimation {
+ Anim {
+ target: lockIcon
+ property: "rotation"
+ to: 360
+ easing.bezierCurve: Appearance.anim.curves.standardDecel
+ }
+ Anim {
+ target: lockIcon
+ property: "opacity"
+ to: 0
+ }
+ Anim {
+ target: content
+ property: "opacity"
+ to: 1
+ }
+ Anim {
+ target: content
+ property: "scale"
+ to: 1
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ Anim {
+ target: lockBg
+ property: "radius"
+ to: Appearance.rounding.large * 1.5
+ }
+ Anim {
+ target: lockContent
+ property: "implicitWidth"
+ to: (root.screen?.height ?? 0) * Config.lock.sizes.heightMult * Config.lock.sizes.ratio
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ Anim {
+ target: lockContent
+ property: "implicitHeight"
+ to: (root.screen?.height ?? 0) * Config.lock.sizes.heightMult
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ }
+ }
+ }
+
+ ScreencopyView {
+ id: background
+
+ anchors.fill: parent
+ captureSource: root.screen
+ opacity: 0
+
+ layer.enabled: true
+ layer.effect: MultiEffect {
+ autoPaddingEnabled: false
+ blurEnabled: true
+ blur: 1
+ blurMax: 64
+ blurMultiplier: 1
+ }
+ }
+
+ Item {
+ id: lockContent
+
+ readonly property int size: lockIcon.implicitHeight + Appearance.padding.large * 4
+ readonly property int radius: size / 4 * Appearance.rounding.scale
+
+ anchors.centerIn: parent
+ implicitWidth: size
+ implicitHeight: size
+
+ rotation: 180
+ scale: 0
+
+ StyledRect {
+ id: lockBg
+
+ anchors.fill: parent
+ color: Colours.palette.m3surface
+ radius: parent.radius
+ opacity: Colours.transparency.enabled ? Colours.transparency.base : 1
+
+ layer.enabled: true
+ layer.effect: MultiEffect {
+ shadowEnabled: true
+ blurMax: 15
+ shadowColor: Qt.alpha(Colours.palette.m3shadow, 0.7)
+ }
+ }
+
+ MaterialIcon {
+ id: lockIcon
+
+ anchors.centerIn: parent
+ text: "lock"
+ font.pointSize: Appearance.font.size.extraLarge * 4
+ font.bold: true
+ rotation: 180
+ }
+
+ Content {
+ id: content
+
+ anchors.centerIn: parent
+ width: (root.screen?.height ?? 0) * Config.lock.sizes.heightMult * Config.lock.sizes.ratio - Appearance.padding.large * 2
+ height: (root.screen?.height ?? 0) * Config.lock.sizes.heightMult - Appearance.padding.large * 2
+
+ lock: root
+ opacity: 0
+ scale: 0
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/lock/Media.qml b/.config/quickshell/caelestia/modules/lock/Media.qml
new file mode 100644
index 0000000..b7e58bb
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/lock/Media.qml
@@ -0,0 +1,208 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.components.effects
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+Item {
+ id: root
+
+ required property var lock
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ implicitHeight: layout.implicitHeight
+
+ Image {
+ anchors.fill: parent
+ source: Players.active?.trackArtUrl ?? ""
+
+ asynchronous: true
+ fillMode: Image.PreserveAspectCrop
+ sourceSize.width: width
+ sourceSize.height: height
+
+ layer.enabled: true
+ layer.effect: OpacityMask {
+ maskSource: mask
+ }
+
+ opacity: status === Image.Ready ? 1 : 0
+
+ Behavior on opacity {
+ Anim {
+ duration: Appearance.anim.durations.extraLarge
+ }
+ }
+ }
+
+ Rectangle {
+ id: mask
+
+ anchors.fill: parent
+ layer.enabled: true
+ visible: false
+
+ gradient: Gradient {
+ orientation: Gradient.Horizontal
+
+ GradientStop {
+ position: 0
+ color: Qt.rgba(0, 0, 0, 0.5)
+ }
+ GradientStop {
+ position: 0.4
+ color: Qt.rgba(0, 0, 0, 0.2)
+ }
+ GradientStop {
+ position: 0.8
+ color: Qt.rgba(0, 0, 0, 0)
+ }
+ }
+ }
+
+ ColumnLayout {
+ id: layout
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.margins: Appearance.padding.large
+
+ StyledText {
+ Layout.topMargin: Appearance.padding.large
+ Layout.bottomMargin: Appearance.spacing.larger
+ text: qsTr("Now playing")
+ color: Colours.palette.m3onSurfaceVariant
+ font.family: Appearance.font.family.mono
+ font.weight: 500
+ }
+
+ StyledText {
+ Layout.fillWidth: true
+ animate: true
+ text: Players.active?.trackArtist ?? qsTr("No media")
+ color: Colours.palette.m3primary
+ horizontalAlignment: Text.AlignHCenter
+ font.pointSize: Appearance.font.size.large
+ font.family: Appearance.font.family.mono
+ font.weight: 600
+ elide: Text.ElideRight
+ }
+
+ StyledText {
+ Layout.fillWidth: true
+ animate: true
+ text: Players.active?.trackTitle ?? qsTr("No media")
+ horizontalAlignment: Text.AlignHCenter
+ font.pointSize: Appearance.font.size.larger
+ font.family: Appearance.font.family.mono
+ elide: Text.ElideRight
+ }
+
+ RowLayout {
+ Layout.alignment: Qt.AlignHCenter
+ Layout.topMargin: Appearance.spacing.large * 1.2
+ Layout.bottomMargin: Appearance.padding.large
+
+ spacing: Appearance.spacing.large
+
+ PlayerControl {
+ icon: "skip_previous"
+
+ function onClicked(): void {
+ if (Players.active?.canGoPrevious)
+ Players.active.previous();
+ }
+ }
+
+ PlayerControl {
+ animate: true
+ icon: active ? "pause" : "play_arrow"
+ colour: "Primary"
+ level: active ? 2 : 1
+ active: Players.active?.isPlaying ?? false
+
+ function onClicked(): void {
+ if (Players.active?.canTogglePlaying)
+ Players.active.togglePlaying();
+ }
+ }
+
+ PlayerControl {
+ icon: "skip_next"
+
+ function onClicked(): void {
+ if (Players.active?.canGoNext)
+ Players.active.next();
+ }
+ }
+ }
+ }
+
+ component PlayerControl: StyledRect {
+ id: control
+
+ property alias animate: controlIcon.animate
+ property alias icon: controlIcon.text
+ property bool active
+ property string colour: "Secondary"
+ property int level: 1
+
+ function onClicked(): void {
+ }
+
+ Layout.preferredWidth: implicitWidth + (controlState.pressed ? Appearance.padding.normal * 2 : active ? Appearance.padding.small * 2 : 0)
+ implicitWidth: controlIcon.implicitWidth + Appearance.padding.large * 2
+ implicitHeight: controlIcon.implicitHeight + Appearance.padding.normal * 2
+
+ color: active ? Colours.palette[`m3${colour.toLowerCase()}`] : Colours.palette[`m3${colour.toLowerCase()}Container`]
+ radius: active || controlState.pressed ? Appearance.rounding.normal : Math.min(implicitWidth, implicitHeight) / 2 * Math.min(1, Appearance.rounding.scale)
+
+ Elevation {
+ anchors.fill: parent
+ radius: parent.radius
+ z: -1
+ level: controlState.containsMouse && !controlState.pressed ? control.level + 1 : control.level
+ }
+
+ StateLayer {
+ id: controlState
+
+ color: control.active ? Colours.palette[`m3on${control.colour}`] : Colours.palette[`m3on${control.colour}Container`]
+
+ function onClicked(): void {
+ control.onClicked();
+ }
+ }
+
+ MaterialIcon {
+ id: controlIcon
+
+ anchors.centerIn: parent
+ color: control.active ? Colours.palette[`m3on${control.colour}`] : Colours.palette[`m3on${control.colour}Container`]
+ font.pointSize: Appearance.font.size.large
+ fill: control.active ? 1 : 0
+
+ Behavior on fill {
+ Anim {}
+ }
+ }
+
+ Behavior on Layout.preferredWidth {
+ Anim {
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
+ }
+ }
+
+ Behavior on radius {
+ Anim {
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/lock/NotifDock.qml b/.config/quickshell/caelestia/modules/lock/NotifDock.qml
new file mode 100644
index 0000000..01f7e4b
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/lock/NotifDock.qml
@@ -0,0 +1,145 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.components.containers
+import qs.components.effects
+import qs.services
+import qs.config
+import Quickshell
+import Quickshell.Widgets
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ required property var lock
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.large
+
+ spacing: Appearance.spacing.smaller
+
+ StyledText {
+ Layout.fillWidth: true
+ text: Notifs.list.length > 0 ? qsTr("%1 notification%2").arg(Notifs.list.length).arg(Notifs.list.length === 1 ? "" : "s") : qsTr("Notifications")
+ color: Colours.palette.m3outline
+ font.family: Appearance.font.family.mono
+ font.weight: 500
+ elide: Text.ElideRight
+ }
+
+ ClippingRectangle {
+ id: clipRect
+
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+
+ radius: Appearance.rounding.small
+ color: "transparent"
+
+ Loader {
+ anchors.centerIn: parent
+ active: opacity > 0
+ opacity: Notifs.list.length > 0 ? 0 : 1
+
+ sourceComponent: ColumnLayout {
+ spacing: Appearance.spacing.large
+
+ Image {
+ asynchronous: true
+ source: Qt.resolvedUrl(`${Quickshell.shellDir}/assets/dino.png`)
+ fillMode: Image.PreserveAspectFit
+ sourceSize.width: clipRect.width * 0.8
+
+ layer.enabled: true
+ layer.effect: Colouriser {
+ colorizationColor: Colours.palette.m3outlineVariant
+ brightness: 1
+ }
+ }
+
+ StyledText {
+ Layout.alignment: Qt.AlignHCenter
+ text: qsTr("No Notifications")
+ color: Colours.palette.m3outlineVariant
+ font.pointSize: Appearance.font.size.large
+ font.family: Appearance.font.family.mono
+ font.weight: 500
+ }
+ }
+
+ Behavior on opacity {
+ Anim {
+ duration: Appearance.anim.durations.extraLarge
+ }
+ }
+ }
+
+ StyledListView {
+ anchors.fill: parent
+
+ spacing: Appearance.spacing.small
+ clip: true
+
+ model: ScriptModel {
+ values: {
+ const list = Notifs.notClosed.map(n => [n.appName, null]);
+ return [...new Map(list).keys()];
+ }
+ }
+
+ delegate: NotifGroup {}
+
+ add: Transition {
+ Anim {
+ property: "opacity"
+ from: 0
+ to: 1
+ }
+ Anim {
+ property: "scale"
+ from: 0
+ to: 1
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ }
+
+ remove: Transition {
+ Anim {
+ property: "opacity"
+ to: 0
+ }
+ Anim {
+ property: "scale"
+ to: 0.6
+ }
+ }
+
+ move: Transition {
+ Anim {
+ properties: "opacity,scale"
+ to: 1
+ }
+ Anim {
+ property: "y"
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ }
+
+ displaced: Transition {
+ Anim {
+ properties: "opacity,scale"
+ to: 1
+ }
+ Anim {
+ property: "y"
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/lock/NotifGroup.qml b/.config/quickshell/caelestia/modules/lock/NotifGroup.qml
new file mode 100644
index 0000000..7796090
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/lock/NotifGroup.qml
@@ -0,0 +1,316 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.components.effects
+import qs.services
+import qs.config
+import qs.utils
+import Quickshell
+import Quickshell.Widgets
+import Quickshell.Services.Notifications
+import QtQuick
+import QtQuick.Layouts
+
+StyledRect {
+ id: root
+
+ required property string modelData
+
+ readonly property list notifs: Notifs.list.filter(notif => notif.appName === modelData)
+ readonly property string image: notifs.find(n => n.image.length > 0)?.image ?? ""
+ readonly property string appIcon: notifs.find(n => n.appIcon.length > 0)?.appIcon ?? ""
+ readonly property string urgency: notifs.some(n => n.urgency === NotificationUrgency.Critical) ? "critical" : notifs.some(n => n.urgency === NotificationUrgency.Normal) ? "normal" : "low"
+
+ property bool expanded
+
+ anchors.left: parent?.left
+ anchors.right: parent?.right
+ implicitHeight: content.implicitHeight + Appearance.padding.normal * 2
+
+ clip: true
+ radius: Appearance.rounding.normal
+ color: root.urgency === "critical" ? Colours.palette.m3secondaryContainer : Colours.layer(Colours.palette.m3surfaceContainerHigh, 2)
+
+ RowLayout {
+ id: content
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.margins: Appearance.padding.normal
+
+ spacing: Appearance.spacing.normal
+
+ Item {
+ Layout.alignment: Qt.AlignLeft | Qt.AlignTop
+ implicitWidth: Config.notifs.sizes.image
+ implicitHeight: Config.notifs.sizes.image
+
+ Component {
+ id: imageComp
+
+ Image {
+ source: Qt.resolvedUrl(root.image)
+ fillMode: Image.PreserveAspectCrop
+ cache: false
+ asynchronous: true
+ width: Config.notifs.sizes.image
+ height: Config.notifs.sizes.image
+ }
+ }
+
+ Component {
+ id: appIconComp
+
+ ColouredIcon {
+ implicitSize: Math.round(Config.notifs.sizes.image * 0.6)
+ source: Quickshell.iconPath(root.appIcon)
+ colour: root.urgency === "critical" ? Colours.palette.m3onError : root.urgency === "low" ? Colours.palette.m3onSurface : Colours.palette.m3onSecondaryContainer
+ layer.enabled: root.appIcon.endsWith("symbolic")
+ }
+ }
+
+ Component {
+ id: materialIconComp
+
+ MaterialIcon {
+ text: Icons.getNotifIcon(root.notifs[0]?.summary, root.urgency)
+ color: root.urgency === "critical" ? Colours.palette.m3onError : root.urgency === "low" ? Colours.palette.m3onSurface : Colours.palette.m3onSecondaryContainer
+ font.pointSize: Appearance.font.size.large
+ }
+ }
+
+ ClippingRectangle {
+ anchors.fill: parent
+ color: root.urgency === "critical" ? Colours.palette.m3error : root.urgency === "low" ? Colours.layer(Colours.palette.m3surfaceContainerHighest, 3) : Colours.palette.m3secondaryContainer
+ radius: Appearance.rounding.full
+
+ Loader {
+ anchors.centerIn: parent
+ sourceComponent: root.image ? imageComp : root.appIcon ? appIconComp : materialIconComp
+ }
+ }
+
+ Loader {
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ active: root.appIcon && root.image
+
+ sourceComponent: StyledRect {
+ implicitWidth: Config.notifs.sizes.badge
+ implicitHeight: Config.notifs.sizes.badge
+
+ color: root.urgency === "critical" ? Colours.palette.m3error : root.urgency === "low" ? Colours.palette.m3surfaceContainerHighest : Colours.palette.m3secondaryContainer
+ radius: Appearance.rounding.full
+
+ ColouredIcon {
+ anchors.centerIn: parent
+ implicitSize: Math.round(Config.notifs.sizes.badge * 0.6)
+ source: Quickshell.iconPath(root.appIcon)
+ colour: root.urgency === "critical" ? Colours.palette.m3onError : root.urgency === "low" ? Colours.palette.m3onSurface : Colours.palette.m3onSecondaryContainer
+ layer.enabled: root.appIcon.endsWith("symbolic")
+ }
+ }
+ }
+ }
+
+ ColumnLayout {
+ Layout.topMargin: -Appearance.padding.small
+ Layout.bottomMargin: -Appearance.padding.small / 2 - (root.expanded ? 0 : spacing)
+ Layout.fillWidth: true
+ spacing: Math.round(Appearance.spacing.small / 2)
+
+ RowLayout {
+ Layout.bottomMargin: -parent.spacing
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.smaller
+
+ StyledText {
+ Layout.fillWidth: true
+ text: root.modelData
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.small
+ elide: Text.ElideRight
+ }
+
+ StyledText {
+ animate: true
+ text: root.notifs[0]?.timeStr ?? ""
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ }
+
+ StyledRect {
+ implicitWidth: expandBtn.implicitWidth + Appearance.padding.smaller * 2
+ implicitHeight: groupCount.implicitHeight + Appearance.padding.small
+
+ color: root.urgency === "critical" ? Colours.palette.m3error : Colours.layer(Colours.palette.m3surfaceContainerHighest, 2)
+ radius: Appearance.rounding.full
+
+ opacity: root.notifs.length > Config.notifs.groupPreviewNum ? 1 : 0
+ Layout.preferredWidth: root.notifs.length > Config.notifs.groupPreviewNum ? implicitWidth : 0
+
+ StateLayer {
+ color: root.urgency === "critical" ? Colours.palette.m3onError : Colours.palette.m3onSurface
+
+ function onClicked(): void {
+ root.expanded = !root.expanded;
+ }
+ }
+
+ RowLayout {
+ id: expandBtn
+
+ anchors.centerIn: parent
+ spacing: Appearance.spacing.small / 2
+
+ StyledText {
+ id: groupCount
+
+ Layout.leftMargin: Appearance.padding.small / 2
+ animate: true
+ text: root.notifs.length
+ color: root.urgency === "critical" ? Colours.palette.m3onError : Colours.palette.m3onSurface
+ font.pointSize: Appearance.font.size.small
+ }
+
+ MaterialIcon {
+ Layout.rightMargin: -Appearance.padding.small / 2
+ animate: true
+ text: root.expanded ? "expand_less" : "expand_more"
+ color: root.urgency === "critical" ? Colours.palette.m3onError : Colours.palette.m3onSurface
+ }
+ }
+
+ Behavior on opacity {
+ Anim {}
+ }
+
+ Behavior on Layout.preferredWidth {
+ Anim {}
+ }
+ }
+ }
+
+ Repeater {
+ model: ScriptModel {
+ values: root.notifs.slice(0, Config.notifs.groupPreviewNum)
+ }
+
+ NotifLine {
+ id: notif
+
+ ParallelAnimation {
+ running: true
+
+ Anim {
+ target: notif
+ property: "opacity"
+ from: 0
+ to: 1
+ }
+ Anim {
+ target: notif
+ property: "scale"
+ from: 0.7
+ to: 1
+ }
+ Anim {
+ target: notif.Layout
+ property: "preferredHeight"
+ from: 0
+ to: notif.implicitHeight
+ }
+ }
+
+ ParallelAnimation {
+ running: notif.modelData.closed
+ onFinished: notif.modelData.unlock(notif)
+
+ Anim {
+ target: notif
+ property: "opacity"
+ to: 0
+ }
+ Anim {
+ target: notif
+ property: "scale"
+ to: 0.7
+ }
+ Anim {
+ target: notif.Layout
+ property: "preferredHeight"
+ to: 0
+ }
+ }
+ }
+ }
+
+ Loader {
+ Layout.fillWidth: true
+
+ opacity: root.expanded ? 1 : 0
+ Layout.preferredHeight: root.expanded ? implicitHeight : 0
+ active: opacity > 0
+
+ sourceComponent: ColumnLayout {
+ Repeater {
+ model: ScriptModel {
+ values: root.notifs.slice(Config.notifs.groupPreviewNum)
+ }
+
+ NotifLine {}
+ }
+ }
+
+ Behavior on opacity {
+ Anim {}
+ }
+ }
+ }
+ }
+
+ Behavior on implicitHeight {
+ Anim {
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ }
+
+ component NotifLine: StyledText {
+ id: notifLine
+
+ required property Notifs.Notif modelData
+
+ Layout.fillWidth: true
+ textFormat: Text.MarkdownText
+ text: {
+ const summary = modelData.summary.replace(/\n/g, " ");
+ const body = modelData.body.replace(/\n/g, " ");
+ const colour = root.urgency === "critical" ? Colours.palette.m3secondary : Colours.palette.m3outline;
+
+ if (metrics.text === metrics.elidedText)
+ return `${summary} ${body} `;
+
+ const t = metrics.elidedText.length - 3;
+ if (t < summary.length)
+ return `${summary.slice(0, t)}...`;
+
+ return `${summary} ${body.slice(0, t - summary.length)}... `;
+ }
+ color: root.urgency === "critical" ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface
+
+ Component.onCompleted: modelData.lock(this)
+ Component.onDestruction: modelData.unlock(this)
+
+ TextMetrics {
+ id: metrics
+
+ text: `${notifLine.modelData.summary} ${notifLine.modelData.body}`.replace(/\n/g, " ")
+ font.pointSize: notifLine.font.pointSize
+ font.family: notifLine.font.family
+ elideWidth: notifLine.width
+ elide: Text.ElideRight
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/lock/Pam.qml b/.config/quickshell/caelestia/modules/lock/Pam.qml
new file mode 100644
index 0000000..0186c2f
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/lock/Pam.qml
@@ -0,0 +1,193 @@
+import qs.config
+import Quickshell
+import Quickshell.Io
+import Quickshell.Wayland
+import Quickshell.Services.Pam
+import QtQuick
+
+Scope {
+ id: root
+
+ required property WlSessionLock lock
+
+ readonly property alias passwd: passwd
+ readonly property alias fprint: fprint
+ property string lockMessage
+ property string state
+ property string fprintState
+ property string buffer
+
+ signal flashMsg
+
+ function handleKey(event: KeyEvent): void {
+ if (passwd.active || state === "max")
+ return;
+
+ if (event.key === Qt.Key_Enter || event.key === Qt.Key_Return) {
+ passwd.start();
+ } else if (event.key === Qt.Key_Backspace) {
+ if (event.modifiers & Qt.ControlModifier) {
+ buffer = "";
+ } else {
+ buffer = buffer.slice(0, -1);
+ }
+ } else if (" abcdefghijklmnopqrstuvwxyz1234567890`~!@#$%^&*()-_=+[{]}\\|;:'\",<.>/?".includes(event.text.toLowerCase())) {
+ // No illegal characters (you are insane if you use unicode in your password)
+ buffer += event.text;
+ }
+ }
+
+ PamContext {
+ id: passwd
+
+ config: "passwd"
+ configDirectory: Quickshell.shellDir + "/assets/pam.d"
+
+ onMessageChanged: {
+ if (message.startsWith("The account is locked"))
+ root.lockMessage = message;
+ else if (root.lockMessage && message.endsWith(" left to unlock)"))
+ root.lockMessage += "\n" + message;
+ }
+
+ onResponseRequiredChanged: {
+ if (!responseRequired)
+ return;
+
+ respond(root.buffer);
+ root.buffer = "";
+ }
+
+ onCompleted: res => {
+ if (res === PamResult.Success)
+ return root.lock.unlock();
+
+ if (res === PamResult.Error)
+ root.state = "error";
+ else if (res === PamResult.MaxTries)
+ root.state = "max";
+ else if (res === PamResult.Failed)
+ root.state = "fail";
+
+ root.flashMsg();
+ stateReset.restart();
+ }
+ }
+
+ PamContext {
+ id: fprint
+
+ property bool available
+ property int tries
+ property int errorTries
+
+ function checkAvail(): void {
+ if (!available || !Config.lock.enableFprint || !root.lock.secure) {
+ abort();
+ return;
+ }
+
+ tries = 0;
+ errorTries = 0;
+ start();
+ }
+
+ config: "fprint"
+ configDirectory: Quickshell.shellDir + "/assets/pam.d"
+
+ onCompleted: res => {
+ if (!available)
+ return;
+
+ if (res === PamResult.Success)
+ return root.lock.unlock();
+
+ if (res === PamResult.Error) {
+ root.fprintState = "error";
+ errorTries++;
+ if (errorTries < 5) {
+ abort();
+ errorRetry.restart();
+ }
+ } else if (res === PamResult.MaxTries) {
+ // Isn't actually the real max tries as pam only reports completed
+ // when max tries is reached.
+ tries++;
+ if (tries < Config.lock.maxFprintTries) {
+ // Restart if not actually real max tries
+ root.fprintState = "fail";
+ start();
+ } else {
+ root.fprintState = "max";
+ abort();
+ }
+ }
+
+ root.flashMsg();
+ fprintStateReset.start();
+ }
+ }
+
+ Process {
+ id: availProc
+
+ command: ["sh", "-c", "fprintd-list $USER"]
+ onExited: code => {
+ fprint.available = code === 0;
+ fprint.checkAvail();
+ }
+ }
+
+ Timer {
+ id: errorRetry
+
+ interval: 800
+ onTriggered: fprint.start()
+ }
+
+ Timer {
+ id: stateReset
+
+ interval: 4000
+ onTriggered: {
+ if (root.state !== "max")
+ root.state = "";
+ }
+ }
+
+ Timer {
+ id: fprintStateReset
+
+ interval: 4000
+ onTriggered: {
+ root.fprintState = "";
+ fprint.errorTries = 0;
+ }
+ }
+
+ Connections {
+ target: root.lock
+
+ function onSecureChanged(): void {
+ if (root.lock.secure) {
+ availProc.running = true;
+ root.buffer = "";
+ root.state = "";
+ root.fprintState = "";
+ root.lockMessage = "";
+ }
+ }
+
+ function onUnlock(): void {
+ fprint.abort();
+ }
+ }
+
+ Connections {
+ target: Config.lock
+
+ function onEnableFprintChanged(): void {
+ fprint.checkAvail();
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/lock/Resources.qml b/.config/quickshell/caelestia/modules/lock/Resources.qml
new file mode 100644
index 0000000..82c004c
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/lock/Resources.qml
@@ -0,0 +1,93 @@
+import qs.components
+import qs.components.controls
+import qs.components.misc
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+GridLayout {
+ id: root
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.margins: Appearance.padding.large
+
+ rowSpacing: Appearance.spacing.large
+ columnSpacing: Appearance.spacing.large
+ rows: 2
+ columns: 2
+
+ Ref {
+ service: SystemUsage
+ }
+
+ Resource {
+ Layout.topMargin: Appearance.padding.large
+ icon: "memory"
+ value: SystemUsage.cpuPerc
+ colour: Colours.palette.m3primary
+ }
+
+ Resource {
+ Layout.topMargin: Appearance.padding.large
+ icon: "thermostat"
+ value: Math.min(1, SystemUsage.cpuTemp / 90)
+ colour: Colours.palette.m3secondary
+ }
+
+ Resource {
+ Layout.bottomMargin: Appearance.padding.large
+ icon: "memory_alt"
+ value: SystemUsage.memPerc
+ colour: Colours.palette.m3secondary
+ }
+
+ Resource {
+ Layout.bottomMargin: Appearance.padding.large
+ icon: "hard_disk"
+ value: SystemUsage.storagePerc
+ colour: Colours.palette.m3tertiary
+ }
+
+ component Resource: StyledRect {
+ id: res
+
+ required property string icon
+ required property real value
+ required property color colour
+
+ Layout.fillWidth: true
+ implicitHeight: width
+
+ color: Colours.layer(Colours.palette.m3surfaceContainerHigh, 2)
+ radius: Appearance.rounding.large
+
+ CircularProgress {
+ id: circ
+
+ anchors.fill: parent
+ value: res.value
+ padding: Appearance.padding.large * 3
+ fgColour: res.colour
+ bgColour: Colours.layer(Colours.palette.m3surfaceContainerHighest, 3)
+ strokeWidth: width < 200 ? Appearance.padding.smaller : Appearance.padding.normal
+ }
+
+ MaterialIcon {
+ id: icon
+
+ anchors.centerIn: parent
+ text: res.icon
+ color: res.colour
+ font.pointSize: (circ.arcRadius * 0.7) || 1
+ font.weight: 600
+ }
+
+ Behavior on value {
+ Anim {
+ duration: Appearance.anim.durations.large
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/lock/WeatherInfo.qml b/.config/quickshell/caelestia/modules/lock/WeatherInfo.qml
new file mode 100644
index 0000000..d6c25af
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/lock/WeatherInfo.qml
@@ -0,0 +1,176 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.services
+import qs.config
+import qs.utils
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ required property int rootHeight
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.margins: Appearance.padding.large * 2
+
+ spacing: Appearance.spacing.small
+
+ Loader {
+ Layout.topMargin: Appearance.padding.large * 2
+ Layout.bottomMargin: -Appearance.padding.large
+ Layout.alignment: Qt.AlignHCenter
+
+ active: root.rootHeight > 610
+ visible: active
+
+ sourceComponent: StyledText {
+ text: qsTr("Weather")
+ color: Colours.palette.m3primary
+ font.pointSize: Appearance.font.size.extraLarge
+ font.weight: 500
+ }
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.large
+
+ MaterialIcon {
+ animate: true
+ text: Weather.icon
+ color: Colours.palette.m3secondary
+ font.pointSize: Appearance.font.size.extraLarge * 2.5
+ }
+
+ ColumnLayout {
+ spacing: Appearance.spacing.small
+
+ StyledText {
+ Layout.fillWidth: true
+
+ animate: true
+ text: Weather.description
+ color: Colours.palette.m3secondary
+ font.pointSize: Appearance.font.size.large
+ font.weight: 500
+ elide: Text.ElideRight
+ }
+
+ StyledText {
+ Layout.fillWidth: true
+
+ animate: true
+ text: qsTr("Humidity: %1%").arg(Weather.humidity)
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.normal
+ elide: Text.ElideRight
+ }
+ }
+
+ Loader {
+ Layout.rightMargin: Appearance.padding.smaller
+ active: root.width > 400
+ visible: active
+
+ sourceComponent: ColumnLayout {
+ spacing: Appearance.spacing.small
+
+ StyledText {
+ Layout.fillWidth: true
+
+ animate: true
+ text: Weather.temp
+ color: Colours.palette.m3primary
+ horizontalAlignment: Text.AlignRight
+ font.pointSize: Appearance.font.size.extraLarge
+ font.weight: 500
+ elide: Text.ElideLeft
+ }
+
+ StyledText {
+ Layout.fillWidth: true
+
+ animate: true
+ text: qsTr("Feels like: %1").arg(Weather.feelsLike)
+ color: Colours.palette.m3outline
+ horizontalAlignment: Text.AlignRight
+ font.pointSize: Appearance.font.size.smaller
+ elide: Text.ElideLeft
+ }
+ }
+ }
+ }
+
+ Loader {
+ id: forecastLoader
+
+ Layout.topMargin: Appearance.spacing.smaller
+ Layout.bottomMargin: Appearance.padding.large * 2
+ Layout.fillWidth: true
+
+ active: root.rootHeight > 820
+ visible: active
+
+ sourceComponent: RowLayout {
+ spacing: Appearance.spacing.large
+
+ Repeater {
+ model: {
+ const forecast = Weather.hourlyForecast;
+ const count = root.width < 320 ? 3 : root.width < 400 ? 4 : 5;
+ if (!forecast)
+ return Array.from({
+ length: count
+ }, () => null);
+
+ return forecast.slice(0, count);
+ }
+
+ ColumnLayout {
+ id: forecastHour
+
+ required property var modelData
+
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.small
+
+ StyledText {
+ Layout.fillWidth: true
+ text: {
+ const hour = forecastHour.modelData?.hour ?? 0;
+ return hour > 12 ? `${(hour - 12).toString().padStart(2, "0")} PM` : `${hour.toString().padStart(2, "0")} AM`;
+ }
+ color: Colours.palette.m3outline
+ horizontalAlignment: Text.AlignHCenter
+ font.pointSize: Appearance.font.size.larger
+ }
+
+ MaterialIcon {
+ Layout.alignment: Qt.AlignHCenter
+ text: forecastHour.modelData?.icon ?? "cloud_alert"
+ font.pointSize: Appearance.font.size.extraLarge * 1.5
+ font.weight: 500
+ }
+
+ StyledText {
+ Layout.alignment: Qt.AlignHCenter
+ text: Config.services.useFahrenheit ? `${forecastHour.modelData?.tempF ?? 0}°F` : `${forecastHour.modelData?.tempC ?? 0}°C`
+ color: Colours.palette.m3secondary
+ font.pointSize: Appearance.font.size.larger
+ }
+ }
+ }
+ }
+ }
+
+ Timer {
+ running: true
+ triggeredOnStart: true
+ repeat: true
+ interval: 900000 // 15 minutes
+ onTriggered: Weather.reload()
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/notifications/Background.qml b/.config/quickshell/caelestia/modules/notifications/Background.qml
new file mode 100644
index 0000000..a44cb19
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/notifications/Background.qml
@@ -0,0 +1,54 @@
+import qs.components
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Shapes
+
+ShapePath {
+ id: root
+
+ required property Wrapper wrapper
+ required property var sidebar
+ readonly property real rounding: Config.border.rounding
+ readonly property bool flatten: wrapper.height < rounding * 2
+ readonly property real roundingY: flatten ? wrapper.height / 2 : rounding
+
+ strokeWidth: -1
+ fillColor: Colours.palette.m3surface
+
+ 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.sidebar.notifsRoundingX
+ relativeY: root.roundingY
+ radiusX: root.sidebar.notifsRoundingX
+ radiusY: Math.min(root.rounding, root.wrapper.height)
+ direction: PathArc.Counterclockwise
+ }
+ PathLine {
+ relativeX: root.wrapper.height > 0 ? root.wrapper.width - root.rounding - root.sidebar.notifsRoundingX : root.wrapper.width
+ relativeY: 0
+ }
+ PathArc {
+ relativeX: root.rounding
+ relativeY: root.rounding
+ radiusX: root.rounding
+ radiusY: root.rounding
+ }
+
+ Behavior on fillColor {
+ CAnim {}
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/notifications/Content.qml b/.config/quickshell/caelestia/modules/notifications/Content.qml
new file mode 100644
index 0000000..2d4590e
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/notifications/Content.qml
@@ -0,0 +1,204 @@
+import qs.components.containers
+import qs.components.widgets
+import qs.services
+import qs.config
+import Quickshell
+import Quickshell.Widgets
+import QtQuick
+
+Item {
+ id: root
+
+ required property PersistentProperties visibilities
+ required property Item panels
+ readonly property int padding: Appearance.padding.large
+
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ anchors.right: parent.right
+
+ implicitWidth: Config.notifs.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;
+
+ if (visibilities && panels) {
+ if (visibilities.osd) {
+ const h = panels.osd.y - Config.border.rounding * 2 - padding * 2;
+ if (height > h)
+ height = h;
+ }
+
+ if (visibilities.session) {
+ const h = panels.session.y - Config.border.rounding * 2 - padding * 2;
+ if (height > h)
+ height = h;
+ }
+ }
+
+ return Math.min((QsWindow.window?.screen?.height ?? 0) - Config.border.thickness * 2, height + padding * 2);
+ }
+
+ ClippingWrapperRectangle {
+ anchors.fill: parent
+ anchors.margins: root.padding
+
+ color: "transparent"
+ radius: Appearance.rounding.normal
+
+ StyledListView {
+ id: list
+
+ model: ScriptModel {
+ values: Notifs.popups.filter(n => !n.closed)
+ }
+
+ 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 ? Config.notifs.sizes.width : -Config.notifs.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"
+ }
+ }
+
+ ExtraIndicator {
+ anchors.top: parent.top
+ extra: {
+ const count = list.count;
+ if (count === 0)
+ return 0;
+
+ const scrollY = list.contentY;
+
+ let height = 0;
+ for (let i = 0; i < count; i++) {
+ height += (list.itemAtIndex(i)?.nonAnimHeight ?? 0) + Appearance.spacing.smaller;
+
+ if (height - Appearance.spacing.smaller >= scrollY)
+ return i;
+ }
+
+ return count;
+ }
+ }
+
+ ExtraIndicator {
+ anchors.bottom: parent.bottom
+ extra: {
+ const count = list.count;
+ if (count === 0)
+ return 0;
+
+ const scrollY = list.contentHeight - (list.contentY + list.height);
+
+ let height = 0;
+ for (let i = count - 1; i >= 0; i--) {
+ height += (list.itemAtIndex(i)?.nonAnimHeight ?? 0) + Appearance.spacing.smaller;
+
+ if (height - Appearance.spacing.smaller >= scrollY)
+ return count - i - 1;
+ }
+
+ return 0;
+ }
+ }
+ }
+ }
+
+ 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/.config/quickshell/caelestia/modules/notifications/Notification.qml b/.config/quickshell/caelestia/modules/notifications/Notification.qml
new file mode 100644
index 0000000..8c2d3ec
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/notifications/Notification.qml
@@ -0,0 +1,480 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.components.effects
+import qs.services
+import qs.config
+import qs.utils
+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: Config.notifs.openExpanded
+
+ color: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3secondaryContainer : Colours.tPalette.m3surfaceContainer
+ radius: Appearance.rounding.normal
+ implicitWidth: Config.notifs.sizes.width
+ implicitHeight: inner.implicitHeight
+
+ x: Config.notifs.sizes.width
+ Component.onCompleted: {
+ x = 0;
+ modelData.lock(this);
+ }
+ Component.onDestruction: modelData.unlock(this)
+
+ Behavior on x {
+ Anim {
+ easing.bezierCurve: Appearance.anim.curves.emphasizedDecel
+ }
+ }
+
+ MouseArea {
+ property int startY
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: root.expanded && body.hoveredLink ? Qt.PointingHandCursor : pressed ? Qt.ClosedHandCursor : undefined
+ acceptedButtons: Qt.LeftButton | Qt.MiddleButton
+ preventStealing: true
+
+ onEntered: root.modelData.timer.stop()
+ onExited: {
+ if (!pressed)
+ root.modelData.timer.start();
+ }
+
+ drag.target: parent
+ drag.axis: Drag.XAxis
+
+ onPressed: event => {
+ root.modelData.timer.stop();
+ startY = event.y;
+ if (event.button === Qt.MiddleButton)
+ root.modelData.close();
+ }
+ onReleased: event => {
+ if (!containsMouse)
+ root.modelData.timer.start();
+
+ if (Math.abs(root.x) < Config.notifs.sizes.width * Config.notifs.clearThreshold)
+ root.x = 0;
+ else
+ root.modelData.popup = false;
+ }
+ onPositionChanged: event => {
+ if (pressed) {
+ const diffY = event.y - startY;
+ if (Math.abs(diffY) > Config.notifs.expandThreshold)
+ root.expanded = diffY > 0;
+ }
+ }
+ onClicked: event => {
+ if (!Config.notifs.actionOnClick || event.button !== Qt.LeftButton)
+ return;
+
+ const actions = root.modelData.actions;
+ if (actions?.length === 1)
+ actions[0].invoke();
+ }
+
+ 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 {
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ }
+
+ Loader {
+ id: image
+
+ active: root.hasImage
+
+ anchors.left: parent.left
+ anchors.top: parent.top
+ width: Config.notifs.sizes.image
+ height: Config.notifs.sizes.image
+ visible: root.hasImage || root.hasAppIcon
+
+ sourceComponent: ClippingRectangle {
+ radius: Appearance.rounding.full
+ implicitWidth: Config.notifs.sizes.image
+ implicitHeight: Config.notifs.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
+
+ 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.layer(Colours.palette.m3surfaceContainerHighest, 2) : Colours.palette.m3secondaryContainer
+ implicitWidth: root.hasImage ? Config.notifs.sizes.badge : Config.notifs.sizes.image
+ implicitHeight: root.hasImage ? Config.notifs.sizes.badge : Config.notifs.sizes.image
+
+ Loader {
+ id: icon
+
+ active: root.hasAppIcon
+
+ anchors.centerIn: parent
+
+ width: Math.round(parent.width * 0.6)
+ height: Math.round(parent.width * 0.6)
+
+ sourceComponent: ColouredIcon {
+ anchors.fill: parent
+ source: Quickshell.iconPath(root.modelData.appIcon)
+ colour: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3onError : root.modelData.urgency === NotificationUrgency.Low ? Colours.palette.m3onSurface : Colours.palette.m3onSecondaryContainer
+ layer.enabled: root.modelData.appIcon.endsWith("symbolic")
+ }
+ }
+
+ Loader {
+ active: !root.hasAppIcon
+ anchors.centerIn: parent
+ anchors.horizontalCenterOffset: -Appearance.font.size.large * 0.02
+ anchors.verticalCenterOffset: Appearance.font.size.large * 0.02
+
+ sourceComponent: MaterialIcon {
+ text: Icons.getNotifIcon(root.modelData.summary, root.modelData.urgency)
+
+ color: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3onError : root.modelData.urgency === NotificationUrgency.Low ? Colours.palette.m3onSurface : Colours.palette.m3onSecondaryContainer
+ 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
+ color: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface
+
+ 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
+ height: text ? implicitHeight : 0
+
+ onLinkActivated: link => {
+ if (!root.expanded)
+ return;
+
+ Quickshell.execDetached(["app2unit", "-O", "--", link]);
+ root.modelData.popup = false;
+ }
+
+ 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 {}
+ }
+
+ Action {
+ modelData: QtObject {
+ readonly property string text: qsTr("Close")
+ function invoke(): void {
+ root.modelData.close();
+ }
+ }
+ }
+
+ Repeater {
+ model: root.modelData.actions
+
+ delegate: Component {
+ Action {}
+ }
+ }
+ }
+ }
+ }
+
+ component Action: StyledRect {
+ id: action
+
+ required property var modelData
+
+ radius: Appearance.rounding.full
+ color: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3secondary : Colours.layer(Colours.palette.m3surfaceContainerHigh, 2)
+
+ 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
+ color: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3onSecondary : Colours.palette.m3onSurface
+
+ function onClicked(): void {
+ action.modelData.invoke();
+ }
+ }
+
+ StyledText {
+ id: actionText
+
+ anchors.centerIn: parent
+ text: actionTextMetrics.elidedText
+ color: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3onSecondary : Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.small
+ }
+
+ TextMetrics {
+ id: actionTextMetrics
+
+ text: action.modelData.text
+ font.family: actionText.font.family
+ font.pointSize: actionText.font.pointSize
+ elide: Text.ElideRight
+ elideWidth: {
+ const numActions = root.modelData.actions.length + 1;
+ return (inner.width - actions.spacing * (numActions - 1)) / numActions - Appearance.padding.normal * 2;
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/notifications/Wrapper.qml b/.config/quickshell/caelestia/modules/notifications/Wrapper.qml
new file mode 100644
index 0000000..61acc56
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/notifications/Wrapper.qml
@@ -0,0 +1,39 @@
+import qs.components
+import qs.config
+import QtQuick
+
+Item {
+ id: root
+
+ required property var visibilities
+ required property Item panels
+
+ visible: height > 0
+ implicitWidth: Math.max(panels.sidebar.width, content.implicitWidth)
+ implicitHeight: content.implicitHeight
+
+ states: State {
+ name: "hidden"
+ when: root.visibilities.sidebar && Config.sidebar.enabled
+
+ PropertyChanges {
+ root.implicitHeight: 0
+ }
+ }
+
+ transitions: Transition {
+ Anim {
+ target: root
+ property: "implicitHeight"
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ }
+
+ Content {
+ id: content
+
+ visibilities: root.visibilities
+ panels: root.panels
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/osd/Background.qml b/.config/quickshell/caelestia/modules/osd/Background.qml
new file mode 100644
index 0000000..78955c7
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/osd/Background.qml
@@ -0,0 +1,60 @@
+import qs.components
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Shapes
+
+ShapePath {
+ id: root
+
+ required property Wrapper wrapper
+ readonly property real rounding: Config.border.rounding
+ readonly property bool flatten: wrapper.width < rounding * 2
+ readonly property real roundingX: flatten ? wrapper.width / 2 : rounding
+
+ strokeWidth: -1
+ fillColor: Colours.palette.m3surface
+
+ 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 {
+ CAnim {}
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/osd/Content.qml b/.config/quickshell/caelestia/modules/osd/Content.qml
new file mode 100644
index 0000000..770fb69
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/osd/Content.qml
@@ -0,0 +1,127 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.components.controls
+import qs.services
+import qs.config
+import qs.utils
+import QtQuick
+import QtQuick.Layouts
+
+Item {
+ id: root
+
+ required property Brightness.Monitor monitor
+ required property var visibilities
+
+ required property real volume
+ required property bool muted
+ required property real sourceVolume
+ required property bool sourceMuted
+ required property real brightness
+
+ implicitWidth: layout.implicitWidth + Appearance.padding.large * 2
+ implicitHeight: layout.implicitHeight + Appearance.padding.large * 2
+
+ ColumnLayout {
+ id: layout
+
+ anchors.centerIn: parent
+ spacing: Appearance.spacing.normal
+
+ // Speaker volume
+ CustomMouseArea {
+ implicitWidth: Config.osd.sizes.sliderWidth
+ implicitHeight: Config.osd.sizes.sliderHeight
+
+ function onWheel(event: WheelEvent) {
+ if (event.angleDelta.y > 0)
+ Audio.incrementVolume();
+ else if (event.angleDelta.y < 0)
+ Audio.decrementVolume();
+ }
+
+ FilledSlider {
+ anchors.fill: parent
+
+ icon: Icons.getVolumeIcon(value, root.muted)
+ value: root.volume
+ to: Config.services.maxVolume
+ onMoved: Audio.setVolume(value)
+ }
+ }
+
+ // Microphone volume
+ WrappedLoader {
+ shouldBeActive: Config.osd.enableMicrophone && (!Config.osd.enableBrightness || !root.visibilities.session)
+
+ sourceComponent: CustomMouseArea {
+ implicitWidth: Config.osd.sizes.sliderWidth
+ implicitHeight: Config.osd.sizes.sliderHeight
+
+ function onWheel(event: WheelEvent) {
+ if (event.angleDelta.y > 0)
+ Audio.incrementSourceVolume();
+ else if (event.angleDelta.y < 0)
+ Audio.decrementSourceVolume();
+ }
+
+ FilledSlider {
+ anchors.fill: parent
+
+ icon: Icons.getMicVolumeIcon(value, root.sourceMuted)
+ value: root.sourceVolume
+ to: Config.services.maxVolume
+ onMoved: Audio.setSourceVolume(value)
+ }
+ }
+ }
+
+ // Brightness
+ WrappedLoader {
+ shouldBeActive: Config.osd.enableBrightness
+
+ sourceComponent: CustomMouseArea {
+ implicitWidth: Config.osd.sizes.sliderWidth
+ implicitHeight: Config.osd.sizes.sliderHeight
+
+ function onWheel(event: WheelEvent) {
+ const monitor = root.monitor;
+ if (!monitor)
+ return;
+ if (event.angleDelta.y > 0)
+ monitor.setBrightness(monitor.brightness + Config.services.brightnessIncrement);
+ else if (event.angleDelta.y < 0)
+ monitor.setBrightness(monitor.brightness - Config.services.brightnessIncrement);
+ }
+
+ FilledSlider {
+ anchors.fill: parent
+
+ icon: `brightness_${(Math.round(value * 6) + 1)}`
+ value: root.brightness
+ onMoved: root.monitor?.setBrightness(value)
+ }
+ }
+ }
+ }
+
+ component WrappedLoader: Loader {
+ required property bool shouldBeActive
+
+ Layout.preferredHeight: shouldBeActive ? Config.osd.sizes.sliderHeight : 0
+ opacity: shouldBeActive ? 1 : 0
+ active: opacity > 0
+ visible: active
+
+ Behavior on Layout.preferredHeight {
+ Anim {
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+
+ Behavior on opacity {
+ Anim {}
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/osd/Wrapper.qml b/.config/quickshell/caelestia/modules/osd/Wrapper.qml
new file mode 100644
index 0000000..2519609
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/osd/Wrapper.qml
@@ -0,0 +1,134 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.services
+import qs.config
+import Quickshell
+import QtQuick
+
+Item {
+ id: root
+
+ required property ShellScreen screen
+ required property var visibilities
+ property bool hovered
+ readonly property Brightness.Monitor monitor: Brightness.getMonitorForScreen(root.screen)
+ readonly property bool shouldBeActive: visibilities.osd && Config.osd.enabled && !(visibilities.utilities && Config.utilities.enabled)
+
+ property real volume
+ property bool muted
+ property real sourceVolume
+ property bool sourceMuted
+ property real brightness
+
+ function show(): void {
+ visibilities.osd = true;
+ timer.restart();
+ }
+
+ Component.onCompleted: {
+ volume = Audio.volume;
+ muted = Audio.muted;
+ sourceVolume = Audio.sourceVolume;
+ sourceMuted = Audio.sourceMuted;
+ brightness = root.monitor?.brightness ?? 0;
+ }
+
+ visible: width > 0
+ implicitWidth: 0
+ implicitHeight: content.implicitHeight
+
+ states: State {
+ name: "visible"
+ when: root.shouldBeActive
+
+ PropertyChanges {
+ root.implicitWidth: content.implicitWidth
+ }
+ }
+
+ transitions: [
+ Transition {
+ from: ""
+ to: "visible"
+
+ Anim {
+ target: root
+ property: "implicitWidth"
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ },
+ Transition {
+ from: "visible"
+ to: ""
+
+ Anim {
+ target: root
+ property: "implicitWidth"
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+ ]
+
+ Connections {
+ target: Audio
+
+ function onMutedChanged(): void {
+ root.show();
+ root.muted = Audio.muted;
+ }
+
+ function onVolumeChanged(): void {
+ root.show();
+ root.volume = Audio.volume;
+ }
+
+ function onSourceMutedChanged(): void {
+ root.show();
+ root.sourceMuted = Audio.sourceMuted;
+ }
+
+ function onSourceVolumeChanged(): void {
+ root.show();
+ root.sourceVolume = Audio.sourceVolume;
+ }
+ }
+
+ Connections {
+ target: root.monitor
+
+ function onBrightnessChanged(): void {
+ root.show();
+ root.brightness = root.monitor?.brightness ?? 0;
+ }
+ }
+
+ Timer {
+ id: timer
+
+ interval: Config.osd.hideDelay
+ onTriggered: {
+ if (!root.hovered)
+ root.visibilities.osd = false;
+ }
+ }
+
+ Loader {
+ id: content
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: parent.left
+
+ Component.onCompleted: active = Qt.binding(() => root.shouldBeActive || root.visible)
+
+ sourceComponent: Content {
+ monitor: root.monitor
+ visibilities: root.visibilities
+ volume: root.volume
+ muted: root.muted
+ sourceVolume: root.sourceVolume
+ sourceMuted: root.sourceMuted
+ brightness: root.brightness
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/session/Background.qml b/.config/quickshell/caelestia/modules/session/Background.qml
new file mode 100644
index 0000000..78955c7
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/session/Background.qml
@@ -0,0 +1,60 @@
+import qs.components
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Shapes
+
+ShapePath {
+ id: root
+
+ required property Wrapper wrapper
+ readonly property real rounding: Config.border.rounding
+ readonly property bool flatten: wrapper.width < rounding * 2
+ readonly property real roundingX: flatten ? wrapper.width / 2 : rounding
+
+ strokeWidth: -1
+ fillColor: Colours.palette.m3surface
+
+ 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 {
+ CAnim {}
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/session/Content.qml b/.config/quickshell/caelestia/modules/session/Content.qml
new file mode 100644
index 0000000..45152e2
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/session/Content.qml
@@ -0,0 +1,135 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.services
+import qs.config
+import qs.utils
+import Quickshell
+import QtQuick
+
+Column {
+ id: root
+
+ required property PersistentProperties visibilities
+
+ padding: Appearance.padding.large
+ spacing: Appearance.spacing.large
+
+ SessionButton {
+ id: logout
+
+ icon: Config.session.icons.logout
+ command: Config.session.commands.logout
+
+ KeyNavigation.down: shutdown
+
+ Component.onCompleted: forceActiveFocus()
+
+ Connections {
+ target: root.visibilities
+
+ function onLauncherChanged(): void {
+ if (!root.visibilities.launcher)
+ logout.forceActiveFocus();
+ }
+ }
+ }
+
+ SessionButton {
+ id: shutdown
+
+ icon: Config.session.icons.shutdown
+ command: Config.session.commands.shutdown
+
+ KeyNavigation.up: logout
+ KeyNavigation.down: hibernate
+ }
+
+ AnimatedImage {
+ width: Config.session.sizes.button
+ height: Config.session.sizes.button
+ sourceSize.width: width
+ sourceSize.height: height
+
+ playing: visible
+ asynchronous: true
+ speed: Appearance.anim.sessionGifSpeed
+ source: Paths.absolutePath(Config.paths.sessionGif)
+ }
+
+ SessionButton {
+ id: hibernate
+
+ icon: Config.session.icons.hibernate
+ command: Config.session.commands.hibernate
+
+ KeyNavigation.up: shutdown
+ KeyNavigation.down: reboot
+ }
+
+ SessionButton {
+ id: reboot
+
+ icon: Config.session.icons.reboot
+ command: Config.session.commands.reboot
+
+ KeyNavigation.up: hibernate
+ }
+
+ component SessionButton: StyledRect {
+ id: button
+
+ required property string icon
+ required property list command
+
+ implicitWidth: Config.session.sizes.button
+ implicitHeight: Config.session.sizes.button
+
+ radius: Appearance.rounding.large
+ color: button.activeFocus ? Colours.palette.m3secondaryContainer : Colours.tPalette.m3surfaceContainer
+
+ Keys.onEnterPressed: Quickshell.execDetached(button.command)
+ Keys.onReturnPressed: Quickshell.execDetached(button.command)
+ Keys.onEscapePressed: root.visibilities.session = false
+ Keys.onPressed: event => {
+ if (!Config.session.vimKeybinds)
+ return;
+
+ if (event.modifiers & Qt.ControlModifier) {
+ if (event.key === Qt.Key_J && KeyNavigation.down) {
+ KeyNavigation.down.focus = true;
+ event.accepted = true;
+ } else if (event.key === Qt.Key_K && KeyNavigation.up) {
+ KeyNavigation.up.focus = true;
+ event.accepted = true;
+ }
+ } else if (event.key === Qt.Key_Tab && KeyNavigation.down) {
+ KeyNavigation.down.focus = true;
+ event.accepted = true;
+ } else if (event.key === Qt.Key_Backtab || (event.key === Qt.Key_Tab && (event.modifiers & Qt.ShiftModifier))) {
+ if (KeyNavigation.up) {
+ KeyNavigation.up.focus = true;
+ event.accepted = true;
+ }
+ }
+ }
+
+ StateLayer {
+ radius: parent.radius
+ color: button.activeFocus ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface
+
+ function onClicked(): void {
+ Quickshell.execDetached(button.command);
+ }
+ }
+
+ MaterialIcon {
+ anchors.centerIn: parent
+
+ text: button.icon
+ color: button.activeFocus ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface
+ font.pointSize: Appearance.font.size.extraLarge
+ font.weight: 500
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/session/Wrapper.qml b/.config/quickshell/caelestia/modules/session/Wrapper.qml
new file mode 100644
index 0000000..14b03a8
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/session/Wrapper.qml
@@ -0,0 +1,63 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.config
+import Quickshell
+import QtQuick
+
+Item {
+ id: root
+
+ required property PersistentProperties visibilities
+ required property var panels
+ readonly property real nonAnimWidth: content.implicitWidth
+
+ visible: width > 0
+ implicitWidth: 0
+ implicitHeight: content.implicitHeight
+
+ states: State {
+ name: "visible"
+ when: root.visibilities.session && Config.session.enabled
+
+ PropertyChanges {
+ root.implicitWidth: root.nonAnimWidth
+ }
+ }
+
+ transitions: [
+ Transition {
+ from: ""
+ to: "visible"
+
+ Anim {
+ target: root
+ property: "implicitWidth"
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ },
+ Transition {
+ from: "visible"
+ to: ""
+
+ Anim {
+ target: root
+ property: "implicitWidth"
+ easing.bezierCurve: root.panels.osd.width > 0 ? Appearance.anim.curves.expressiveDefaultSpatial : Appearance.anim.curves.emphasized
+ }
+ }
+ ]
+
+ Loader {
+ id: content
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: parent.left
+
+ Component.onCompleted: active = Qt.binding(() => (root.visibilities.session && Config.session.enabled) || root.visible)
+
+ sourceComponent: Content {
+ visibilities: root.visibilities
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/sidebar/Background.qml b/.config/quickshell/caelestia/modules/sidebar/Background.qml
new file mode 100644
index 0000000..beefdf5
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/sidebar/Background.qml
@@ -0,0 +1,52 @@
+import qs.components
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Shapes
+
+ShapePath {
+ id: root
+
+ required property Wrapper wrapper
+ required property var panels
+
+ readonly property real rounding: Config.border.rounding
+
+ readonly property real notifsWidthDiff: panels.notifications.width - wrapper.width
+ readonly property real notifsRoundingX: panels.notifications.height > 0 && notifsWidthDiff < rounding * 2 ? notifsWidthDiff / 2 : rounding
+
+ readonly property real utilsWidthDiff: panels.utilities.width - wrapper.width
+ readonly property real utilsRoundingX: utilsWidthDiff < rounding * 2 ? utilsWidthDiff / 2 : rounding
+
+ strokeWidth: -1
+ fillColor: Colours.palette.m3surface
+
+ PathLine {
+ relativeX: -root.wrapper.width - root.notifsRoundingX
+ relativeY: 0
+ }
+ PathArc {
+ relativeX: root.notifsRoundingX
+ relativeY: root.rounding
+ radiusX: root.notifsRoundingX
+ radiusY: root.rounding
+ }
+ PathLine {
+ relativeX: 0
+ relativeY: root.wrapper.height - root.rounding * 2
+ }
+ PathArc {
+ relativeX: -root.utilsRoundingX
+ relativeY: root.rounding
+ radiusX: root.utilsRoundingX
+ radiusY: root.rounding
+ }
+ PathLine {
+ relativeX: root.wrapper.width + root.utilsRoundingX
+ relativeY: 0
+ }
+
+ Behavior on fillColor {
+ CAnim {}
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/sidebar/Content.qml b/.config/quickshell/caelestia/modules/sidebar/Content.qml
new file mode 100644
index 0000000..1b7feed
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/sidebar/Content.qml
@@ -0,0 +1,40 @@
+import qs.components
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+Item {
+ id: root
+
+ required property Props props
+ required property var visibilities
+
+ ColumnLayout {
+ id: layout
+
+ anchors.fill: parent
+ spacing: Appearance.spacing.normal
+
+ StyledRect {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+
+ radius: Appearance.rounding.normal
+ color: Colours.tPalette.m3surfaceContainerLow
+
+ NotifDock {
+ props: root.props
+ visibilities: root.visibilities
+ }
+ }
+
+ StyledRect {
+ Layout.topMargin: Appearance.padding.large - layout.spacing
+ Layout.fillWidth: true
+ implicitHeight: 1
+
+ color: Colours.tPalette.m3outlineVariant
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/sidebar/Notif.qml b/.config/quickshell/caelestia/modules/sidebar/Notif.qml
new file mode 100644
index 0000000..5a31764
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/sidebar/Notif.qml
@@ -0,0 +1,164 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.services
+import qs.config
+import Quickshell
+import QtQuick
+import QtQuick.Layouts
+
+StyledRect {
+ id: root
+
+ required property Notifs.Notif modelData
+ required property Props props
+ required property bool expanded
+ required property var visibilities
+
+ readonly property StyledText body: expandedContent.item?.body ?? null
+ readonly property real nonAnimHeight: expanded ? summary.implicitHeight + expandedContent.implicitHeight + expandedContent.anchors.topMargin + Appearance.padding.normal * 2 : summaryHeightMetrics.height
+
+ implicitHeight: nonAnimHeight
+
+ radius: Appearance.rounding.small
+ color: {
+ const c = root.modelData.urgency === "critical" ? Colours.palette.m3secondaryContainer : Colours.layer(Colours.palette.m3surfaceContainerHigh, 2);
+ return expanded ? c : Qt.alpha(c, 0);
+ }
+
+ states: State {
+ name: "expanded"
+ when: root.expanded
+
+ PropertyChanges {
+ summary.anchors.margins: Appearance.padding.normal
+ dummySummary.anchors.margins: Appearance.padding.normal
+ compactBody.anchors.margins: Appearance.padding.normal
+ timeStr.anchors.margins: Appearance.padding.normal
+ expandedContent.anchors.margins: Appearance.padding.normal
+ summary.width: root.width - Appearance.padding.normal * 2 - timeStr.implicitWidth - Appearance.spacing.small
+ summary.maximumLineCount: Number.MAX_SAFE_INTEGER
+ }
+ }
+
+ transitions: Transition {
+ Anim {
+ properties: "margins,width,maximumLineCount"
+ }
+ }
+
+ TextMetrics {
+ id: summaryHeightMetrics
+
+ font: summary.font
+ text: " " // Use this height to prevent weird characters from changing the line height
+ }
+
+ StyledText {
+ id: summary
+
+ anchors.top: parent.top
+ anchors.left: parent.left
+
+ width: parent.width
+ text: root.modelData.summary
+ color: root.modelData.urgency === "critical" ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface
+ elide: Text.ElideRight
+ wrapMode: Text.WordWrap
+ maximumLineCount: 1
+ }
+
+ StyledText {
+ id: dummySummary
+
+ anchors.top: parent.top
+ anchors.left: parent.left
+
+ visible: false
+ text: root.modelData.summary
+ }
+
+ WrappedLoader {
+ id: compactBody
+
+ shouldBeActive: !root.expanded
+ anchors.top: parent.top
+ anchors.left: dummySummary.right
+ anchors.right: parent.right
+ anchors.leftMargin: Appearance.spacing.small
+
+ sourceComponent: StyledText {
+ text: root.modelData.body.replace(/\n/g, " ")
+ color: root.modelData.urgency === "critical" ? Colours.palette.m3secondary : Colours.palette.m3outline
+ elide: Text.ElideRight
+ }
+ }
+
+ WrappedLoader {
+ id: timeStr
+
+ shouldBeActive: root.expanded
+ anchors.top: parent.top
+ anchors.right: parent.right
+
+ sourceComponent: StyledText {
+ animate: true
+ text: root.modelData.timeStr
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ }
+ }
+
+ WrappedLoader {
+ id: expandedContent
+
+ shouldBeActive: root.expanded
+ anchors.top: summary.bottom
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.topMargin: Appearance.spacing.small / 2
+
+ sourceComponent: ColumnLayout {
+ readonly property alias body: body
+
+ spacing: Appearance.spacing.smaller
+
+ StyledText {
+ id: body
+
+ Layout.fillWidth: true
+ textFormat: Text.MarkdownText
+ text: root.modelData.body.replace(/(.)\n(?!\n)/g, "$1\n\n") || qsTr("No body here! :/")
+ color: root.modelData.urgency === "critical" ? Colours.palette.m3secondary : Colours.palette.m3outline
+ wrapMode: Text.WordWrap
+
+ onLinkActivated: link => {
+ Quickshell.execDetached(["app2unit", "-O", "--", link]);
+ root.visibilities.sidebar = false;
+ }
+ }
+
+ NotifActionList {
+ notif: root.modelData
+ }
+ }
+ }
+
+ Behavior on implicitHeight {
+ Anim {
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ }
+
+ component WrappedLoader: Loader {
+ required property bool shouldBeActive
+
+ opacity: shouldBeActive ? 1 : 0
+ active: opacity > 0
+
+ Behavior on opacity {
+ Anim {}
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/sidebar/NotifActionList.qml b/.config/quickshell/caelestia/modules/sidebar/NotifActionList.qml
new file mode 100644
index 0000000..d1f1e1f
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/sidebar/NotifActionList.qml
@@ -0,0 +1,200 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.components.containers
+import qs.components.effects
+import qs.services
+import qs.config
+import Quickshell
+import Quickshell.Widgets
+import QtQuick
+import QtQuick.Layouts
+
+Item {
+ id: root
+
+ required property Notifs.Notif notif
+
+ Layout.fillWidth: true
+ implicitHeight: flickable.contentHeight
+
+ layer.enabled: true
+ layer.smooth: true
+ layer.effect: OpacityMask {
+ maskSource: gradientMask
+ }
+
+ Item {
+ id: gradientMask
+
+ anchors.fill: parent
+ layer.enabled: true
+ visible: false
+
+ Rectangle {
+ anchors.fill: parent
+
+ gradient: Gradient {
+ orientation: Gradient.Horizontal
+
+ GradientStop {
+ position: 0
+ color: Qt.rgba(0, 0, 0, 0)
+ }
+ GradientStop {
+ position: 0.1
+ color: Qt.rgba(0, 0, 0, 1)
+ }
+ GradientStop {
+ position: 0.9
+ color: Qt.rgba(0, 0, 0, 1)
+ }
+ GradientStop {
+ position: 1
+ color: Qt.rgba(0, 0, 0, 0)
+ }
+ }
+ }
+
+ Rectangle {
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ anchors.left: parent.left
+
+ implicitWidth: parent.width / 2
+ opacity: flickable.contentX > 0 ? 0 : 1
+
+ Behavior on opacity {
+ Anim {}
+ }
+ }
+
+ Rectangle {
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ anchors.right: parent.right
+
+ implicitWidth: parent.width / 2
+ opacity: flickable.contentX < flickable.contentWidth - parent.width ? 0 : 1
+
+ Behavior on opacity {
+ Anim {}
+ }
+ }
+ }
+
+ StyledFlickable {
+ id: flickable
+
+ anchors.fill: parent
+ contentWidth: Math.max(width, actionList.implicitWidth)
+ contentHeight: actionList.implicitHeight
+
+ RowLayout {
+ id: actionList
+
+ anchors.fill: parent
+ spacing: Appearance.spacing.small
+
+ Repeater {
+ model: [
+ {
+ isClose: true
+ },
+ ...root.notif.actions,
+ {
+ isCopy: true
+ }
+ ]
+
+ StyledRect {
+ id: action
+
+ required property var modelData
+
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ implicitWidth: actionInner.implicitWidth + Appearance.padding.normal * 2
+ implicitHeight: actionInner.implicitHeight + Appearance.padding.small * 2
+
+ Layout.preferredWidth: implicitWidth + (actionStateLayer.pressed ? Appearance.padding.large : 0)
+ radius: actionStateLayer.pressed ? Appearance.rounding.small / 2 : Appearance.rounding.small
+ color: Colours.layer(Colours.palette.m3surfaceContainerHighest, 4)
+
+ Timer {
+ id: copyTimer
+
+ interval: 3000
+ onTriggered: actionInner.item.text = "content_copy"
+ }
+
+ StateLayer {
+ id: actionStateLayer
+
+ function onClicked(): void {
+ if (action.modelData.isClose) {
+ root.notif.close();
+ } else if (action.modelData.isCopy) {
+ Quickshell.clipboardText = root.notif.body;
+ actionInner.item.text = "inventory";
+ copyTimer.start();
+ } else if (action.modelData.invoke) {
+ action.modelData.invoke();
+ } else if (!root.notif.resident) {
+ root.notif.close();
+ }
+ }
+ }
+
+ Loader {
+ id: actionInner
+
+ anchors.centerIn: parent
+ sourceComponent: action.modelData.isClose || action.modelData.isCopy ? iconBtn : root.notif.hasActionIcons ? iconComp : textComp
+ }
+
+ Component {
+ id: iconBtn
+
+ MaterialIcon {
+ animate: action.modelData.isCopy ?? false
+ text: action.modelData.isCopy ? "content_copy" : "close"
+ color: Colours.palette.m3onSurfaceVariant
+ }
+ }
+
+ Component {
+ id: iconComp
+
+ IconImage {
+ source: Quickshell.iconPath(action.modelData.identifier)
+ }
+ }
+
+ Component {
+ id: textComp
+
+ StyledText {
+ text: action.modelData.text
+ color: Colours.palette.m3onSurfaceVariant
+ }
+ }
+
+ Behavior on Layout.preferredWidth {
+ Anim {
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
+ }
+ }
+
+ Behavior on radius {
+ Anim {
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/sidebar/NotifDock.qml b/.config/quickshell/caelestia/modules/sidebar/NotifDock.qml
new file mode 100644
index 0000000..d039d15
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/sidebar/NotifDock.qml
@@ -0,0 +1,207 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.components.controls
+import qs.components.containers
+import qs.components.effects
+import qs.services
+import qs.config
+import Quickshell
+import Quickshell.Widgets
+import QtQuick
+import QtQuick.Layouts
+
+Item {
+ id: root
+
+ required property Props props
+ required property var visibilities
+ readonly property int notifCount: Notifs.list.reduce((acc, n) => n.closed ? acc : acc + 1, 0)
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.normal
+
+ Component.onCompleted: Notifs.list.forEach(n => n.popup = false)
+
+ Item {
+ id: title
+
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.margins: Appearance.padding.small
+
+ implicitHeight: Math.max(count.implicitHeight, titleText.implicitHeight)
+
+ StyledText {
+ id: count
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: parent.left
+ anchors.leftMargin: root.notifCount > 0 ? 0 : -width - titleText.anchors.leftMargin
+ opacity: root.notifCount > 0 ? 1 : 0
+
+ text: root.notifCount
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.normal
+ font.family: Appearance.font.family.mono
+ font.weight: 500
+
+ Behavior on anchors.leftMargin {
+ Anim {}
+ }
+
+ Behavior on opacity {
+ Anim {}
+ }
+ }
+
+ StyledText {
+ id: titleText
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: count.right
+ anchors.right: parent.right
+ anchors.leftMargin: Appearance.spacing.small
+
+ text: root.notifCount > 0 ? qsTr("notification%1").arg(root.notifCount === 1 ? "" : "s") : qsTr("Notifications")
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.normal
+ font.family: Appearance.font.family.mono
+ font.weight: 500
+ elide: Text.ElideRight
+ }
+ }
+
+ ClippingRectangle {
+ id: clipRect
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: title.bottom
+ anchors.bottom: parent.bottom
+ anchors.topMargin: Appearance.spacing.smaller
+
+ radius: Appearance.rounding.small
+ color: "transparent"
+
+ Loader {
+ anchors.centerIn: parent
+ active: opacity > 0
+ opacity: root.notifCount > 0 ? 0 : 1
+
+ sourceComponent: ColumnLayout {
+ spacing: Appearance.spacing.large
+
+ Image {
+ asynchronous: true
+ source: Qt.resolvedUrl(`${Quickshell.shellDir}/assets/dino.png`)
+ fillMode: Image.PreserveAspectFit
+ sourceSize.width: clipRect.width * 0.8
+
+ layer.enabled: true
+ layer.effect: Colouriser {
+ colorizationColor: Colours.palette.m3outlineVariant
+ brightness: 1
+ }
+ }
+
+ StyledText {
+ Layout.alignment: Qt.AlignHCenter
+ text: qsTr("No Notifications")
+ color: Colours.palette.m3outlineVariant
+ font.pointSize: Appearance.font.size.large
+ font.family: Appearance.font.family.mono
+ font.weight: 500
+ }
+ }
+
+ Behavior on opacity {
+ Anim {
+ duration: Appearance.anim.durations.extraLarge
+ }
+ }
+ }
+
+ StyledFlickable {
+ id: view
+
+ anchors.fill: parent
+
+ flickableDirection: Flickable.VerticalFlick
+ contentWidth: width
+ contentHeight: notifList.implicitHeight
+
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: view
+ }
+
+ NotifDockList {
+ id: notifList
+
+ props: root.props
+ visibilities: root.visibilities
+ container: view
+ }
+ }
+ }
+
+ Timer {
+ id: clearTimer
+
+ repeat: true
+ interval: 50
+ onTriggered: {
+ let next = null;
+ for (let i = 0; i < notifList.repeater.count; i++) {
+ next = notifList.repeater.itemAt(i);
+ if (!next?.closed)
+ break;
+ }
+ if (next)
+ next.closeAll();
+ else
+ stop();
+ }
+ }
+
+ Loader {
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ anchors.margins: Appearance.padding.normal
+
+ scale: root.notifCount > 0 ? 1 : 0.5
+ opacity: root.notifCount > 0 ? 1 : 0
+ active: opacity > 0
+
+ sourceComponent: IconButton {
+ id: clearBtn
+
+ icon: "clear_all"
+ radius: Appearance.rounding.normal
+ padding: Appearance.padding.normal
+ font.pointSize: Math.round(Appearance.font.size.large * 1.2)
+ onClicked: clearTimer.start()
+
+ Elevation {
+ anchors.fill: parent
+ radius: parent.radius
+ z: -1
+ level: clearBtn.stateLayer.containsMouse ? 4 : 3
+ }
+ }
+
+ Behavior on scale {
+ Anim {
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
+ }
+ }
+
+ Behavior on opacity {
+ Anim {
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/sidebar/NotifDockList.qml b/.config/quickshell/caelestia/modules/sidebar/NotifDockList.qml
new file mode 100644
index 0000000..b927e91
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/sidebar/NotifDockList.qml
@@ -0,0 +1,167 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.services
+import qs.config
+import Quickshell
+import QtQuick
+
+Item {
+ id: root
+
+ required property Props props
+ required property Flickable container
+ required property var visibilities
+
+ readonly property alias repeater: repeater
+ readonly property int spacing: Appearance.spacing.small
+ property bool flag
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ implicitHeight: {
+ const item = repeater.itemAt(repeater.count - 1);
+ return item ? item.y + item.implicitHeight : 0;
+ }
+
+ Repeater {
+ id: repeater
+
+ model: ScriptModel {
+ values: {
+ const map = new Map();
+ for (const n of Notifs.notClosed)
+ map.set(n.appName, null);
+ for (const n of Notifs.list)
+ map.set(n.appName, null);
+ return [...map.keys()];
+ }
+ onValuesChanged: root.flagChanged()
+ }
+
+ MouseArea {
+ id: notif
+
+ required property int index
+ required property string modelData
+
+ readonly property bool closed: notifInner.notifCount === 0
+ readonly property alias nonAnimHeight: notifInner.nonAnimHeight
+ property int startY
+
+ function closeAll(): void {
+ for (const n of Notifs.notClosed.filter(n => n.appName === modelData))
+ n.close();
+ }
+
+ y: {
+ root.flag; // Force update
+ let y = 0;
+ for (let i = 0; i < index; i++) {
+ const item = repeater.itemAt(i);
+ if (!item.closed)
+ y += item.nonAnimHeight + root.spacing;
+ }
+ return y;
+ }
+
+ containmentMask: QtObject {
+ function contains(p: point): bool {
+ if (!root.container.contains(notif.mapToItem(root.container, p)))
+ return false;
+ return notifInner.contains(p);
+ }
+ }
+
+ implicitWidth: root.width
+ implicitHeight: notifInner.implicitHeight
+
+ hoverEnabled: true
+ cursorShape: pressed ? Qt.ClosedHandCursor : undefined
+ acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
+ preventStealing: true
+ enabled: !closed
+
+ drag.target: this
+ drag.axis: Drag.XAxis
+
+ onPressed: event => {
+ startY = event.y;
+ if (event.button === Qt.RightButton)
+ notifInner.toggleExpand(!notifInner.expanded);
+ else if (event.button === Qt.MiddleButton)
+ closeAll();
+ }
+ onPositionChanged: event => {
+ if (pressed) {
+ const diffY = event.y - startY;
+ if (Math.abs(diffY) > Config.notifs.expandThreshold)
+ notifInner.toggleExpand(diffY > 0);
+ }
+ }
+ onReleased: event => {
+ if (Math.abs(x) < width * Config.notifs.clearThreshold)
+ x = 0;
+ else
+ closeAll();
+ }
+
+ ParallelAnimation {
+ running: true
+
+ Anim {
+ target: notif
+ property: "opacity"
+ from: 0
+ to: 1
+ }
+ Anim {
+ target: notif
+ property: "scale"
+ from: 0
+ to: 1
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ }
+
+ ParallelAnimation {
+ running: notif.closed
+
+ Anim {
+ target: notif
+ property: "opacity"
+ to: 0
+ }
+ Anim {
+ target: notif
+ property: "scale"
+ to: 0.6
+ }
+ }
+
+ NotifGroup {
+ id: notifInner
+
+ modelData: notif.modelData
+ props: root.props
+ container: root.container
+ visibilities: root.visibilities
+ }
+
+ Behavior on x {
+ Anim {
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ }
+
+ Behavior on y {
+ Anim {
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/sidebar/NotifGroup.qml b/.config/quickshell/caelestia/modules/sidebar/NotifGroup.qml
new file mode 100644
index 0000000..16aac33
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/sidebar/NotifGroup.qml
@@ -0,0 +1,241 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.components.effects
+import qs.services
+import qs.config
+import qs.utils
+import Quickshell
+import Quickshell.Services.Notifications
+import QtQuick
+import QtQuick.Layouts
+
+StyledRect {
+ id: root
+
+ required property string modelData
+ required property Props props
+ required property Flickable container
+ required property var visibilities
+
+ readonly property list notifs: Notifs.list.filter(n => n.appName === modelData)
+ readonly property int notifCount: notifs.reduce((acc, n) => n.closed ? acc : acc + 1, 0)
+ readonly property string image: notifs.find(n => !n.closed && n.image.length > 0)?.image ?? ""
+ readonly property string appIcon: notifs.find(n => !n.closed && n.appIcon.length > 0)?.appIcon ?? ""
+ readonly property int urgency: notifs.some(n => !n.closed && n.urgency === NotificationUrgency.Critical) ? NotificationUrgency.Critical : notifs.some(n => n.urgency === NotificationUrgency.Normal) ? NotificationUrgency.Normal : NotificationUrgency.Low
+
+ readonly property int nonAnimHeight: {
+ const headerHeight = header.implicitHeight + (root.expanded ? Math.round(Appearance.spacing.small / 2) : 0);
+ const columnHeight = headerHeight + notifList.nonAnimHeight + column.Layout.topMargin + column.Layout.bottomMargin;
+ return Math.round(Math.max(Config.notifs.sizes.image, columnHeight) + Appearance.padding.normal * 2);
+ }
+ readonly property bool expanded: props.expandedNotifs.includes(modelData)
+
+ function toggleExpand(expand: bool): void {
+ if (expand) {
+ if (!expanded)
+ props.expandedNotifs.push(modelData);
+ } else if (expanded) {
+ props.expandedNotifs.splice(props.expandedNotifs.indexOf(modelData), 1);
+ }
+ }
+
+ Component.onDestruction: {
+ if (notifCount === 0 && expanded)
+ props.expandedNotifs.splice(props.expandedNotifs.indexOf(modelData), 1);
+ }
+
+ anchors.left: parent?.left
+ anchors.right: parent?.right
+ implicitHeight: content.implicitHeight + Appearance.padding.normal * 2
+
+ clip: true
+ radius: Appearance.rounding.normal
+ color: Colours.layer(Colours.palette.m3surfaceContainer, 2)
+
+ RowLayout {
+ id: content
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.margins: Appearance.padding.normal
+
+ spacing: Appearance.spacing.normal
+
+ Item {
+ Layout.alignment: Qt.AlignLeft | Qt.AlignTop
+ implicitWidth: Config.notifs.sizes.image
+ implicitHeight: Config.notifs.sizes.image
+
+ Component {
+ id: imageComp
+
+ Image {
+ source: Qt.resolvedUrl(root.image)
+ fillMode: Image.PreserveAspectCrop
+ cache: false
+ asynchronous: true
+ width: Config.notifs.sizes.image
+ height: Config.notifs.sizes.image
+ }
+ }
+
+ Component {
+ id: appIconComp
+
+ ColouredIcon {
+ implicitSize: Math.round(Config.notifs.sizes.image * 0.6)
+ source: Quickshell.iconPath(root.appIcon)
+ colour: root.urgency === NotificationUrgency.Critical ? Colours.palette.m3onError : root.urgency === NotificationUrgency.Low ? Colours.palette.m3onSurface : Colours.palette.m3onSecondaryContainer
+ layer.enabled: root.appIcon.endsWith("symbolic")
+ }
+ }
+
+ Component {
+ id: materialIconComp
+
+ MaterialIcon {
+ text: Icons.getNotifIcon(root.notifs[0]?.summary, root.urgency)
+ color: root.urgency === NotificationUrgency.Critical ? Colours.palette.m3onError : root.urgency === NotificationUrgency.Low ? Colours.palette.m3onSurface : Colours.palette.m3onSecondaryContainer
+ font.pointSize: Appearance.font.size.large
+ }
+ }
+
+ StyledClippingRect {
+ anchors.fill: parent
+ color: root.urgency === NotificationUrgency.Critical ? Colours.palette.m3error : root.urgency === NotificationUrgency.Low ? Colours.layer(Colours.palette.m3surfaceContainerHigh, 3) : Colours.palette.m3secondaryContainer
+ radius: Appearance.rounding.full
+
+ Loader {
+ anchors.centerIn: parent
+ sourceComponent: root.image ? imageComp : root.appIcon ? appIconComp : materialIconComp
+ }
+ }
+
+ Loader {
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ active: root.appIcon && root.image
+
+ sourceComponent: StyledRect {
+ implicitWidth: Config.notifs.sizes.badge
+ implicitHeight: Config.notifs.sizes.badge
+
+ color: root.urgency === NotificationUrgency.Critical ? Colours.palette.m3error : root.urgency === NotificationUrgency.Low ? Colours.palette.m3surfaceContainerHigh : Colours.palette.m3secondaryContainer
+ radius: Appearance.rounding.full
+
+ ColouredIcon {
+ anchors.centerIn: parent
+ implicitSize: Math.round(Config.notifs.sizes.badge * 0.6)
+ source: Quickshell.iconPath(root.appIcon)
+ colour: root.urgency === NotificationUrgency.Critical ? Colours.palette.m3onError : root.urgency === NotificationUrgency.Low ? Colours.palette.m3onSurface : Colours.palette.m3onSecondaryContainer
+ layer.enabled: root.appIcon.endsWith("symbolic")
+ }
+ }
+ }
+ }
+
+ ColumnLayout {
+ id: column
+
+ Layout.topMargin: -Appearance.padding.small
+ Layout.bottomMargin: -Appearance.padding.small / 2
+ Layout.fillWidth: true
+ spacing: 0
+
+ RowLayout {
+ id: header
+
+ Layout.bottomMargin: root.expanded ? Math.round(Appearance.spacing.small / 2) : 0
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.smaller
+
+ StyledText {
+ Layout.fillWidth: true
+ text: root.modelData
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.small
+ elide: Text.ElideRight
+ }
+
+ StyledText {
+ animate: true
+ text: root.notifs.find(n => !n.closed)?.timeStr ?? ""
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ }
+
+ StyledRect {
+ implicitWidth: expandBtn.implicitWidth + Appearance.padding.smaller * 2
+ implicitHeight: groupCount.implicitHeight + Appearance.padding.small
+
+ color: root.urgency === NotificationUrgency.Critical ? Colours.palette.m3error : Colours.layer(Colours.palette.m3surfaceContainerHigh, 3)
+ radius: Appearance.rounding.full
+
+ StateLayer {
+ color: root.urgency === NotificationUrgency.Critical ? Colours.palette.m3onError : Colours.palette.m3onSurface
+
+ function onClicked(): void {
+ root.toggleExpand(!root.expanded);
+ }
+ }
+
+ RowLayout {
+ id: expandBtn
+
+ anchors.centerIn: parent
+ spacing: Appearance.spacing.small / 2
+
+ StyledText {
+ id: groupCount
+
+ Layout.leftMargin: Appearance.padding.small / 2
+ animate: true
+ text: root.notifCount
+ color: root.urgency === NotificationUrgency.Critical ? Colours.palette.m3onError : Colours.palette.m3onSurface
+ font.pointSize: Appearance.font.size.small
+ }
+
+ MaterialIcon {
+ Layout.rightMargin: -Appearance.padding.small / 2
+ text: "expand_more"
+ color: root.urgency === NotificationUrgency.Critical ? Colours.palette.m3onError : Colours.palette.m3onSurface
+ rotation: root.expanded ? 180 : 0
+ Layout.topMargin: root.expanded ? -Math.floor(Appearance.padding.smaller / 2) : 0
+
+ Behavior on rotation {
+ Anim {
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ }
+
+ Behavior on Layout.topMargin {
+ Anim {
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ }
+ }
+ }
+ }
+
+ Behavior on Layout.bottomMargin {
+ Anim {}
+ }
+ }
+
+ NotifGroupList {
+ id: notifList
+
+ props: root.props
+ notifs: root.notifs
+ expanded: root.expanded
+ container: root.container
+ visibilities: root.visibilities
+ onRequestToggleExpand: expand => root.toggleExpand(expand)
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/sidebar/NotifGroupList.qml b/.config/quickshell/caelestia/modules/sidebar/NotifGroupList.qml
new file mode 100644
index 0000000..e586b5f
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/sidebar/NotifGroupList.qml
@@ -0,0 +1,213 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.services
+import qs.config
+import Quickshell
+import QtQuick
+import QtQuick.Layouts
+
+Item {
+ id: root
+
+ required property Props props
+ required property list notifs
+ required property bool expanded
+ required property Flickable container
+ required property var visibilities
+
+ readonly property real nonAnimHeight: {
+ let h = -root.spacing;
+ for (let i = 0; i < repeater.count; i++) {
+ const item = repeater.itemAt(i);
+ if (!item.modelData.closed && !item.previewHidden)
+ h += item.nonAnimHeight + root.spacing;
+ }
+ return h;
+ }
+
+ readonly property int spacing: Math.round(Appearance.spacing.small / 2)
+ property bool showAllNotifs
+ property bool flag
+
+ signal requestToggleExpand(expand: bool)
+
+ onExpandedChanged: {
+ if (expanded) {
+ clearTimer.stop();
+ showAllNotifs = true;
+ } else {
+ clearTimer.start();
+ }
+ }
+
+ Layout.fillWidth: true
+ implicitHeight: nonAnimHeight
+
+ Timer {
+ id: clearTimer
+
+ interval: Appearance.anim.durations.normal
+ onTriggered: root.showAllNotifs = false
+ }
+
+ Repeater {
+ id: repeater
+
+ model: ScriptModel {
+ values: root.showAllNotifs ? root.notifs : root.notifs.slice(0, Config.notifs.groupPreviewNum + 1)
+ onValuesChanged: root.flagChanged()
+ }
+
+ MouseArea {
+ id: notif
+
+ required property int index
+ required property Notifs.Notif modelData
+
+ readonly property alias nonAnimHeight: notifInner.nonAnimHeight
+ readonly property bool previewHidden: {
+ if (root.expanded)
+ return false;
+
+ let extraHidden = 0;
+ for (let i = 0; i < index; i++)
+ if (root.notifs[i].closed)
+ extraHidden++;
+
+ return index >= Config.notifs.groupPreviewNum + extraHidden;
+ }
+ property int startY
+
+ y: {
+ root.flag; // Force update
+ let y = 0;
+ for (let i = 0; i < index; i++) {
+ const item = repeater.itemAt(i);
+ if (!item.modelData.closed && !item.previewHidden)
+ y += item.nonAnimHeight + root.spacing;
+ }
+ return y;
+ }
+
+ containmentMask: QtObject {
+ function contains(p: point): bool {
+ if (!root.container.contains(notif.mapToItem(root.container, p)))
+ return false;
+ return notifInner.contains(p);
+ }
+ }
+
+ opacity: previewHidden ? 0 : 1
+ scale: previewHidden ? 0.7 : 1
+
+ implicitWidth: root.width
+ implicitHeight: notifInner.implicitHeight
+
+ hoverEnabled: true
+ cursorShape: notifInner.body?.hoveredLink ? Qt.PointingHandCursor : pressed ? Qt.ClosedHandCursor : undefined
+ acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
+ preventStealing: !root.expanded
+ enabled: !modelData.closed
+
+ drag.target: this
+ drag.axis: Drag.XAxis
+
+ onPressed: event => {
+ startY = event.y;
+ if (event.button === Qt.RightButton)
+ root.requestToggleExpand(!root.expanded);
+ else if (event.button === Qt.MiddleButton)
+ modelData.close();
+ }
+ onPositionChanged: event => {
+ if (pressed && !root.expanded) {
+ const diffY = event.y - startY;
+ if (Math.abs(diffY) > Config.notifs.expandThreshold)
+ root.requestToggleExpand(diffY > 0);
+ }
+ }
+ onReleased: event => {
+ if (Math.abs(x) < width * Config.notifs.clearThreshold)
+ x = 0;
+ else
+ modelData.close();
+ }
+
+ Component.onCompleted: modelData.lock(this)
+ Component.onDestruction: modelData.unlock(this)
+
+ ParallelAnimation {
+ Component.onCompleted: running = !notif.previewHidden
+
+ Anim {
+ target: notif
+ property: "opacity"
+ from: 0
+ to: 1
+ }
+ Anim {
+ target: notif
+ property: "scale"
+ from: 0.7
+ to: 1
+ }
+ }
+
+ ParallelAnimation {
+ running: notif.modelData.closed
+ onFinished: notif.modelData.unlock(notif)
+
+ Anim {
+ target: notif
+ property: "opacity"
+ to: 0
+ }
+ Anim {
+ target: notif
+ property: "x"
+ to: notif.x >= 0 ? notif.width : -notif.width
+ }
+ }
+
+ Notif {
+ id: notifInner
+
+ anchors.fill: parent
+ modelData: notif.modelData
+ props: root.props
+ expanded: root.expanded
+ visibilities: root.visibilities
+ }
+
+ Behavior on opacity {
+ Anim {}
+ }
+
+ Behavior on scale {
+ Anim {}
+ }
+
+ Behavior on x {
+ Anim {
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ }
+
+ Behavior on y {
+ Anim {
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ }
+ }
+ }
+
+ Behavior on implicitHeight {
+ Anim {
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/sidebar/Props.qml b/.config/quickshell/caelestia/modules/sidebar/Props.qml
new file mode 100644
index 0000000..4613942
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/sidebar/Props.qml
@@ -0,0 +1,7 @@
+import Quickshell
+
+PersistentProperties {
+ property list expandedNotifs: []
+
+ reloadableId: "sidebar"
+}
diff --git a/.config/quickshell/caelestia/modules/sidebar/Wrapper.qml b/.config/quickshell/caelestia/modules/sidebar/Wrapper.qml
new file mode 100644
index 0000000..9303c6b
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/sidebar/Wrapper.qml
@@ -0,0 +1,68 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.config
+import QtQuick
+
+Item {
+ id: root
+
+ required property var visibilities
+ required property var panels
+ readonly property Props props: Props {}
+
+ visible: width > 0
+ implicitWidth: 0
+
+ states: State {
+ name: "visible"
+ when: root.visibilities.sidebar && Config.sidebar.enabled
+
+ PropertyChanges {
+ root.implicitWidth: Config.sidebar.sizes.width
+ }
+ }
+
+ transitions: [
+ Transition {
+ from: ""
+ to: "visible"
+
+ Anim {
+ target: root
+ property: "implicitWidth"
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ },
+ Transition {
+ from: "visible"
+ to: ""
+
+ Anim {
+ target: root
+ property: "implicitWidth"
+ easing.bezierCurve: root.panels.osd.width > 0 || root.panels.session.width > 0 ? Appearance.anim.curves.expressiveDefaultSpatial : Appearance.anim.curves.emphasized
+ }
+ }
+ ]
+
+ Loader {
+ id: content
+
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ anchors.left: parent.left
+ anchors.margins: Appearance.padding.large
+ anchors.bottomMargin: 0
+
+ active: true
+ Component.onCompleted: active = Qt.binding(() => (root.visibilities.sidebar && Config.sidebar.enabled) || root.visible)
+
+ sourceComponent: Content {
+ implicitWidth: Config.sidebar.sizes.width - Appearance.padding.large * 2
+ props: root.props
+ visibilities: root.visibilities
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/utilities/Background.qml b/.config/quickshell/caelestia/modules/utilities/Background.qml
new file mode 100644
index 0000000..fbce896
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/utilities/Background.qml
@@ -0,0 +1,55 @@
+import qs.components
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Shapes
+
+ShapePath {
+ id: root
+
+ required property Wrapper wrapper
+ required property var sidebar
+ readonly property real rounding: Config.border.rounding
+ readonly property bool flatten: wrapper.height < rounding * 2
+ readonly property real roundingY: flatten ? wrapper.height / 2 : rounding
+
+ strokeWidth: -1
+ fillColor: Colours.palette.m3surface
+
+ 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)
+ direction: PathArc.Counterclockwise
+ }
+ PathLine {
+ relativeX: 0
+ relativeY: -(root.wrapper.height - root.roundingY * 2)
+ }
+ PathArc {
+ relativeX: root.sidebar.utilsRoundingX
+ relativeY: -root.roundingY
+ radiusX: root.sidebar.utilsRoundingX
+ radiusY: Math.min(root.rounding, root.wrapper.height)
+ }
+ PathLine {
+ relativeX: root.wrapper.height > 0 ? root.wrapper.width - root.rounding - root.sidebar.utilsRoundingX : root.wrapper.width
+ relativeY: 0
+ }
+ PathArc {
+ relativeX: root.rounding
+ relativeY: -root.rounding
+ radiusX: root.rounding
+ radiusY: root.rounding
+ direction: PathArc.Counterclockwise
+ }
+
+ Behavior on fillColor {
+ CAnim {}
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/utilities/Content.qml b/.config/quickshell/caelestia/modules/utilities/Content.qml
new file mode 100644
index 0000000..902656d
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/utilities/Content.qml
@@ -0,0 +1,39 @@
+import "cards"
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+Item {
+ id: root
+
+ required property var props
+ required property var visibilities
+ required property Item popouts
+
+ implicitWidth: layout.implicitWidth
+ implicitHeight: layout.implicitHeight
+
+ ColumnLayout {
+ id: layout
+
+ anchors.fill: parent
+ spacing: Appearance.spacing.normal
+
+ IdleInhibit {}
+
+ Record {
+ props: root.props
+ visibilities: root.visibilities
+ z: 1
+ }
+
+ Toggles {
+ visibilities: root.visibilities
+ popouts: root.popouts
+ }
+ }
+
+ RecordingDeleteModal {
+ props: root.props
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/utilities/RecordingDeleteModal.qml b/.config/quickshell/caelestia/modules/utilities/RecordingDeleteModal.qml
new file mode 100644
index 0000000..127afe9
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/utilities/RecordingDeleteModal.qml
@@ -0,0 +1,207 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.components.controls
+import qs.components.effects
+import qs.services
+import qs.config
+import Caelestia
+import QtQuick
+import QtQuick.Layouts
+import QtQuick.Shapes
+
+Loader {
+ id: root
+
+ required property var props
+
+ anchors.fill: parent
+
+ opacity: root.props.recordingConfirmDelete ? 1 : 0
+ active: opacity > 0
+
+ sourceComponent: MouseArea {
+ id: deleteConfirmation
+
+ property string path
+
+ Component.onCompleted: path = root.props.recordingConfirmDelete
+
+ hoverEnabled: true
+ onClicked: root.props.recordingConfirmDelete = ""
+
+ Item {
+ anchors.fill: parent
+ anchors.margins: -Appearance.padding.large
+ anchors.rightMargin: -Appearance.padding.large - Config.border.thickness
+ anchors.bottomMargin: -Appearance.padding.large - Config.border.thickness
+ opacity: 0.5
+
+ StyledRect {
+ anchors.fill: parent
+ topLeftRadius: Config.border.rounding
+ color: Colours.palette.m3scrim
+ }
+
+ Shape {
+ id: shape
+
+ anchors.fill: parent
+ preferredRendererType: Shape.CurveRenderer
+ asynchronous: true
+
+ ShapePath {
+ startX: -Config.border.rounding * 2
+ startY: shape.height - Config.border.thickness
+ strokeWidth: 0
+ fillGradient: LinearGradient {
+ orientation: LinearGradient.Horizontal
+ x1: -Config.border.rounding * 2
+
+ GradientStop {
+ position: 0
+ color: Qt.alpha(Colours.palette.m3scrim, 0)
+ }
+ GradientStop {
+ position: 1
+ color: Colours.palette.m3scrim
+ }
+ }
+
+ PathLine {
+ relativeX: Config.border.rounding
+ relativeY: 0
+ }
+ PathArc {
+ relativeY: -Config.border.rounding
+ radiusX: Config.border.rounding
+ radiusY: Config.border.rounding
+ direction: PathArc.Counterclockwise
+ }
+ PathLine {
+ relativeX: 0
+ relativeY: Config.border.rounding + Config.border.thickness
+ }
+ PathLine {
+ relativeX: -Config.border.rounding * 2
+ relativeY: 0
+ }
+ }
+
+ ShapePath {
+ startX: shape.width - Config.border.rounding - Config.border.thickness
+ strokeWidth: 0
+ fillGradient: LinearGradient {
+ orientation: LinearGradient.Vertical
+ y1: -Config.border.rounding * 2
+
+ GradientStop {
+ position: 0
+ color: Qt.alpha(Colours.palette.m3scrim, 0)
+ }
+ GradientStop {
+ position: 1
+ color: Colours.palette.m3scrim
+ }
+ }
+
+ PathArc {
+ relativeX: Config.border.rounding
+ relativeY: -Config.border.rounding
+ radiusX: Config.border.rounding
+ radiusY: Config.border.rounding
+ direction: PathArc.Counterclockwise
+ }
+ PathLine {
+ relativeX: 0
+ relativeY: -Config.border.rounding
+ }
+ PathLine {
+ relativeX: Config.border.thickness
+ relativeY: 0
+ }
+ PathLine {
+ relativeX: 0
+ }
+ }
+ }
+ }
+
+ StyledRect {
+ anchors.centerIn: parent
+ radius: Appearance.rounding.large
+ color: Colours.palette.m3surfaceContainerHigh
+
+ scale: 0
+ Component.onCompleted: scale = Qt.binding(() => root.props.recordingConfirmDelete ? 1 : 0)
+
+ width: Math.min(parent.width - Appearance.padding.large * 2, implicitWidth)
+ implicitWidth: deleteConfirmationLayout.implicitWidth + Appearance.padding.large * 3
+ implicitHeight: deleteConfirmationLayout.implicitHeight + Appearance.padding.large * 3
+
+ MouseArea {
+ anchors.fill: parent
+ }
+
+ Elevation {
+ anchors.fill: parent
+ radius: parent.radius
+ z: -1
+ level: 3
+ }
+
+ ColumnLayout {
+ id: deleteConfirmationLayout
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.large * 1.5
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ text: qsTr("Delete recording?")
+ font.pointSize: Appearance.font.size.large
+ }
+
+ StyledText {
+ Layout.fillWidth: true
+ text: qsTr("Recording '%1' will be permanently deleted.").arg(deleteConfirmation.path)
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.small
+ wrapMode: Text.WrapAtWordBoundaryOrAnywhere
+ }
+
+ RowLayout {
+ Layout.topMargin: Appearance.spacing.normal
+ Layout.alignment: Qt.AlignRight
+ spacing: Appearance.spacing.normal
+
+ TextButton {
+ text: qsTr("Cancel")
+ type: TextButton.Text
+ onClicked: root.props.recordingConfirmDelete = ""
+ }
+
+ TextButton {
+ text: qsTr("Delete")
+ type: TextButton.Text
+ onClicked: {
+ CUtils.deleteFile(Qt.resolvedUrl(root.props.recordingConfirmDelete));
+ root.props.recordingConfirmDelete = "";
+ }
+ }
+ }
+ }
+
+ Behavior on scale {
+ Anim {
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ }
+ }
+ }
+
+ Behavior on opacity {
+ Anim {}
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/utilities/Wrapper.qml b/.config/quickshell/caelestia/modules/utilities/Wrapper.qml
new file mode 100644
index 0000000..77178e3
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/utilities/Wrapper.qml
@@ -0,0 +1,96 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.config
+import Quickshell
+import QtQuick
+
+Item {
+ id: root
+
+ required property var visibilities
+ required property Item sidebar
+ required property Item popouts
+
+ readonly property PersistentProperties props: PersistentProperties {
+ property bool recordingListExpanded: false
+ property string recordingConfirmDelete
+ property string recordingMode
+
+ reloadableId: "utilities"
+ }
+ readonly property bool shouldBeActive: visibilities.sidebar || (visibilities.utilities && Config.utilities.enabled && !(visibilities.session && Config.session.enabled))
+
+ visible: height > 0
+ implicitHeight: 0
+ implicitWidth: sidebar.visible ? sidebar.width : Config.utilities.sizes.width
+
+ onStateChanged: {
+ if (state === "visible" && timer.running) {
+ timer.triggered();
+ timer.stop();
+ }
+ }
+
+ states: State {
+ name: "visible"
+ when: root.shouldBeActive
+
+ PropertyChanges {
+ root.implicitHeight: content.implicitHeight + Appearance.padding.large * 2
+ }
+ }
+
+ transitions: [
+ Transition {
+ from: ""
+ to: "visible"
+
+ Anim {
+ target: root
+ property: "implicitHeight"
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ },
+ Transition {
+ from: "visible"
+ to: ""
+
+ Anim {
+ target: root
+ property: "implicitHeight"
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+ ]
+
+ Timer {
+ id: timer
+
+ running: true
+ interval: Appearance.anim.durations.extraLarge
+ onTriggered: {
+ content.active = Qt.binding(() => root.shouldBeActive || root.visible);
+ content.visible = true;
+ }
+ }
+
+ Loader {
+ id: content
+
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.margins: Appearance.padding.large
+
+ visible: false
+ active: true
+
+ sourceComponent: Content {
+ implicitWidth: root.implicitWidth - Appearance.padding.large * 2
+ props: root.props
+ visibilities: root.visibilities
+ popouts: root.popouts
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/utilities/cards/IdleInhibit.qml b/.config/quickshell/caelestia/modules/utilities/cards/IdleInhibit.qml
new file mode 100644
index 0000000..0344e3a
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/utilities/cards/IdleInhibit.qml
@@ -0,0 +1,125 @@
+import qs.components
+import qs.components.controls
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+StyledRect {
+ id: root
+
+ Layout.fillWidth: true
+ implicitHeight: layout.implicitHeight + (IdleInhibitor.enabled ? activeChip.implicitHeight + activeChip.anchors.topMargin : 0) + Appearance.padding.large * 2
+
+ radius: Appearance.rounding.normal
+ color: Colours.tPalette.m3surfaceContainer
+ clip: true
+
+ RowLayout {
+ id: layout
+
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.margins: Appearance.padding.large
+ spacing: Appearance.spacing.normal
+
+ StyledRect {
+ implicitWidth: implicitHeight
+ implicitHeight: icon.implicitHeight + Appearance.padding.smaller * 2
+
+ radius: Appearance.rounding.full
+ color: IdleInhibitor.enabled ? Colours.palette.m3secondary : Colours.palette.m3secondaryContainer
+
+ MaterialIcon {
+ id: icon
+
+ anchors.centerIn: parent
+ text: "coffee"
+ color: IdleInhibitor.enabled ? Colours.palette.m3onSecondary : Colours.palette.m3onSecondaryContainer
+ font.pointSize: Appearance.font.size.large
+ }
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: 0
+
+ StyledText {
+ Layout.fillWidth: true
+ text: qsTr("Keep Awake")
+ font.pointSize: Appearance.font.size.normal
+ elide: Text.ElideRight
+ }
+
+ StyledText {
+ Layout.fillWidth: true
+ text: IdleInhibitor.enabled ? qsTr("Preventing sleep mode") : qsTr("Normal power management")
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.small
+ elide: Text.ElideRight
+ }
+ }
+
+ StyledSwitch {
+ checked: IdleInhibitor.enabled
+ onToggled: IdleInhibitor.enabled = checked
+ }
+ }
+
+ Loader {
+ id: activeChip
+
+ anchors.bottom: parent.bottom
+ anchors.left: parent.left
+ anchors.topMargin: Appearance.spacing.larger
+ anchors.bottomMargin: IdleInhibitor.enabled ? Appearance.padding.large : -implicitHeight
+ anchors.leftMargin: Appearance.padding.large
+
+ opacity: IdleInhibitor.enabled ? 1 : 0
+ scale: IdleInhibitor.enabled ? 1 : 0.5
+
+ Component.onCompleted: active = Qt.binding(() => opacity > 0)
+
+ sourceComponent: StyledRect {
+ implicitWidth: activeText.implicitWidth + Appearance.padding.normal * 2
+ implicitHeight: activeText.implicitHeight + Appearance.padding.small * 2
+
+ radius: Appearance.rounding.full
+ color: Colours.palette.m3primary
+
+ StyledText {
+ id: activeText
+
+ anchors.centerIn: parent
+ text: qsTr("Active since %1").arg(Qt.formatTime(IdleInhibitor.enabledSince, Config.services.useTwelveHourClock ? "hh:mm a" : "hh:mm"))
+ color: Colours.palette.m3onPrimary
+ font.pointSize: Math.round(Appearance.font.size.small * 0.9)
+ }
+ }
+
+ Behavior on anchors.bottomMargin {
+ Anim {
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ }
+
+ Behavior on opacity {
+ Anim {
+ duration: Appearance.anim.durations.small
+ }
+ }
+
+ Behavior on scale {
+ Anim {}
+ }
+ }
+
+ Behavior on implicitHeight {
+ Anim {
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/utilities/cards/Record.qml b/.config/quickshell/caelestia/modules/utilities/cards/Record.qml
new file mode 100644
index 0000000..273c640
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/utilities/cards/Record.qml
@@ -0,0 +1,277 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.components.controls
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+StyledRect {
+ id: root
+
+ required property var props
+ required property var visibilities
+
+ Layout.fillWidth: true
+ implicitHeight: layout.implicitHeight + layout.anchors.margins * 2
+
+ radius: Appearance.rounding.normal
+ color: Colours.tPalette.m3surfaceContainer
+
+ ColumnLayout {
+ id: layout
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.large
+ spacing: Appearance.spacing.normal
+
+ RowLayout {
+ spacing: Appearance.spacing.normal
+ z: 1
+
+ StyledRect {
+ implicitWidth: implicitHeight
+ implicitHeight: {
+ const h = icon.implicitHeight + Appearance.padding.smaller * 2;
+ return h - (h % 2);
+ }
+
+ radius: Appearance.rounding.full
+ color: Recorder.running ? Colours.palette.m3secondary : Colours.palette.m3secondaryContainer
+
+ MaterialIcon {
+ id: icon
+
+ anchors.centerIn: parent
+ anchors.horizontalCenterOffset: -0.5
+ anchors.verticalCenterOffset: 1.5
+ text: "screen_record"
+ color: Recorder.running ? Colours.palette.m3onSecondary : Colours.palette.m3onSecondaryContainer
+ font.pointSize: Appearance.font.size.large
+ }
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: 0
+
+ StyledText {
+ Layout.fillWidth: true
+ text: qsTr("Screen Recorder")
+ font.pointSize: Appearance.font.size.normal
+ elide: Text.ElideRight
+ }
+
+ StyledText {
+ Layout.fillWidth: true
+ text: Recorder.paused ? qsTr("Recording paused") : Recorder.running ? qsTr("Recording running") : qsTr("Recording off")
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.small
+ elide: Text.ElideRight
+ }
+ }
+
+ SplitButton {
+ disabled: Recorder.running
+ active: menuItems.find(m => root.props.recordingMode === m.icon + m.text) ?? menuItems[0]
+ menu.onItemSelected: item => root.props.recordingMode = item.icon + item.text
+
+ menuItems: [
+ MenuItem {
+ icon: "fullscreen"
+ text: qsTr("Record fullscreen")
+ activeText: qsTr("Fullscreen")
+ onClicked: Recorder.start()
+ },
+ MenuItem {
+ icon: "screenshot_region"
+ text: qsTr("Record region")
+ activeText: qsTr("Region")
+ onClicked: Recorder.start(["-r"])
+ },
+ MenuItem {
+ icon: "select_to_speak"
+ text: qsTr("Record fullscreen with sound")
+ activeText: qsTr("Fullscreen")
+ onClicked: Recorder.start(["-s"])
+ },
+ MenuItem {
+ icon: "volume_up"
+ text: qsTr("Record region with sound")
+ activeText: qsTr("Region")
+ onClicked: Recorder.start(["-sr"])
+ }
+ ]
+ }
+ }
+
+ Loader {
+ id: listOrControls
+
+ property bool running: Recorder.running
+
+ Layout.fillWidth: true
+ Layout.preferredHeight: implicitHeight
+ sourceComponent: running ? recordingControls : recordingList
+
+ Behavior on Layout.preferredHeight {
+ id: locHeightAnim
+
+ enabled: false
+
+ Anim {}
+ }
+
+ Behavior on running {
+ SequentialAnimation {
+ ParallelAnimation {
+ Anim {
+ target: listOrControls
+ property: "scale"
+ to: 0.7
+ duration: Appearance.anim.durations.small
+ easing.bezierCurve: Appearance.anim.curves.standardAccel
+ }
+ Anim {
+ target: listOrControls
+ property: "opacity"
+ to: 0
+ duration: Appearance.anim.durations.small
+ easing.bezierCurve: Appearance.anim.curves.standardAccel
+ }
+ }
+ PropertyAction {
+ target: locHeightAnim
+ property: "enabled"
+ value: true
+ }
+ PropertyAction {}
+ PropertyAction {
+ target: locHeightAnim
+ property: "enabled"
+ value: false
+ }
+ ParallelAnimation {
+ Anim {
+ target: listOrControls
+ property: "scale"
+ to: 1
+ duration: Appearance.anim.durations.small
+ easing.bezierCurve: Appearance.anim.curves.standardDecel
+ }
+ Anim {
+ target: listOrControls
+ property: "opacity"
+ to: 1
+ duration: Appearance.anim.durations.small
+ easing.bezierCurve: Appearance.anim.curves.standardDecel
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Component {
+ id: recordingList
+
+ RecordingList {
+ props: root.props
+ visibilities: root.visibilities
+ }
+ }
+
+ Component {
+ id: recordingControls
+
+ RowLayout {
+ spacing: Appearance.spacing.normal
+
+ StyledRect {
+ radius: Appearance.rounding.full
+ color: Recorder.paused ? Colours.palette.m3tertiary : Colours.palette.m3error
+
+ implicitWidth: recText.implicitWidth + Appearance.padding.normal * 2
+ implicitHeight: recText.implicitHeight + Appearance.padding.smaller * 2
+
+ StyledText {
+ id: recText
+
+ anchors.centerIn: parent
+ animate: true
+ text: Recorder.paused ? "PAUSED" : "REC"
+ color: Recorder.paused ? Colours.palette.m3onTertiary : Colours.palette.m3onError
+ font.family: Appearance.font.family.mono
+ }
+
+ Behavior on implicitWidth {
+ Anim {}
+ }
+
+ SequentialAnimation on opacity {
+ running: !Recorder.paused
+ alwaysRunToEnd: true
+ loops: Animation.Infinite
+
+ Anim {
+ from: 1
+ to: 0
+ duration: Appearance.anim.durations.large
+ easing.bezierCurve: Appearance.anim.curves.emphasizedAccel
+ }
+ Anim {
+ from: 0
+ to: 1
+ duration: Appearance.anim.durations.extraLarge
+ easing.bezierCurve: Appearance.anim.curves.emphasizedDecel
+ }
+ }
+ }
+
+ StyledText {
+ text: {
+ const elapsed = Recorder.elapsed;
+
+ const hours = Math.floor(elapsed / 3600);
+ const mins = Math.floor((elapsed % 3600) / 60);
+ const secs = Math.floor(elapsed % 60).toString().padStart(2, "0");
+
+ let time;
+ if (hours > 0)
+ time = `${hours}:${mins.toString().padStart(2, "0")}:${secs}`;
+ else
+ time = `${mins}:${secs}`;
+
+ return qsTr("Recording for %1").arg(time);
+ }
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ IconButton {
+ label.animate: true
+ icon: Recorder.paused ? "play_arrow" : "pause"
+ toggle: true
+ checked: Recorder.paused
+ type: IconButton.Tonal
+ font.pointSize: Appearance.font.size.large
+ onClicked: {
+ Recorder.togglePause();
+ internalChecked = Recorder.paused;
+ }
+ }
+
+ IconButton {
+ icon: "stop"
+ inactiveColour: Colours.palette.m3error
+ inactiveOnColour: Colours.palette.m3onError
+ font.pointSize: Appearance.font.size.large
+ onClicked: Recorder.stop()
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/utilities/cards/RecordingList.qml b/.config/quickshell/caelestia/modules/utilities/cards/RecordingList.qml
new file mode 100644
index 0000000..b9d757a
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/utilities/cards/RecordingList.qml
@@ -0,0 +1,241 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.components.controls
+import qs.components.containers
+import qs.services
+import qs.config
+import qs.utils
+import Caelestia
+import Caelestia.Models
+import Quickshell
+import Quickshell.Widgets
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ required property var props
+ required property var visibilities
+
+ spacing: 0
+
+ WrapperMouseArea {
+ Layout.fillWidth: true
+
+ cursorShape: Qt.PointingHandCursor
+ onClicked: root.props.recordingListExpanded = !root.props.recordingListExpanded
+
+ RowLayout {
+ spacing: Appearance.spacing.smaller
+
+ MaterialIcon {
+ Layout.alignment: Qt.AlignVCenter
+ text: "list"
+ font.pointSize: Appearance.font.size.large
+ }
+
+ StyledText {
+ Layout.alignment: Qt.AlignVCenter
+ Layout.fillWidth: true
+ text: qsTr("Recordings")
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ IconButton {
+ icon: root.props.recordingListExpanded ? "unfold_less" : "unfold_more"
+ type: IconButton.Text
+ label.animate: true
+ onClicked: root.props.recordingListExpanded = !root.props.recordingListExpanded
+ }
+ }
+ }
+
+ StyledListView {
+ id: list
+
+ model: FileSystemModel {
+ path: Paths.recsdir
+ nameFilters: ["recording_*.mp4"]
+ sortReverse: true
+ }
+
+ Layout.fillWidth: true
+ Layout.rightMargin: -Appearance.spacing.small
+ implicitHeight: (Appearance.font.size.larger + Appearance.padding.small) * (root.props.recordingListExpanded ? 10 : 3)
+ clip: true
+
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: list
+ }
+
+ delegate: RowLayout {
+ id: recording
+
+ required property FileSystemEntry modelData
+ property string baseName
+
+ anchors.left: list.contentItem.left
+ anchors.right: list.contentItem.right
+ anchors.rightMargin: Appearance.spacing.small
+ spacing: Appearance.spacing.small / 2
+
+ Component.onCompleted: baseName = modelData.baseName
+
+ StyledText {
+ Layout.fillWidth: true
+ Layout.rightMargin: Appearance.spacing.small / 2
+ text: {
+ const time = recording.baseName;
+ const matches = time.match(/^recording_(\d{4})(\d{2})(\d{2})_(\d{2})-(\d{2})-(\d{2})/);
+ if (!matches)
+ return time;
+ const date = new Date(...matches.slice(1));
+ date.setMonth(date.getMonth() - 1); // Woe (months start from 0)
+ return qsTr("Recording at %1").arg(Qt.formatDateTime(date, Qt.locale()));
+ }
+ color: Colours.palette.m3onSurfaceVariant
+ elide: Text.ElideRight
+ }
+
+ IconButton {
+ icon: "play_arrow"
+ type: IconButton.Text
+ onClicked: {
+ root.visibilities.utilities = false;
+ root.visibilities.sidebar = false;
+ Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.playback, recording.modelData.path]);
+ }
+ }
+
+ IconButton {
+ icon: "folder"
+ type: IconButton.Text
+ onClicked: {
+ root.visibilities.utilities = false;
+ root.visibilities.sidebar = false;
+ Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.explorer, recording.modelData.path]);
+ }
+ }
+
+ IconButton {
+ icon: "delete_forever"
+ type: IconButton.Text
+ label.color: Colours.palette.m3error
+ stateLayer.color: Colours.palette.m3error
+ onClicked: root.props.recordingConfirmDelete = recording.modelData.path
+ }
+ }
+
+ add: Transition {
+ Anim {
+ property: "opacity"
+ from: 0
+ to: 1
+ }
+ Anim {
+ property: "scale"
+ from: 0.5
+ to: 1
+ }
+ }
+
+ remove: Transition {
+ Anim {
+ property: "opacity"
+ to: 0
+ }
+ Anim {
+ property: "scale"
+ to: 0.5
+ }
+ }
+
+ displaced: Transition {
+ Anim {
+ properties: "opacity,scale"
+ to: 1
+ }
+ Anim {
+ property: "y"
+ }
+ }
+
+ Loader {
+ anchors.centerIn: parent
+
+ opacity: list.count === 0 ? 1 : 0
+ active: opacity > 0
+
+ sourceComponent: ColumnLayout {
+ spacing: Appearance.spacing.small
+
+ MaterialIcon {
+ Layout.alignment: Qt.AlignHCenter
+ text: "scan_delete"
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.extraLarge
+
+ opacity: root.props.recordingListExpanded ? 1 : 0
+ scale: root.props.recordingListExpanded ? 1 : 0
+ Layout.preferredHeight: root.props.recordingListExpanded ? implicitHeight : 0
+
+ Behavior on opacity {
+ Anim {}
+ }
+
+ Behavior on scale {
+ Anim {}
+ }
+
+ Behavior on Layout.preferredHeight {
+ Anim {}
+ }
+ }
+
+ RowLayout {
+ spacing: Appearance.spacing.smaller
+
+ MaterialIcon {
+ Layout.alignment: Qt.AlignHCenter
+ text: "scan_delete"
+ color: Colours.palette.m3outline
+
+ opacity: !root.props.recordingListExpanded ? 1 : 0
+ scale: !root.props.recordingListExpanded ? 1 : 0
+ Layout.preferredWidth: !root.props.recordingListExpanded ? implicitWidth : 0
+
+ Behavior on opacity {
+ Anim {}
+ }
+
+ Behavior on scale {
+ Anim {}
+ }
+
+ Behavior on Layout.preferredWidth {
+ Anim {}
+ }
+ }
+
+ StyledText {
+ text: qsTr("No recordings found")
+ color: Colours.palette.m3outline
+ }
+ }
+ }
+
+ Behavior on opacity {
+ Anim {}
+ }
+ }
+
+ Behavior on implicitHeight {
+ Anim {
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/utilities/cards/Toggles.qml b/.config/quickshell/caelestia/modules/utilities/cards/Toggles.qml
new file mode 100644
index 0000000..dd4a687
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/utilities/cards/Toggles.qml
@@ -0,0 +1,113 @@
+import qs.components
+import qs.components.controls
+import qs.services
+import qs.config
+import qs.modules.controlcenter
+import Quickshell
+import Quickshell.Bluetooth
+import QtQuick
+import QtQuick.Layouts
+
+StyledRect {
+ id: root
+
+ required property var visibilities
+ required property Item popouts
+
+ Layout.fillWidth: true
+ implicitHeight: layout.implicitHeight + Appearance.padding.large * 2
+
+ radius: Appearance.rounding.normal
+ color: Colours.tPalette.m3surfaceContainer
+
+ ColumnLayout {
+ id: layout
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.large
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ text: qsTr("Quick Toggles")
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ RowLayout {
+ Layout.alignment: Qt.AlignHCenter
+ spacing: Appearance.spacing.small
+
+ Toggle {
+ icon: "wifi"
+ checked: Nmcli.wifiEnabled
+ onClicked: Nmcli.toggleWifi()
+ }
+
+ Toggle {
+ icon: "bluetooth"
+ checked: Bluetooth.defaultAdapter?.enabled ?? false
+ onClicked: {
+ const adapter = Bluetooth.defaultAdapter;
+ if (adapter)
+ adapter.enabled = !adapter.enabled;
+ }
+ }
+
+ Toggle {
+ icon: "mic"
+ checked: !Audio.sourceMuted
+ onClicked: {
+ const audio = Audio.source?.audio;
+ if (audio)
+ audio.muted = !audio.muted;
+ }
+ }
+
+ Toggle {
+ icon: "settings"
+ inactiveOnColour: Colours.palette.m3onSurfaceVariant
+ toggle: false
+ onClicked: {
+ root.visibilities.utilities = false;
+ root.popouts.detach("network");
+ }
+ }
+
+ Toggle {
+ icon: "gamepad"
+ checked: GameMode.enabled
+ onClicked: GameMode.enabled = !GameMode.enabled
+ }
+
+ Toggle {
+ icon: "notifications_off"
+ checked: Notifs.dnd
+ onClicked: Notifs.dnd = !Notifs.dnd
+ }
+
+ Toggle {
+ icon: "vpn_key"
+ checked: VPN.connected
+ enabled: !VPN.connecting
+ visible: Config.utilities.vpn.provider.some(p => typeof p === "object" ? (p.enabled === true) : false)
+ onClicked: VPN.toggle()
+ }
+ }
+ }
+
+ component Toggle: IconButton {
+ Layout.fillWidth: true
+ Layout.preferredWidth: implicitWidth + (stateLayer.pressed ? Appearance.padding.large : internalChecked ? Appearance.padding.smaller : 0)
+ radius: stateLayer.pressed ? Appearance.rounding.small / 2 : internalChecked ? Appearance.rounding.small : Appearance.rounding.normal
+ inactiveColour: Colours.layer(Colours.palette.m3surfaceContainerHighest, 2)
+ toggle: true
+ radiusAnim.duration: Appearance.anim.durations.expressiveFastSpatial
+ radiusAnim.easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
+
+ Behavior on Layout.preferredWidth {
+ Anim {
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/utilities/toasts/ToastItem.qml b/.config/quickshell/caelestia/modules/utilities/toasts/ToastItem.qml
new file mode 100644
index 0000000..f475500
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/utilities/toasts/ToastItem.qml
@@ -0,0 +1,135 @@
+import qs.components
+import qs.components.effects
+import qs.services
+import qs.config
+import Caelestia
+import QtQuick
+import QtQuick.Layouts
+
+StyledRect {
+ id: root
+
+ required property Toast modelData
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ implicitHeight: layout.implicitHeight + Appearance.padding.smaller * 2
+
+ radius: Appearance.rounding.normal
+ color: {
+ if (root.modelData.type === Toast.Success)
+ return Colours.palette.m3successContainer;
+ if (root.modelData.type === Toast.Warning)
+ return Colours.palette.m3secondary;
+ if (root.modelData.type === Toast.Error)
+ return Colours.palette.m3errorContainer;
+ return Colours.palette.m3surface;
+ }
+
+ border.width: 1
+ border.color: {
+ let colour = Colours.palette.m3outlineVariant;
+ if (root.modelData.type === Toast.Success)
+ colour = Colours.palette.m3success;
+ if (root.modelData.type === Toast.Warning)
+ colour = Colours.palette.m3secondaryContainer;
+ if (root.modelData.type === Toast.Error)
+ colour = Colours.palette.m3error;
+ return Qt.alpha(colour, 0.3);
+ }
+
+ Elevation {
+ anchors.fill: parent
+ radius: parent.radius
+ opacity: parent.opacity
+ z: -1
+ level: 3
+ }
+
+ RowLayout {
+ id: layout
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.smaller
+ anchors.leftMargin: Appearance.padding.normal
+ anchors.rightMargin: Appearance.padding.normal
+ spacing: Appearance.spacing.normal
+
+ StyledRect {
+ radius: Appearance.rounding.normal
+ color: {
+ if (root.modelData.type === Toast.Success)
+ return Colours.palette.m3success;
+ if (root.modelData.type === Toast.Warning)
+ return Colours.palette.m3secondaryContainer;
+ if (root.modelData.type === Toast.Error)
+ return Colours.palette.m3error;
+ return Colours.palette.m3surfaceContainerHigh;
+ }
+
+ implicitWidth: implicitHeight
+ implicitHeight: icon.implicitHeight + Appearance.padding.smaller * 2
+
+ MaterialIcon {
+ id: icon
+
+ anchors.centerIn: parent
+ text: root.modelData.icon
+ color: {
+ if (root.modelData.type === Toast.Success)
+ return Colours.palette.m3onSuccess;
+ if (root.modelData.type === Toast.Warning)
+ return Colours.palette.m3onSecondaryContainer;
+ if (root.modelData.type === Toast.Error)
+ return Colours.palette.m3onError;
+ return Colours.palette.m3onSurfaceVariant;
+ }
+ font.pointSize: Math.round(Appearance.font.size.large * 1.2)
+ }
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: 0
+
+ StyledText {
+ id: title
+
+ Layout.fillWidth: true
+ text: root.modelData.title
+ color: {
+ if (root.modelData.type === Toast.Success)
+ return Colours.palette.m3onSuccessContainer;
+ if (root.modelData.type === Toast.Warning)
+ return Colours.palette.m3onSecondary;
+ if (root.modelData.type === Toast.Error)
+ return Colours.palette.m3onErrorContainer;
+ return Colours.palette.m3onSurface;
+ }
+ font.pointSize: Appearance.font.size.normal
+ elide: Text.ElideRight
+ }
+
+ StyledText {
+ Layout.fillWidth: true
+ textFormat: Text.StyledText
+ text: root.modelData.message
+ color: {
+ if (root.modelData.type === Toast.Success)
+ return Colours.palette.m3onSuccessContainer;
+ if (root.modelData.type === Toast.Warning)
+ return Colours.palette.m3onSecondary;
+ if (root.modelData.type === Toast.Error)
+ return Colours.palette.m3onErrorContainer;
+ return Colours.palette.m3onSurface;
+ }
+ opacity: 0.8
+ elide: Text.ElideRight
+ }
+ }
+ }
+
+ Behavior on border.color {
+ CAnim {}
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/utilities/toasts/Toasts.qml b/.config/quickshell/caelestia/modules/utilities/toasts/Toasts.qml
new file mode 100644
index 0000000..2915404
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/utilities/toasts/Toasts.qml
@@ -0,0 +1,143 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.config
+import Caelestia
+import Quickshell
+import QtQuick
+
+Item {
+ id: root
+
+ readonly property int spacing: Appearance.spacing.small
+ property bool flag
+
+ implicitWidth: Config.utilities.sizes.toastWidth - Appearance.padding.normal * 2
+ implicitHeight: {
+ let h = -spacing;
+ for (let i = 0; i < repeater.count; i++) {
+ const item = repeater.itemAt(i) as ToastWrapper;
+ if (!item.modelData.closed && !item.previewHidden)
+ h += item.implicitHeight + spacing;
+ }
+ return h;
+ }
+
+ Repeater {
+ id: repeater
+
+ model: ScriptModel {
+ values: {
+ const toasts = [];
+ let count = 0;
+ for (const toast of Toaster.toasts) {
+ toasts.push(toast);
+ if (!toast.closed) {
+ count++;
+ if (count > Config.utilities.maxToasts)
+ break;
+ }
+ }
+ return toasts;
+ }
+ onValuesChanged: root.flagChanged()
+ }
+
+ ToastWrapper {}
+ }
+
+ component ToastWrapper: MouseArea {
+ id: toast
+
+ required property int index
+ required property Toast modelData
+
+ readonly property bool previewHidden: {
+ let extraHidden = 0;
+ for (let i = 0; i < index; i++)
+ if (Toaster.toasts[i].closed)
+ extraHidden++;
+ return index >= Config.utilities.maxToasts + extraHidden;
+ }
+
+ onPreviewHiddenChanged: {
+ if (initAnim.running && previewHidden)
+ initAnim.stop();
+ }
+
+ opacity: modelData.closed || previewHidden ? 0 : 1
+ scale: modelData.closed || previewHidden ? 0.7 : 1
+
+ anchors.bottomMargin: {
+ root.flag; // Force update
+ let y = 0;
+ for (let i = 0; i < index; i++) {
+ const item = repeater.itemAt(i) as ToastWrapper;
+ if (item && !item.modelData.closed && !item.previewHidden)
+ y += item.implicitHeight + root.spacing;
+ }
+ return y;
+ }
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ implicitHeight: toastInner.implicitHeight
+
+ acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton
+ onClicked: modelData.close()
+
+ Component.onCompleted: modelData.lock(this)
+
+ Anim {
+ id: initAnim
+
+ Component.onCompleted: running = !toast.previewHidden
+
+ target: toast
+ properties: "opacity,scale"
+ from: 0
+ to: 1
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+
+ ParallelAnimation {
+ running: toast.modelData.closed
+ onStarted: toast.anchors.bottomMargin = toast.anchors.bottomMargin
+ onFinished: toast.modelData.unlock(toast)
+
+ Anim {
+ target: toast
+ property: "opacity"
+ to: 0
+ }
+ Anim {
+ target: toast
+ property: "scale"
+ to: 0.7
+ }
+ }
+
+ ToastItem {
+ id: toastInner
+
+ modelData: toast.modelData
+ }
+
+ Behavior on opacity {
+ Anim {}
+ }
+
+ Behavior on scale {
+ Anim {}
+ }
+
+ Behavior on anchors.bottomMargin {
+ Anim {
+ duration: Appearance.anim.durations.expressiveDefaultSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/windowinfo/Buttons.qml b/.config/quickshell/caelestia/modules/windowinfo/Buttons.qml
new file mode 100644
index 0000000..89acfe6
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/windowinfo/Buttons.qml
@@ -0,0 +1,180 @@
+import qs.components
+import qs.services
+import qs.config
+import Quickshell.Widgets
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ required property var client
+ property bool moveToWsExpanded
+
+ anchors.fill: parent
+ spacing: Appearance.spacing.small
+
+ RowLayout {
+ Layout.topMargin: Appearance.padding.large
+ Layout.leftMargin: Appearance.padding.large
+ Layout.rightMargin: Appearance.padding.large
+
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ Layout.fillWidth: true
+ text: qsTr("Move to workspace")
+ elide: Text.ElideRight
+ }
+
+ StyledRect {
+ color: Colours.palette.m3primary
+ radius: Appearance.rounding.small
+
+ implicitWidth: moveToWsIcon.implicitWidth + Appearance.padding.small * 2
+ implicitHeight: moveToWsIcon.implicitHeight + Appearance.padding.small
+
+ StateLayer {
+ color: Colours.palette.m3onPrimary
+
+ function onClicked(): void {
+ root.moveToWsExpanded = !root.moveToWsExpanded;
+ }
+ }
+
+ MaterialIcon {
+ id: moveToWsIcon
+
+ anchors.centerIn: parent
+
+ animate: true
+ text: root.moveToWsExpanded ? "expand_more" : "keyboard_arrow_right"
+ color: Colours.palette.m3onPrimary
+ font.pointSize: Appearance.font.size.large
+ }
+ }
+ }
+
+ WrapperItem {
+ Layout.fillWidth: true
+ Layout.leftMargin: Appearance.padding.large * 2
+ Layout.rightMargin: Appearance.padding.large * 2
+
+ Layout.preferredHeight: root.moveToWsExpanded ? implicitHeight : 0
+ clip: true
+
+ topMargin: Appearance.spacing.normal
+ bottomMargin: Appearance.spacing.normal
+
+ GridLayout {
+ id: wsGrid
+
+ rowSpacing: Appearance.spacing.smaller
+ columnSpacing: Appearance.spacing.normal
+ columns: 5
+
+ Repeater {
+ model: 10
+
+ Button {
+ required property int index
+ readonly property int wsId: Math.floor((Hypr.activeWsId - 1) / 10) * 10 + index + 1
+ readonly property bool isCurrent: root.client?.workspace.id === wsId
+
+ color: isCurrent ? Colours.tPalette.m3surfaceContainerHighest : Colours.palette.m3tertiaryContainer
+ onColor: isCurrent ? Colours.palette.m3onSurface : Colours.palette.m3onTertiaryContainer
+ text: wsId
+ disabled: isCurrent
+
+ function onClicked(): void {
+ Hypr.dispatch(`movetoworkspace ${wsId},address:0x${root.client?.address}`);
+ }
+ }
+ }
+ }
+
+ Behavior on Layout.preferredHeight {
+ Anim {}
+ }
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ Layout.leftMargin: Appearance.padding.large
+ Layout.rightMargin: Appearance.padding.large
+ Layout.bottomMargin: Appearance.padding.large
+
+ spacing: root.client?.lastIpcObject.floating ? Appearance.spacing.normal : Appearance.spacing.small
+
+ Button {
+ color: Colours.palette.m3secondaryContainer
+ onColor: Colours.palette.m3onSecondaryContainer
+ text: root.client?.lastIpcObject.floating ? qsTr("Tile") : qsTr("Float")
+
+ function onClicked(): void {
+ Hypr.dispatch(`togglefloating address:0x${root.client?.address}`);
+ }
+ }
+
+ Loader {
+ active: root.client?.lastIpcObject.floating
+ Layout.fillWidth: active
+ Layout.leftMargin: active ? 0 : -parent.spacing
+ Layout.rightMargin: active ? 0 : -parent.spacing
+
+ sourceComponent: Button {
+ color: Colours.palette.m3secondaryContainer
+ onColor: Colours.palette.m3onSecondaryContainer
+ text: root.client?.lastIpcObject.pinned ? qsTr("Unpin") : qsTr("Pin")
+
+ function onClicked(): void {
+ Hypr.dispatch(`pin address:0x${root.client?.address}`);
+ }
+ }
+ }
+
+ Button {
+ color: Colours.palette.m3errorContainer
+ onColor: Colours.palette.m3onErrorContainer
+ text: qsTr("Kill")
+
+ function onClicked(): void {
+ Hypr.dispatch(`killwindow address:0x${root.client?.address}`);
+ }
+ }
+ }
+
+ component Button: StyledRect {
+ property color onColor: Colours.palette.m3onSurface
+ property alias disabled: stateLayer.disabled
+ property alias text: label.text
+
+ function onClicked(): void {
+ }
+
+ radius: Appearance.rounding.small
+
+ Layout.fillWidth: true
+ implicitHeight: label.implicitHeight + Appearance.padding.small * 2
+
+ StateLayer {
+ id: stateLayer
+
+ color: parent.onColor
+
+ function onClicked(): void {
+ parent.onClicked();
+ }
+ }
+
+ StyledText {
+ id: label
+
+ anchors.centerIn: parent
+
+ animate: true
+ color: parent.onColor
+ font.pointSize: Appearance.font.size.normal
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/windowinfo/Details.qml b/.config/quickshell/caelestia/modules/windowinfo/Details.qml
new file mode 100644
index 0000000..f9ee66a
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/windowinfo/Details.qml
@@ -0,0 +1,164 @@
+import qs.components
+import qs.services
+import qs.config
+import Quickshell.Hyprland
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ required property HyprlandToplevel client
+
+ anchors.fill: parent
+ spacing: Appearance.spacing.small
+
+ Label {
+ Layout.topMargin: Appearance.padding.large * 2
+
+ text: root.client?.title ?? qsTr("No active client")
+ wrapMode: Text.WrapAtWordBoundaryOrAnywhere
+
+ font.pointSize: Appearance.font.size.large
+ font.weight: 500
+ }
+
+ Label {
+ text: root.client?.lastIpcObject.class ?? qsTr("No active client")
+ color: Colours.palette.m3tertiary
+
+ font.pointSize: Appearance.font.size.larger
+ }
+
+ StyledRect {
+ Layout.fillWidth: true
+ Layout.preferredHeight: 1
+ Layout.leftMargin: Appearance.padding.large * 2
+ Layout.rightMargin: Appearance.padding.large * 2
+ Layout.topMargin: Appearance.spacing.normal
+ Layout.bottomMargin: Appearance.spacing.large
+
+ color: Colours.palette.m3secondary
+ }
+
+ Detail {
+ icon: "location_on"
+ text: qsTr("Address: %1").arg(`0x${root.client?.address}` ?? "unknown")
+ color: Colours.palette.m3primary
+ }
+
+ Detail {
+ icon: "location_searching"
+ text: qsTr("Position: %1, %2").arg(root.client?.lastIpcObject.at[0] ?? -1).arg(root.client?.lastIpcObject.at[1] ?? -1)
+ }
+
+ Detail {
+ icon: "resize"
+ text: qsTr("Size: %1 x %2").arg(root.client?.lastIpcObject.size[0] ?? -1).arg(root.client?.lastIpcObject.size[1] ?? -1)
+ color: Colours.palette.m3tertiary
+ }
+
+ Detail {
+ icon: "workspaces"
+ text: qsTr("Workspace: %1 (%2)").arg(root.client?.workspace.name ?? -1).arg(root.client?.workspace.id ?? -1)
+ color: Colours.palette.m3secondary
+ }
+
+ Detail {
+ icon: "desktop_windows"
+ text: {
+ const mon = root.client?.monitor;
+ if (mon)
+ return qsTr("Monitor: %1 (%2) at %3, %4").arg(mon.name).arg(mon.id).arg(mon.x).arg(mon.y);
+ return qsTr("Monitor: unknown");
+ }
+ }
+
+ Detail {
+ icon: "page_header"
+ text: qsTr("Initial title: %1").arg(root.client?.lastIpcObject.initialTitle ?? "unknown")
+ color: Colours.palette.m3tertiary
+ }
+
+ Detail {
+ icon: "category"
+ text: qsTr("Initial class: %1").arg(root.client?.lastIpcObject.initialClass ?? "unknown")
+ }
+
+ Detail {
+ icon: "account_tree"
+ text: qsTr("Process id: %1").arg(root.client?.lastIpcObject.pid ?? -1)
+ color: Colours.palette.m3primary
+ }
+
+ Detail {
+ icon: "picture_in_picture_center"
+ text: qsTr("Floating: %1").arg(root.client?.lastIpcObject.floating ? "yes" : "no")
+ color: Colours.palette.m3secondary
+ }
+
+ Detail {
+ icon: "gradient"
+ text: qsTr("Xwayland: %1").arg(root.client?.lastIpcObject.xwayland ? "yes" : "no")
+ }
+
+ Detail {
+ icon: "keep"
+ text: qsTr("Pinned: %1").arg(root.client?.lastIpcObject.pinned ? "yes" : "no")
+ color: Colours.palette.m3secondary
+ }
+
+ Detail {
+ icon: "fullscreen"
+ text: {
+ const fs = root.client?.lastIpcObject.fullscreen;
+ if (fs)
+ return qsTr("Fullscreen state: %1").arg(fs == 0 ? "off" : fs == 1 ? "maximised" : "on");
+ return qsTr("Fullscreen state: unknown");
+ }
+ color: Colours.palette.m3tertiary
+ }
+
+ Item {
+ Layout.fillHeight: true
+ }
+
+ component Detail: RowLayout {
+ id: detail
+
+ required property string icon
+ required property string text
+ property alias color: icon.color
+
+ Layout.leftMargin: Appearance.padding.large
+ Layout.rightMargin: Appearance.padding.large
+ Layout.fillWidth: true
+
+ spacing: Appearance.spacing.smaller
+
+ MaterialIcon {
+ id: icon
+
+ Layout.alignment: Qt.AlignVCenter
+ text: detail.icon
+ }
+
+ StyledText {
+ Layout.fillWidth: true
+ Layout.alignment: Qt.AlignVCenter
+
+ text: detail.text
+ elide: Text.ElideRight
+ font.pointSize: Appearance.font.size.normal
+ }
+ }
+
+ component Label: StyledText {
+ Layout.leftMargin: Appearance.padding.large
+ Layout.rightMargin: Appearance.padding.large
+ Layout.fillWidth: true
+ elide: Text.ElideRight
+ horizontalAlignment: Text.AlignHCenter
+ animate: true
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/windowinfo/Preview.qml b/.config/quickshell/caelestia/modules/windowinfo/Preview.qml
new file mode 100644
index 0000000..4cc0aab
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/windowinfo/Preview.qml
@@ -0,0 +1,96 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.services
+import qs.config
+import Quickshell
+import Quickshell.Wayland
+import Quickshell.Hyprland
+import QtQuick
+import QtQuick.Layouts
+
+Item {
+ id: root
+
+ required property ShellScreen screen
+ required property HyprlandToplevel client
+
+ Layout.preferredWidth: preview.implicitWidth + Appearance.padding.large * 2
+ Layout.fillHeight: true
+
+ StyledClippingRect {
+ id: preview
+
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.top: parent.top
+ anchors.bottom: label.top
+ anchors.topMargin: Appearance.padding.large
+ anchors.bottomMargin: Appearance.spacing.normal
+
+ implicitWidth: view.implicitWidth
+
+ color: Colours.tPalette.m3surfaceContainer
+ radius: Appearance.rounding.small
+
+ Loader {
+ anchors.centerIn: parent
+ active: !root.client
+
+ sourceComponent: ColumnLayout {
+ spacing: 0
+
+ MaterialIcon {
+ Layout.alignment: Qt.AlignHCenter
+ text: "web_asset_off"
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.extraLarge * 3
+ }
+
+ StyledText {
+ Layout.alignment: Qt.AlignHCenter
+ text: qsTr("No active client")
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.extraLarge
+ font.weight: 500
+ }
+
+ StyledText {
+ Layout.alignment: Qt.AlignHCenter
+ text: qsTr("Try switching to a window")
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.large
+ }
+ }
+ }
+
+ ScreencopyView {
+ id: view
+
+ anchors.centerIn: parent
+
+ captureSource: root.client?.wayland ?? null
+ live: true
+
+ constraintSize.width: root.client ? parent.height * Math.min(root.screen.width / root.screen.height, root.client?.lastIpcObject.size[0] / root.client?.lastIpcObject.size[1]) : parent.height
+ constraintSize.height: parent.height
+ }
+ }
+
+ StyledText {
+ id: label
+
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.bottom: parent.bottom
+ anchors.bottomMargin: Appearance.padding.large
+
+ animate: true
+ text: {
+ const client = root.client;
+ if (!client)
+ return qsTr("No active client");
+
+ const mon = client.monitor;
+ return qsTr("%1 on monitor %2 at %3, %4").arg(client.title).arg(mon.name).arg(client.lastIpcObject.at[0]).arg(client.lastIpcObject.at[1]);
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/modules/windowinfo/WindowInfo.qml b/.config/quickshell/caelestia/modules/windowinfo/WindowInfo.qml
new file mode 100644
index 0000000..919b3fb
--- /dev/null
+++ b/.config/quickshell/caelestia/modules/windowinfo/WindowInfo.qml
@@ -0,0 +1,64 @@
+import qs.components
+import qs.services
+import qs.config
+import Quickshell
+import Quickshell.Hyprland
+import QtQuick
+import QtQuick.Layouts
+
+Item {
+ id: root
+
+ required property ShellScreen screen
+ required property HyprlandToplevel client
+
+ implicitWidth: child.implicitWidth
+ implicitHeight: screen.height * Config.winfo.sizes.heightMult
+
+ RowLayout {
+ id: child
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.large
+
+ spacing: Appearance.spacing.normal
+
+ Preview {
+ screen: root.screen
+ client: root.client
+ }
+
+ ColumnLayout {
+ spacing: Appearance.spacing.normal
+
+ Layout.preferredWidth: Config.winfo.sizes.detailsWidth
+ Layout.fillHeight: true
+
+ StyledRect {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+
+ color: Colours.tPalette.m3surfaceContainer
+ radius: Appearance.rounding.normal
+
+ Details {
+ client: root.client
+ }
+ }
+
+ StyledRect {
+ Layout.fillWidth: true
+ Layout.preferredHeight: buttons.implicitHeight
+
+ color: Colours.tPalette.m3surfaceContainer
+ radius: Appearance.rounding.normal
+
+ Buttons {
+ id: buttons
+
+ client: root.client
+ }
+ }
+ }
+ }
+}
diff --git a/.config/quickshell/caelestia/nix/app2unit.nix b/.config/quickshell/caelestia/nix/app2unit.nix
new file mode 100644
index 0000000..51b4241
--- /dev/null
+++ b/.config/quickshell/caelestia/nix/app2unit.nix
@@ -0,0 +1,14 @@
+{
+ pkgs, # To ensure the nixpkgs version of app2unit
+ fetchFromGitHub,
+ ...
+}:
+pkgs.app2unit.overrideAttrs (final: prev: rec {
+ version = "1.0.3"; # Fix old issue related to missing env var
+ src = fetchFromGitHub {
+ owner = "Vladimir-csp";
+ repo = "app2unit";
+ tag = "v${version}";
+ hash = "sha256-7eEVjgs+8k+/NLteSBKgn4gPaPLHC+3Uzlmz6XB0930=";
+ };
+})
diff --git a/.config/quickshell/caelestia/nix/default.nix b/.config/quickshell/caelestia/nix/default.nix
new file mode 100644
index 0000000..67747b2
--- /dev/null
+++ b/.config/quickshell/caelestia/nix/default.nix
@@ -0,0 +1,158 @@
+{
+ rev,
+ lib,
+ stdenv,
+ makeWrapper,
+ makeFontsConf,
+ fish,
+ ddcutil,
+ brightnessctl,
+ app2unit,
+ networkmanager,
+ lm_sensors,
+ swappy,
+ wl-clipboard,
+ libqalculate,
+ bash,
+ hyprland,
+ material-symbols,
+ rubik,
+ nerd-fonts,
+ qt6,
+ quickshell,
+ aubio,
+ libcava,
+ fftw,
+ pipewire,
+ xkeyboard-config,
+ cmake,
+ ninja,
+ pkg-config,
+ caelestia-cli,
+ debug ? false,
+ withCli ? false,
+ extraRuntimeDeps ? [],
+}: let
+ version = "1.0.0";
+
+ runtimeDeps =
+ [
+ fish
+ ddcutil
+ brightnessctl
+ app2unit
+ networkmanager
+ lm_sensors
+ swappy
+ wl-clipboard
+ libqalculate
+ bash
+ hyprland
+ ]
+ ++ extraRuntimeDeps
+ ++ lib.optional withCli caelestia-cli;
+
+ fontconfig = makeFontsConf {
+ fontDirectories = [material-symbols rubik nerd-fonts.caskaydia-cove];
+ };
+
+ cmakeBuildType =
+ if debug
+ then "Debug"
+ else "RelWithDebInfo";
+
+ cmakeVersionFlags = [
+ (lib.cmakeFeature "VERSION" version)
+ (lib.cmakeFeature "GIT_REVISION" rev)
+ (lib.cmakeFeature "DISTRIBUTOR" "nix-flake")
+ ];
+
+ extras = stdenv.mkDerivation {
+ inherit cmakeBuildType;
+ name = "caelestia-extras${lib.optionalString debug "-debug"}";
+ src = lib.fileset.toSource {
+ root = ./..;
+ fileset = lib.fileset.union ./../CMakeLists.txt ./../extras;
+ };
+
+ nativeBuildInputs = [cmake ninja];
+
+ cmakeFlags =
+ [
+ (lib.cmakeFeature "ENABLE_MODULES" "extras")
+ (lib.cmakeFeature "INSTALL_LIBDIR" "${placeholder "out"}/lib")
+ ]
+ ++ cmakeVersionFlags;
+ };
+
+ plugin = stdenv.mkDerivation {
+ inherit cmakeBuildType;
+ name = "caelestia-qml-plugin${lib.optionalString debug "-debug"}";
+ src = lib.fileset.toSource {
+ root = ./..;
+ fileset = lib.fileset.union ./../CMakeLists.txt ./../plugin;
+ };
+
+ nativeBuildInputs = [cmake ninja pkg-config];
+ buildInputs = [qt6.qtbase qt6.qtdeclarative libqalculate pipewire aubio libcava fftw];
+
+ dontWrapQtApps = true;
+ cmakeFlags =
+ [
+ (lib.cmakeFeature "ENABLE_MODULES" "plugin")
+ (lib.cmakeFeature "INSTALL_QMLDIR" qt6.qtbase.qtQmlPrefix)
+ ]
+ ++ cmakeVersionFlags;
+ };
+in
+ stdenv.mkDerivation {
+ inherit version cmakeBuildType;
+ pname = "caelestia-shell${lib.optionalString debug "-debug"}";
+ src = ./..;
+
+ nativeBuildInputs = [cmake ninja makeWrapper qt6.wrapQtAppsHook];
+ buildInputs = [quickshell extras plugin xkeyboard-config qt6.qtbase];
+ propagatedBuildInputs = runtimeDeps;
+
+ cmakeFlags =
+ [
+ (lib.cmakeFeature "ENABLE_MODULES" "shell")
+ (lib.cmakeFeature "INSTALL_QSCONFDIR" "${placeholder "out"}/share/caelestia-shell")
+ ]
+ ++ cmakeVersionFlags;
+
+ dontStrip = debug;
+
+ prePatch = ''
+ substituteInPlace assets/pam.d/fprint \
+ --replace-fail pam_fprintd.so /run/current-system/sw/lib/security/pam_fprintd.so
+ substituteInPlace shell.qml \
+ --replace-fail 'ShellRoot {' 'ShellRoot { settings.watchFiles: false'
+ '';
+
+ postInstall = ''
+ makeWrapper ${quickshell}/bin/qs $out/bin/caelestia-shell \
+ --prefix PATH : "${lib.makeBinPath runtimeDeps}" \
+ --set FONTCONFIG_FILE "${fontconfig}" \
+ --set CAELESTIA_LIB_DIR ${extras}/lib \
+ --set CAELESTIA_XKB_RULES_PATH ${xkeyboard-config}/share/xkeyboard-config-2/rules/base.lst \
+ --add-flags "-p $out/share/caelestia-shell"
+
+ mkdir -p $out/lib
+ ln -s ${extras}/lib/* $out/lib/
+
+ # Ensure wrap_term_launch.sh is executable
+ chmod 755 $out/share/caelestia-shell/assets/wrap_term_launch.sh
+ '';
+
+ passthru = {
+ inherit plugin extras;
+ };
+
+ meta = {
+ description = "A very segsy desktop shell";
+ homepage = "https://github.com/caelestia-dots/shell";
+ license = lib.licenses.gpl3Only;
+ mainProgram = "caelestia-shell";
+ };
+ }
diff --git a/.config/quickshell/caelestia/nix/hm-module.nix b/.config/quickshell/caelestia/nix/hm-module.nix
new file mode 100644
index 0000000..2976cf6
--- /dev/null
+++ b/.config/quickshell/caelestia/nix/hm-module.nix
@@ -0,0 +1,136 @@
+self: {
+ config,
+ pkgs,
+ lib,
+ ...
+}: let
+ inherit (pkgs.stdenv.hostPlatform) system;
+
+ cli-default = self.inputs.caelestia-cli.packages.${system}.default;
+ shell-default = self.packages.${system}.with-cli;
+
+ cfg = config.programs.caelestia;
+in {
+ imports = [
+ (lib.mkRenamedOptionModule ["programs" "caelestia" "environment"] ["programs" "caelestia" "systemd" "environment"])
+ ];
+ options = with lib; {
+ programs.caelestia = {
+ enable = mkEnableOption "Enable Caelestia shell";
+ package = mkOption {
+ type = types.package;
+ default = shell-default;
+ description = "The package of Caelestia shell";
+ };
+ systemd = {
+ enable = mkOption {
+ type = types.bool;
+ default = true;
+ description = "Enable the systemd service for Caelestia shell";
+ };
+ target = mkOption {
+ type = types.str;
+ description = ''
+ The systemd target that will automatically start the Caelestia shell.
+ '';
+ default = config.wayland.systemd.target;
+ };
+ environment = mkOption {
+ type = types.listOf types.str;
+ description = "Extra Environment variables to pass to the Caelestia shell systemd service.";
+ default = [];
+ example = [
+ "QT_QPA_PLATFORMTHEME=gtk3"
+ ];
+ };
+ };
+ settings = mkOption {
+ type = types.attrsOf types.anything;
+ default = {};
+ description = "Caelestia shell settings";
+ };
+ extraConfig = mkOption {
+ type = types.str;
+ default = "";
+ description = "Caelestia shell extra configs written to shell.json";
+ };
+ cli = {
+ enable = mkEnableOption "Enable Caelestia CLI";
+ package = mkOption {
+ type = types.package;
+ default = cli-default;
+ description = "The package of Caelestia CLI"; # Doesn't override the shell's CLI, only change from home.packages
+ };
+ settings = mkOption {
+ type = types.attrsOf types.anything;
+ default = {};
+ description = "Caelestia CLI settings";
+ };
+ extraConfig = mkOption {
+ type = types.str;
+ default = "";
+ description = "Caelestia CLI extra configs written to cli.json";
+ };
+ };
+ };
+ };
+
+ config = let
+ cli = cfg.cli.package;
+ shell = cfg.package;
+ in
+ lib.mkIf cfg.enable {
+ systemd.user.services.caelestia = lib.mkIf cfg.systemd.enable {
+ Unit = {
+ Description = "Caelestia Shell Service";
+ After = [cfg.systemd.target];
+ PartOf = [cfg.systemd.target];
+ X-Restart-Triggers = lib.mkIf (cfg.settings != {}) [
+ "${config.xdg.configFile."caelestia/shell.json".source}"
+ ];
+ };
+
+ Service = {
+ Type = "exec";
+ ExecStart = "${shell}/bin/caelestia-shell";
+ Restart = "on-failure";
+ RestartSec = "5s";
+ TimeoutStopSec = "5s";
+ Environment =
+ [
+ "QT_QPA_PLATFORM=wayland"
+ ]
+ ++ cfg.systemd.environment;
+
+ Slice = "session.slice";
+ };
+
+ Install = {
+ WantedBy = [cfg.systemd.target];
+ };
+ };
+
+ xdg.configFile = let
+ mkConfig = c:
+ lib.pipe (
+ if c.extraConfig != ""
+ then c.extraConfig
+ else "{}"
+ ) [
+ builtins.fromJSON
+ (lib.recursiveUpdate c.settings)
+ builtins.toJSON
+ ];
+ shouldGenerate = c: c.extraConfig != "" || c.settings != {};
+ in {
+ "caelestia/shell.json" = lib.mkIf (shouldGenerate cfg) {
+ text = mkConfig cfg;
+ };
+ "caelestia/cli.json" = lib.mkIf (shouldGenerate cfg.cli) {
+ text = mkConfig cfg.cli;
+ };
+ };
+
+ home.packages = [shell] ++ lib.optional cfg.cli.enable cli;
+ };
+}
diff --git a/.config/quickshell/caelestia/plugin/CMakeLists.txt b/.config/quickshell/caelestia/plugin/CMakeLists.txt
new file mode 100644
index 0000000..062959c
--- /dev/null
+++ b/.config/quickshell/caelestia/plugin/CMakeLists.txt
@@ -0,0 +1 @@
+add_subdirectory(src/Caelestia)
diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/CMakeLists.txt b/.config/quickshell/caelestia/plugin/src/Caelestia/CMakeLists.txt
new file mode 100644
index 0000000..1b7d0e4
--- /dev/null
+++ b/.config/quickshell/caelestia/plugin/src/Caelestia/CMakeLists.txt
@@ -0,0 +1,62 @@
+find_package(Qt6 REQUIRED COMPONENTS Core Qml Gui Quick Concurrent Sql Network DBus)
+find_package(PkgConfig REQUIRED)
+pkg_check_modules(Qalculate IMPORTED_TARGET libqalculate REQUIRED)
+pkg_check_modules(Pipewire IMPORTED_TARGET libpipewire-0.3 REQUIRED)
+pkg_check_modules(Aubio IMPORTED_TARGET aubio REQUIRED)
+pkg_check_modules(Cava IMPORTED_TARGET libcava QUIET)
+if(NOT Cava_FOUND)
+ pkg_check_modules(Cava IMPORTED_TARGET cava REQUIRED)
+endif()
+
+set(QT_QML_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qml")
+qt_standard_project_setup(REQUIRES 6.9)
+
+function(qml_module arg_TARGET)
+ cmake_parse_arguments(PARSE_ARGV 1 arg "" "URI" "SOURCES;LIBRARIES")
+
+ qt_add_qml_module(${arg_TARGET}
+ URI ${arg_URI}
+ VERSION ${VERSION}
+ SOURCES ${arg_SOURCES}
+ )
+
+ qt_query_qml_module(${arg_TARGET}
+ URI module_uri
+ VERSION module_version
+ PLUGIN_TARGET module_plugin_target
+ TARGET_PATH module_target_path
+ QMLDIR module_qmldir
+ TYPEINFO module_typeinfo
+ )
+
+ message(STATUS "Created QML module ${module_uri}, version ${module_version}")
+
+ set(module_dir "${INSTALL_QMLDIR}/${module_target_path}")
+ install(TARGETS ${arg_TARGET} LIBRARY DESTINATION "${module_dir}" RUNTIME DESTINATION "${module_dir}")
+ install(TARGETS "${module_plugin_target}" LIBRARY DESTINATION "${module_dir}" RUNTIME DESTINATION "${module_dir}")
+ install(FILES "${module_qmldir}" DESTINATION "${module_dir}")
+ install(FILES "${module_typeinfo}" DESTINATION "${module_dir}")
+
+ target_link_libraries(${arg_TARGET} PRIVATE Qt::Core Qt::Qml ${arg_LIBRARIES})
+endfunction()
+
+qml_module(caelestia
+ URI Caelestia
+ SOURCES
+ cutils.hpp cutils.cpp
+ qalculator.hpp qalculator.cpp
+ appdb.hpp appdb.cpp
+ requests.hpp requests.cpp
+ toaster.hpp toaster.cpp
+ imageanalyser.hpp imageanalyser.cpp
+ LIBRARIES
+ Qt::Gui
+ Qt::Quick
+ Qt::Concurrent
+ Qt::Sql
+ PkgConfig::Qalculate
+)
+
+add_subdirectory(Internal)
+add_subdirectory(Models)
+add_subdirectory(Services)
diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/CMakeLists.txt b/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/CMakeLists.txt
new file mode 100644
index 0000000..bdc58db
--- /dev/null
+++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/CMakeLists.txt
@@ -0,0 +1,15 @@
+qml_module(caelestia-internal
+ URI Caelestia.Internal
+ SOURCES
+ cachingimagemanager.hpp cachingimagemanager.cpp
+ circularindicatormanager.hpp circularindicatormanager.cpp
+ hyprdevices.hpp hyprdevices.cpp
+ hyprextras.hpp hyprextras.cpp
+ logindmanager.hpp logindmanager.cpp
+ LIBRARIES
+ Qt::Gui
+ Qt::Quick
+ Qt::Concurrent
+ Qt::Network
+ Qt::DBus
+)
diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/cachingimagemanager.cpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/cachingimagemanager.cpp
new file mode 100644
index 0000000..1c15cd2
--- /dev/null
+++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/cachingimagemanager.cpp
@@ -0,0 +1,223 @@
+#include "cachingimagemanager.hpp"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace caelestia::internal {
+
+qreal CachingImageManager::effectiveScale() const {
+ if (m_item && m_item->window()) {
+ return m_item->window()->devicePixelRatio();
+ }
+
+ return 1.0;
+}
+
+QSize CachingImageManager::effectiveSize() const {
+ if (!m_item) {
+ return QSize();
+ }
+
+ const qreal scale = effectiveScale();
+ const QSize size = QSizeF(m_item->width() * scale, m_item->height() * scale).toSize();
+ m_item->setProperty("sourceSize", size);
+ return size;
+}
+
+QQuickItem* CachingImageManager::item() const {
+ return m_item;
+}
+
+void CachingImageManager::setItem(QQuickItem* item) {
+ if (m_item == item) {
+ return;
+ }
+
+ if (m_widthConn) {
+ disconnect(m_widthConn);
+ }
+ if (m_heightConn) {
+ disconnect(m_heightConn);
+ }
+
+ m_item = item;
+ emit itemChanged();
+
+ if (item) {
+ m_widthConn = connect(item, &QQuickItem::widthChanged, this, [this]() {
+ updateSource();
+ });
+ m_heightConn = connect(item, &QQuickItem::heightChanged, this, [this]() {
+ updateSource();
+ });
+ updateSource();
+ }
+}
+
+QUrl CachingImageManager::cacheDir() const {
+ return m_cacheDir;
+}
+
+void CachingImageManager::setCacheDir(const QUrl& cacheDir) {
+ if (m_cacheDir == cacheDir) {
+ return;
+ }
+
+ m_cacheDir = cacheDir;
+ if (!m_cacheDir.path().endsWith("/")) {
+ m_cacheDir.setPath(m_cacheDir.path() + "/");
+ }
+ emit cacheDirChanged();
+}
+
+QString CachingImageManager::path() const {
+ return m_path;
+}
+
+void CachingImageManager::setPath(const QString& path) {
+ if (m_path == path) {
+ return;
+ }
+
+ m_path = path;
+ emit pathChanged();
+
+ if (!path.isEmpty()) {
+ updateSource(path);
+ }
+}
+
+void CachingImageManager::updateSource() {
+ updateSource(m_path);
+}
+
+void CachingImageManager::updateSource(const QString& path) {
+ if (path.isEmpty() || path == m_shaPath) {
+ // Path is empty or already calculating sha for path
+ return;
+ }
+
+ m_shaPath = path;
+
+ const auto future = QtConcurrent::run(&CachingImageManager::sha256sum, path);
+
+ const auto watcher = new QFutureWatcher(this);
+
+ connect(watcher, &QFutureWatcher::finished, this, [watcher, path, this]() {
+ if (m_path != path) {
+ // Object is destroyed or path has changed, ignore
+ watcher->deleteLater();
+ return;
+ }
+
+ const QSize size = effectiveSize();
+
+ if (!m_item || !size.width() || !size.height()) {
+ watcher->deleteLater();
+ return;
+ }
+
+ const QString fillMode = m_item->property("fillMode").toString();
+ // clang-format off
+ const QString filename = QString("%1@%2x%3-%4.png")
+ .arg(watcher->result()).arg(size.width()).arg(size.height())
+ .arg(fillMode == "PreserveAspectCrop" ? "crop" : fillMode == "PreserveAspectFit" ? "fit" : "stretch");
+ // clang-format on
+
+ const QUrl cache = m_cacheDir.resolved(QUrl(filename));
+ if (m_cachePath == cache) {
+ watcher->deleteLater();
+ return;
+ }
+
+ m_cachePath = cache;
+ emit cachePathChanged();
+
+ if (!cache.isLocalFile()) {
+ qWarning() << "CachingImageManager::updateSource: cachePath" << cache << "is not a local file";
+ watcher->deleteLater();
+ return;
+ }
+
+ const QImageReader reader(cache.toLocalFile());
+ if (reader.canRead()) {
+ m_item->setProperty("source", cache);
+ } else {
+ m_item->setProperty("source", QUrl::fromLocalFile(path));
+ createCache(path, cache.toLocalFile(), fillMode, size);
+ }
+
+ // Clear current running sha if same
+ if (m_shaPath == path) {
+ m_shaPath = QString();
+ }
+
+ watcher->deleteLater();
+ });
+
+ watcher->setFuture(future);
+}
+
+QUrl CachingImageManager::cachePath() const {
+ return m_cachePath;
+}
+
+void CachingImageManager::createCache(
+ const QString& path, const QString& cache, const QString& fillMode, const QSize& size) const {
+ QThreadPool::globalInstance()->start([path, cache, fillMode, size] {
+ QImage image(path);
+
+ if (image.isNull()) {
+ qWarning() << "CachingImageManager::createCache: failed to read" << path;
+ return;
+ }
+
+ image.convertTo(QImage::Format_ARGB32);
+
+ if (fillMode == "PreserveAspectCrop") {
+ image = image.scaled(size, Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation);
+ } else if (fillMode == "PreserveAspectFit") {
+ image = image.scaled(size, Qt::KeepAspectRatio, Qt::SmoothTransformation);
+ } else {
+ image = image.scaled(size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
+ }
+
+ if (fillMode == "PreserveAspectCrop" || fillMode == "PreserveAspectFit") {
+ QImage canvas(size, QImage::Format_ARGB32);
+ canvas.fill(Qt::transparent);
+
+ QPainter painter(&canvas);
+ painter.drawImage((size.width() - image.width()) / 2, (size.height() - image.height()) / 2, image);
+ painter.end();
+
+ image = canvas;
+ }
+
+ const QString parent = QFileInfo(cache).absolutePath();
+ if (!QDir().mkpath(parent) || !image.save(cache)) {
+ qWarning() << "CachingImageManager::createCache: failed to save to" << cache;
+ }
+ });
+}
+
+QString CachingImageManager::sha256sum(const QString& path) {
+ QFile file(path);
+ if (!file.open(QIODevice::ReadOnly)) {
+ qWarning() << "CachingImageManager::sha256sum: failed to open" << path;
+ return "";
+ }
+
+ QCryptographicHash hash(QCryptographicHash::Sha256);
+ hash.addData(&file);
+ file.close();
+
+ return hash.result().toHex();
+}
+
+} // namespace caelestia::internal
diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/cachingimagemanager.hpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/cachingimagemanager.hpp
new file mode 100644
index 0000000..3611699
--- /dev/null
+++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/cachingimagemanager.hpp
@@ -0,0 +1,65 @@
+#pragma once
+
+#include
+#include
+#include
+
+namespace caelestia::internal {
+
+class CachingImageManager : public QObject {
+ Q_OBJECT
+ QML_ELEMENT
+
+ Q_PROPERTY(QQuickItem* item READ item WRITE setItem NOTIFY itemChanged REQUIRED)
+ Q_PROPERTY(QUrl cacheDir READ cacheDir WRITE setCacheDir NOTIFY cacheDirChanged REQUIRED)
+
+ Q_PROPERTY(QString path READ path WRITE setPath NOTIFY pathChanged)
+ Q_PROPERTY(QUrl cachePath READ cachePath NOTIFY cachePathChanged)
+
+public:
+ explicit CachingImageManager(QObject* parent = nullptr)
+ : QObject(parent)
+ , m_item(nullptr) {}
+
+ [[nodiscard]] QQuickItem* item() const;
+ void setItem(QQuickItem* item);
+
+ [[nodiscard]] QUrl cacheDir() const;
+ void setCacheDir(const QUrl& cacheDir);
+
+ [[nodiscard]] QString path() const;
+ void setPath(const QString& path);
+
+ [[nodiscard]] QUrl cachePath() const;
+
+ Q_INVOKABLE void updateSource();
+ Q_INVOKABLE void updateSource(const QString& path);
+
+signals:
+ void itemChanged();
+ void cacheDirChanged();
+
+ void pathChanged();
+ void cachePathChanged();
+ void usingCacheChanged();
+
+private:
+ QString m_shaPath;
+
+ QQuickItem* m_item;
+ QUrl m_cacheDir;
+
+ QString m_path;
+ QUrl m_cachePath;
+
+ QMetaObject::Connection m_widthConn;
+ QMetaObject::Connection m_heightConn;
+
+ [[nodiscard]] qreal effectiveScale() const;
+ [[nodiscard]] QSize effectiveSize() const;
+
+ void createCache(const QString& path, const QString& cache, const QString& fillMode, const QSize& size) const;
+ [[nodiscard]] static QString sha256sum(const QString& path);
+};
+
+} // namespace caelestia::internal
diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/circularindicatormanager.cpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/circularindicatormanager.cpp
new file mode 100644
index 0000000..434b756
--- /dev/null
+++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/circularindicatormanager.cpp
@@ -0,0 +1,211 @@
+#include "circularindicatormanager.hpp"
+#include
+#include
+
+namespace {
+
+namespace advance {
+
+constexpr qint32 TOTAL_CYCLES = 4;
+constexpr qint32 TOTAL_DURATION_IN_MS = 5400;
+constexpr qint32 DURATION_TO_EXPAND_IN_MS = 667;
+constexpr qint32 DURATION_TO_COLLAPSE_IN_MS = 667;
+constexpr qint32 DURATION_TO_COMPLETE_END_IN_MS = 333;
+constexpr qint32 TAIL_DEGREES_OFFSET = -20;
+constexpr qint32 EXTRA_DEGREES_PER_CYCLE = 250;
+constexpr qint32 CONSTANT_ROTATION_DEGREES = 1520;
+
+constexpr std::array DELAY_TO_EXPAND_IN_MS = { 0, 1350, 2700, 4050 };
+constexpr std::array DELAY_TO_COLLAPSE_IN_MS = { 667, 2017, 3367, 4717 };
+
+} // namespace advance
+
+namespace retreat {
+
+constexpr qint32 TOTAL_DURATION_IN_MS = 6000;
+constexpr qint32 DURATION_SPIN_IN_MS = 500;
+constexpr qint32 DURATION_GROW_ACTIVE_IN_MS = 3000;
+constexpr qint32 DURATION_SHRINK_ACTIVE_IN_MS = 3000;
+constexpr std::array DELAY_SPINS_IN_MS = { 0, 1500, 3000, 4500 };
+constexpr qint32 DELAY_GROW_ACTIVE_IN_MS = 0;
+constexpr qint32 DELAY_SHRINK_ACTIVE_IN_MS = 3000;
+constexpr qint32 DURATION_TO_COMPLETE_END_IN_MS = 500;
+
+// Constants for animation values.
+
+// The total degrees that a constant rotation goes by.
+constexpr qint32 CONSTANT_ROTATION_DEGREES = 1080;
+// Despite of the constant rotation, there are also 5 extra rotations the entire animation. The
+// total degrees that each extra rotation goes by.
+constexpr qint32 SPIN_ROTATION_DEGREES = 90;
+constexpr std::array END_FRACTION_RANGE = { 0.10, 0.87 };
+
+} // namespace retreat
+
+inline qreal getFractionInRange(qreal playtime, qreal start, qreal duration) {
+ const auto fraction = (playtime - start) / duration;
+ return std::clamp(fraction, 0.0, 1.0);
+}
+
+} // namespace
+
+namespace caelestia::internal {
+
+CircularIndicatorManager::CircularIndicatorManager(QObject* parent)
+ : QObject(parent)
+ , m_type(IndeterminateAnimationType::Advance)
+ , m_curve(QEasingCurve(QEasingCurve::BezierSpline))
+ , m_progress(0)
+ , m_startFraction(0)
+ , m_endFraction(0)
+ , m_rotation(0)
+ , m_completeEndProgress(0) {
+ // Fast out slow in
+ m_curve.addCubicBezierSegment({ 0.4, 0.0 }, { 0.2, 1.0 }, { 1.0, 1.0 });
+}
+
+qreal CircularIndicatorManager::startFraction() const {
+ return m_startFraction;
+}
+
+qreal CircularIndicatorManager::endFraction() const {
+ return m_endFraction;
+}
+
+qreal CircularIndicatorManager::rotation() const {
+ return m_rotation;
+}
+
+qreal CircularIndicatorManager::progress() const {
+ return m_progress;
+}
+
+void CircularIndicatorManager::setProgress(qreal progress) {
+ update(progress);
+}
+
+qreal CircularIndicatorManager::duration() const {
+ if (m_type == IndeterminateAnimationType::Advance) {
+ return advance::TOTAL_DURATION_IN_MS;
+ } else {
+ return retreat::TOTAL_DURATION_IN_MS;
+ }
+}
+
+qreal CircularIndicatorManager::completeEndDuration() const {
+ if (m_type == IndeterminateAnimationType::Advance) {
+ return advance::DURATION_TO_COMPLETE_END_IN_MS;
+ } else {
+ return retreat::DURATION_TO_COMPLETE_END_IN_MS;
+ }
+}
+
+CircularIndicatorManager::IndeterminateAnimationType CircularIndicatorManager::indeterminateAnimationType() const {
+ return m_type;
+}
+
+void CircularIndicatorManager::setIndeterminateAnimationType(IndeterminateAnimationType t) {
+ if (m_type != t) {
+ m_type = t;
+ emit indeterminateAnimationTypeChanged();
+ }
+}
+
+qreal CircularIndicatorManager::completeEndProgress() const {
+ return m_completeEndProgress;
+}
+
+void CircularIndicatorManager::setCompleteEndProgress(qreal progress) {
+ if (qFuzzyCompare(m_completeEndProgress + 1.0, progress + 1.0)) {
+ return;
+ }
+
+ m_completeEndProgress = progress;
+ emit completeEndProgressChanged();
+
+ update(m_progress);
+}
+
+void CircularIndicatorManager::update(qreal progress) {
+ if (qFuzzyCompare(m_progress + 1.0, progress + 1.0)) {
+ return;
+ }
+
+ if (m_type == IndeterminateAnimationType::Advance) {
+ updateAdvance(progress);
+ } else {
+ updateRetreat(progress);
+ }
+
+ m_progress = progress;
+ emit progressChanged();
+}
+
+void CircularIndicatorManager::updateRetreat(qreal progress) {
+ using namespace retreat;
+ const auto playtime = progress * TOTAL_DURATION_IN_MS;
+
+ // Constant rotation.
+ const qreal constantRotation = CONSTANT_ROTATION_DEGREES * progress;
+ // Extra rotation for the faster spinning.
+ qreal spinRotation = 0;
+ for (const int spinDelay : DELAY_SPINS_IN_MS) {
+ spinRotation += m_curve.valueForProgress(getFractionInRange(playtime, spinDelay, DURATION_SPIN_IN_MS)) *
+ SPIN_ROTATION_DEGREES;
+ }
+ m_rotation = constantRotation + spinRotation;
+ emit rotationChanged();
+
+ // Grow active indicator.
+ qreal fraction =
+ m_curve.valueForProgress(getFractionInRange(playtime, DELAY_GROW_ACTIVE_IN_MS, DURATION_GROW_ACTIVE_IN_MS));
+ fraction -=
+ m_curve.valueForProgress(getFractionInRange(playtime, DELAY_SHRINK_ACTIVE_IN_MS, DURATION_SHRINK_ACTIVE_IN_MS));
+
+ if (!qFuzzyIsNull(m_startFraction)) {
+ m_startFraction = 0.0;
+ emit startFractionChanged();
+ }
+ const auto oldEndFrac = m_endFraction;
+ m_endFraction = std::lerp(END_FRACTION_RANGE[0], END_FRACTION_RANGE[1], fraction);
+
+ // Completing animation.
+ if (m_completeEndProgress > 0) {
+ m_endFraction *= 1 - m_completeEndProgress;
+ }
+
+ if (!qFuzzyCompare(m_endFraction + 1.0, oldEndFrac + 1.0)) {
+ emit endFractionChanged();
+ }
+}
+
+void CircularIndicatorManager::updateAdvance(qreal progress) {
+ using namespace advance;
+ const auto playtime = progress * TOTAL_DURATION_IN_MS;
+
+ // Adds constant rotation to segment positions.
+ m_startFraction = CONSTANT_ROTATION_DEGREES * progress + TAIL_DEGREES_OFFSET;
+ m_endFraction = CONSTANT_ROTATION_DEGREES * progress;
+
+ // Adds cycle specific rotation to segment positions.
+ for (size_t cycleIndex = 0; cycleIndex < TOTAL_CYCLES; ++cycleIndex) {
+ // While expanding.
+ qreal fraction = getFractionInRange(playtime, DELAY_TO_EXPAND_IN_MS[cycleIndex], DURATION_TO_EXPAND_IN_MS);
+ m_endFraction += m_curve.valueForProgress(fraction) * EXTRA_DEGREES_PER_CYCLE;
+
+ // While collapsing.
+ fraction = getFractionInRange(playtime, DELAY_TO_COLLAPSE_IN_MS[cycleIndex], DURATION_TO_COLLAPSE_IN_MS);
+ m_startFraction += m_curve.valueForProgress(fraction) * EXTRA_DEGREES_PER_CYCLE;
+ }
+
+ // Closes the gap between head and tail for complete end.
+ m_startFraction += (m_endFraction - m_startFraction) * m_completeEndProgress;
+
+ m_startFraction /= 360.0;
+ m_endFraction /= 360.0;
+
+ emit startFractionChanged();
+ emit endFractionChanged();
+}
+
+} // namespace caelestia::internal
diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/circularindicatormanager.hpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/circularindicatormanager.hpp
new file mode 100644
index 0000000..2dbc9d6
--- /dev/null
+++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/circularindicatormanager.hpp
@@ -0,0 +1,72 @@
+#pragma once
+
+#include
+#include
+#include
+
+namespace caelestia::internal {
+
+class CircularIndicatorManager : public QObject {
+ Q_OBJECT
+ QML_ELEMENT
+
+ Q_PROPERTY(qreal startFraction READ startFraction NOTIFY startFractionChanged)
+ Q_PROPERTY(qreal endFraction READ endFraction NOTIFY endFractionChanged)
+ Q_PROPERTY(qreal rotation READ rotation NOTIFY rotationChanged)
+ Q_PROPERTY(qreal progress READ progress WRITE setProgress NOTIFY progressChanged)
+ Q_PROPERTY(qreal completeEndProgress READ completeEndProgress WRITE setCompleteEndProgress NOTIFY
+ completeEndProgressChanged)
+ Q_PROPERTY(qreal duration READ duration NOTIFY indeterminateAnimationTypeChanged)
+ Q_PROPERTY(qreal completeEndDuration READ completeEndDuration NOTIFY indeterminateAnimationTypeChanged)
+ Q_PROPERTY(IndeterminateAnimationType indeterminateAnimationType READ indeterminateAnimationType WRITE
+ setIndeterminateAnimationType NOTIFY indeterminateAnimationTypeChanged)
+
+public:
+ explicit CircularIndicatorManager(QObject* parent = nullptr);
+
+ enum IndeterminateAnimationType {
+ Advance = 0,
+ Retreat
+ };
+ Q_ENUM(IndeterminateAnimationType)
+
+ [[nodiscard]] qreal startFraction() const;
+ [[nodiscard]] qreal endFraction() const;
+ [[nodiscard]] qreal rotation() const;
+
+ [[nodiscard]] qreal progress() const;
+ void setProgress(qreal progress);
+
+ [[nodiscard]] qreal completeEndProgress() const;
+ void setCompleteEndProgress(qreal progress);
+
+ [[nodiscard]] qreal duration() const;
+ [[nodiscard]] qreal completeEndDuration() const;
+
+ [[nodiscard]] IndeterminateAnimationType indeterminateAnimationType() const;
+ void setIndeterminateAnimationType(IndeterminateAnimationType t);
+
+signals:
+ void startFractionChanged();
+ void endFractionChanged();
+ void rotationChanged();
+ void progressChanged();
+ void completeEndProgressChanged();
+ void indeterminateAnimationTypeChanged();
+
+private:
+ IndeterminateAnimationType m_type;
+ QEasingCurve m_curve;
+
+ qreal m_progress;
+ qreal m_startFraction;
+ qreal m_endFraction;
+ qreal m_rotation;
+ qreal m_completeEndProgress;
+
+ void update(qreal progress);
+ void updateAdvance(qreal progress);
+ void updateRetreat(qreal progress);
+};
+
+} // namespace caelestia::internal
diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/hyprdevices.cpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/hyprdevices.cpp
new file mode 100644
index 0000000..1ac7d25
--- /dev/null
+++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/hyprdevices.cpp
@@ -0,0 +1,134 @@
+#include "hyprdevices.hpp"
+
+#include
+
+namespace caelestia::internal::hypr {
+
+HyprKeyboard::HyprKeyboard(QJsonObject ipcObject, QObject* parent)
+ : QObject(parent)
+ , m_lastIpcObject(ipcObject) {}
+
+QVariantHash HyprKeyboard::lastIpcObject() const {
+ return m_lastIpcObject.toVariantHash();
+}
+
+QString HyprKeyboard::address() const {
+ return m_lastIpcObject.value("address").toString();
+}
+
+QString HyprKeyboard::name() const {
+ return m_lastIpcObject.value("name").toString();
+}
+
+QString HyprKeyboard::layout() const {
+ return m_lastIpcObject.value("layout").toString();
+}
+
+QString HyprKeyboard::activeKeymap() const {
+ return m_lastIpcObject.value("active_keymap").toString();
+}
+
+bool HyprKeyboard::capsLock() const {
+ return m_lastIpcObject.value("capsLock").toBool();
+}
+
+bool HyprKeyboard::numLock() const {
+ return m_lastIpcObject.value("numLock").toBool();
+}
+
+bool HyprKeyboard::main() const {
+ return m_lastIpcObject.value("main").toBool();
+}
+
+bool HyprKeyboard::updateLastIpcObject(QJsonObject object) {
+ if (m_lastIpcObject == object) {
+ return false;
+ }
+
+ const auto last = m_lastIpcObject;
+
+ m_lastIpcObject = object;
+ emit lastIpcObjectChanged();
+
+ bool dirty = false;
+ if (last.value("address") != object.value("address")) {
+ dirty = true;
+ emit addressChanged();
+ }
+ if (last.value("name") != object.value("name")) {
+ dirty = true;
+ emit nameChanged();
+ }
+ if (last.value("layout") != object.value("layout")) {
+ dirty = true;
+ emit layoutChanged();
+ }
+ if (last.value("active_keymap") != object.value("active_keymap")) {
+ dirty = true;
+ emit activeKeymapChanged();
+ }
+ if (last.value("capsLock") != object.value("capsLock")) {
+ dirty = true;
+ emit capsLockChanged();
+ }
+ if (last.value("numLock") != object.value("numLock")) {
+ dirty = true;
+ emit numLockChanged();
+ }
+ if (last.value("main") != object.value("main")) {
+ dirty = true;
+ emit mainChanged();
+ }
+ return dirty;
+}
+
+HyprDevices::HyprDevices(QObject* parent)
+ : QObject(parent) {}
+
+QQmlListProperty HyprDevices::keyboards() {
+ return QQmlListProperty(this, &m_keyboards);
+}
+
+bool HyprDevices::updateLastIpcObject(QJsonObject object) {
+ const auto val = object.value("keyboards").toArray();
+ bool dirty = false;
+
+ for (auto it = m_keyboards.begin(); it != m_keyboards.end();) {
+ auto* const keyboard = *it;
+ const auto inNewValues = std::any_of(val.begin(), val.end(), [keyboard](const QJsonValue& o) {
+ return o.toObject().value("address").toString() == keyboard->address();
+ });
+
+ if (!inNewValues) {
+ dirty = true;
+ it = m_keyboards.erase(it);
+ keyboard->deleteLater();
+ } else {
+ ++it;
+ }
+ }
+
+ for (const auto& o : val) {
+ const auto obj = o.toObject();
+ const auto addr = obj.value("address").toString();
+
+ auto it = std::find_if(m_keyboards.begin(), m_keyboards.end(), [addr](const HyprKeyboard* kb) {
+ return kb->address() == addr;
+ });
+
+ if (it != m_keyboards.end()) {
+ dirty |= (*it)->updateLastIpcObject(obj);
+ } else {
+ dirty = true;
+ m_keyboards << new HyprKeyboard(obj, this);
+ }
+ }
+
+ if (dirty) {
+ emit keyboardsChanged();
+ }
+
+ return dirty;
+}
+
+} // namespace caelestia::internal::hypr
diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/hyprdevices.hpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/hyprdevices.hpp
new file mode 100644
index 0000000..da18063
--- /dev/null
+++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/hyprdevices.hpp
@@ -0,0 +1,74 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+
+namespace caelestia::internal::hypr {
+
+class HyprKeyboard : public QObject {
+ Q_OBJECT
+ QML_ELEMENT
+ QML_UNCREATABLE("HyprKeyboard instances can only be retrieved from a HyprDevices")
+
+ Q_PROPERTY(QVariantHash lastIpcObject READ lastIpcObject NOTIFY lastIpcObjectChanged)
+ Q_PROPERTY(QString address READ address NOTIFY addressChanged)
+ Q_PROPERTY(QString name READ name NOTIFY nameChanged)
+ Q_PROPERTY(QString layout READ layout NOTIFY layoutChanged)
+ Q_PROPERTY(QString activeKeymap READ activeKeymap NOTIFY activeKeymapChanged)
+ Q_PROPERTY(bool capsLock READ capsLock NOTIFY capsLockChanged)
+ Q_PROPERTY(bool numLock READ numLock NOTIFY numLockChanged)
+ Q_PROPERTY(bool main READ main NOTIFY mainChanged)
+
+public:
+ explicit HyprKeyboard(QJsonObject ipcObject, QObject* parent = nullptr);
+
+ [[nodiscard]] QVariantHash lastIpcObject() const;
+ [[nodiscard]] QString address() const;
+ [[nodiscard]] QString name() const;
+ [[nodiscard]] QString layout() const;
+ [[nodiscard]] QString activeKeymap() const;
+ [[nodiscard]] bool capsLock() const;
+ [[nodiscard]] bool numLock() const;
+ [[nodiscard]] bool main() const;
+
+ bool updateLastIpcObject(QJsonObject object);
+
+signals:
+ void lastIpcObjectChanged();
+ void addressChanged();
+ void nameChanged();
+ void layoutChanged();
+ void activeKeymapChanged();
+ void capsLockChanged();
+ void numLockChanged();
+ void mainChanged();
+
+private:
+ QJsonObject m_lastIpcObject;
+};
+
+class HyprDevices : public QObject {
+ Q_OBJECT
+ QML_ELEMENT
+ QML_UNCREATABLE("HyprDevices instances can only be retrieved from a HyprExtras")
+
+ Q_PROPERTY(
+ QQmlListProperty keyboards READ keyboards NOTIFY keyboardsChanged)
+
+public:
+ explicit HyprDevices(QObject* parent = nullptr);
+
+ [[nodiscard]] QQmlListProperty keyboards();
+
+ bool updateLastIpcObject(QJsonObject object);
+
+signals:
+ void keyboardsChanged();
+
+private:
+ QList m_keyboards;
+};
+
+} // namespace caelestia::internal::hypr
diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/hyprextras.cpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/hyprextras.cpp
new file mode 100644
index 0000000..5308524
--- /dev/null
+++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/hyprextras.cpp
@@ -0,0 +1,217 @@
+#include "hyprextras.hpp"
+
+#include
+#include
+#include
+#include
+
+namespace caelestia::internal::hypr {
+
+HyprExtras::HyprExtras(QObject* parent)
+ : QObject(parent)
+ , m_requestSocket("")
+ , m_eventSocket("")
+ , m_socket(nullptr)
+ , m_socketValid(false)
+ , m_devices(new HyprDevices(this)) {
+ const auto his = qEnvironmentVariable("HYPRLAND_INSTANCE_SIGNATURE");
+ if (his.isEmpty()) {
+ qWarning()
+ << "HyprExtras::HyprExtras: $HYPRLAND_INSTANCE_SIGNATURE is unset. Unable to connect to Hyprland socket.";
+ return;
+ }
+
+ auto hyprDir = QString("%1/hypr/%2").arg(qEnvironmentVariable("XDG_RUNTIME_DIR"), his);
+ if (!QDir(hyprDir).exists()) {
+ hyprDir = "/tmp/hypr/" + his;
+
+ if (!QDir(hyprDir).exists()) {
+ qWarning() << "HyprExtras::HyprExtras: Hyprland socket directory does not exist. Unable to connect to "
+ "Hyprland socket.";
+ return;
+ }
+ }
+
+ m_requestSocket = hyprDir + "/.socket.sock";
+ m_eventSocket = hyprDir + "/.socket2.sock";
+
+ refreshOptions();
+ refreshDevices();
+
+ m_socket = new QLocalSocket(this);
+
+ QObject::connect(m_socket, &QLocalSocket::errorOccurred, this, &HyprExtras::socketError);
+ QObject::connect(m_socket, &QLocalSocket::stateChanged, this, &HyprExtras::socketStateChanged);
+ QObject::connect(m_socket, &QLocalSocket::readyRead, this, &HyprExtras::readEvent);
+
+ m_socket->connectToServer(m_eventSocket, QLocalSocket::ReadOnly);
+}
+
+QVariantHash HyprExtras::options() const {
+ return m_options;
+}
+
+HyprDevices* HyprExtras::devices() const {
+ return m_devices;
+}
+
+void HyprExtras::message(const QString& message) {
+ if (message.isEmpty()) {
+ return;
+ }
+
+ makeRequest(message, [](bool success, const QByteArray& res) {
+ if (!success) {
+ qWarning() << "HyprExtras::message: request error:" << QString::fromUtf8(res);
+ }
+ });
+}
+
+void HyprExtras::batchMessage(const QStringList& messages) {
+ if (messages.isEmpty()) {
+ return;
+ }
+
+ makeRequest("[[BATCH]]" + messages.join(";"), [](bool success, const QByteArray& res) {
+ if (!success) {
+ qWarning() << "HyprExtras::batchMessage: request error:" << QString::fromUtf8(res);
+ }
+ });
+}
+
+void HyprExtras::applyOptions(const QVariantHash& options) {
+ if (options.isEmpty()) {
+ return;
+ }
+
+ QString request = "[[BATCH]]";
+ for (auto it = options.constBegin(); it != options.constEnd(); ++it) {
+ request += QString("keyword %1 %2;").arg(it.key(), it.value().toString());
+ }
+
+ makeRequest(request, [this](bool success, const QByteArray& res) {
+ if (success) {
+ refreshOptions();
+ } else {
+ qWarning() << "HyprExtras::applyOptions: request error" << QString::fromUtf8(res);
+ }
+ });
+}
+
+void HyprExtras::refreshOptions() {
+ if (!m_optionsRefresh.isNull()) {
+ m_optionsRefresh->close();
+ }
+
+ m_optionsRefresh = makeRequestJson("descriptions", [this](bool success, const QJsonDocument& response) {
+ m_optionsRefresh.reset();
+ if (!success) {
+ return;
+ }
+
+ const auto options = response.array();
+ bool dirty = false;
+
+ for (const auto& o : std::as_const(options)) {
+ const auto obj = o.toObject();
+ const auto key = obj.value("value").toString();
+ const auto value = obj.value("data").toObject().value("current").toVariant();
+ if (m_options.value(key) != value) {
+ dirty = true;
+ m_options.insert(key, value);
+ }
+ }
+
+ if (dirty) {
+ emit optionsChanged();
+ }
+ });
+}
+
+void HyprExtras::refreshDevices() {
+ if (!m_devicesRefresh.isNull()) {
+ m_devicesRefresh->close();
+ }
+
+ m_devicesRefresh = makeRequestJson("devices", [this](bool success, const QJsonDocument& response) {
+ m_devicesRefresh.reset();
+ if (success) {
+ m_devices->updateLastIpcObject(response.object());
+ }
+ });
+}
+
+void HyprExtras::socketError(QLocalSocket::LocalSocketError error) const {
+ if (!m_socketValid) {
+ qWarning() << "HyprExtras::socketError: unable to connect to Hyprland event socket:" << error;
+ } else {
+ qWarning() << "HyprExtras::socketError: Hyprland event socket error:" << error;
+ }
+}
+
+void HyprExtras::socketStateChanged(QLocalSocket::LocalSocketState state) {
+ if (state == QLocalSocket::UnconnectedState && m_socketValid) {
+ qWarning() << "HyprExtras::socketStateChanged: Hyprland event socket disconnected.";
+ }
+
+ m_socketValid = state == QLocalSocket::ConnectedState;
+}
+
+void HyprExtras::readEvent() {
+ while (true) {
+ auto rawEvent = m_socket->readLine();
+ if (rawEvent.isEmpty()) {
+ break;
+ }
+ rawEvent.truncate(rawEvent.length() - 1); // Remove trailing \n
+ const auto event = QByteArrayView(rawEvent.data(), rawEvent.indexOf(">>"));
+ handleEvent(QString::fromUtf8(event));
+ }
+}
+
+void HyprExtras::handleEvent(const QString& event) {
+ if (event == "configreloaded") {
+ refreshOptions();
+ } else if (event == "activelayout") {
+ refreshDevices();
+ }
+}
+
+HyprExtras::SocketPtr HyprExtras::makeRequestJson(
+ const QString& request, const std::function& callback) {
+ return makeRequest("j/" + request, [callback](bool success, const QByteArray& response) {
+ callback(success, QJsonDocument::fromJson(response));
+ });
+}
+
+HyprExtras::SocketPtr HyprExtras::makeRequest(
+ const QString& request, const std::function& callback) {
+ if (m_requestSocket.isEmpty()) {
+ return SocketPtr();
+ }
+
+ auto socket = SocketPtr::create(this);
+
+ QObject::connect(socket.data(), &QLocalSocket::connected, this, [=, this]() {
+ QObject::connect(socket.data(), &QLocalSocket::readyRead, this, [socket, callback]() {
+ const auto response = socket->readAll();
+ callback(true, std::move(response));
+ socket->close();
+ });
+
+ socket->write(request.toUtf8());
+ socket->flush();
+ });
+
+ QObject::connect(socket.data(), &QLocalSocket::errorOccurred, this, [=](QLocalSocket::LocalSocketError err) {
+ qWarning() << "HyprExtras::makeRequest: error making request:" << err << "| request:" << request;
+ callback(false, {});
+ socket->close();
+ });
+
+ socket->connectToServer(m_requestSocket);
+
+ return socket;
+}
+
+} // namespace caelestia::internal::hypr
diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/hyprextras.hpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/hyprextras.hpp
new file mode 100644
index 0000000..14563c0
--- /dev/null
+++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/hyprextras.hpp
@@ -0,0 +1,56 @@
+#pragma once
+
+#include "hyprdevices.hpp"
+#include
+#include
+#include
+
+namespace caelestia::internal::hypr {
+
+class HyprExtras : public QObject {
+ Q_OBJECT
+ QML_ELEMENT
+
+ Q_PROPERTY(QVariantHash options READ options NOTIFY optionsChanged)
+ Q_PROPERTY(caelestia::internal::hypr::HyprDevices* devices READ devices CONSTANT)
+
+public:
+ explicit HyprExtras(QObject* parent = nullptr);
+
+ [[nodiscard]] QVariantHash options() const;
+ [[nodiscard]] HyprDevices* devices() const;
+
+ Q_INVOKABLE void message(const QString& message);
+ Q_INVOKABLE void batchMessage(const QStringList& messages);
+ Q_INVOKABLE void applyOptions(const QVariantHash& options);
+
+ Q_INVOKABLE void refreshOptions();
+ Q_INVOKABLE void refreshDevices();
+
+signals:
+ void optionsChanged();
+
+private:
+ using SocketPtr = QSharedPointer;
+
+ QString m_requestSocket;
+ QString m_eventSocket;
+ QLocalSocket* m_socket;
+ bool m_socketValid;
+
+ QVariantHash m_options;
+ HyprDevices* const m_devices;
+
+ SocketPtr m_optionsRefresh;
+ SocketPtr m_devicesRefresh;
+
+ void socketError(QLocalSocket::LocalSocketError error) const;
+ void socketStateChanged(QLocalSocket::LocalSocketState state);
+ void readEvent();
+ void handleEvent(const QString& event);
+
+ SocketPtr makeRequestJson(const QString& request, const std::function& callback);
+ SocketPtr makeRequest(const QString& request, const std::function& callback);
+};
+
+} // namespace caelestia::internal::hypr
diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/logindmanager.cpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/logindmanager.cpp
new file mode 100644
index 0000000..4194ee1
--- /dev/null
+++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/logindmanager.cpp
@@ -0,0 +1,65 @@
+#include "logindmanager.hpp"
+
+#include
+#include
+#include
+#include
+
+namespace caelestia::internal {
+
+LogindManager::LogindManager(QObject* parent)
+ : QObject(parent) {
+ auto bus = QDBusConnection::systemBus();
+ if (!bus.isConnected()) {
+ qWarning() << "LogindManager::LogindManager: failed to connect to system bus:" << bus.lastError().message();
+ return;
+ }
+
+ bool ok = bus.connect("org.freedesktop.login1", "/org/freedesktop/login1", "org.freedesktop.login1.Manager",
+ "PrepareForSleep", this, SLOT(handlePrepareForSleep(bool)));
+
+ if (!ok) {
+ qWarning() << "LogindManager::LogindManager: failed to connect to PrepareForSleep signal:"
+ << bus.lastError().message();
+ }
+
+ QDBusInterface login1("org.freedesktop.login1", "/org/freedesktop/login1", "org.freedesktop.login1.Manager", bus);
+ const QDBusReply reply = login1.call("GetSession", "auto");
+ if (!reply.isValid()) {
+ qWarning() << "LogindManager::LogindManager: failed to get session path";
+ return;
+ }
+ const auto sessionPath = reply.value().path();
+
+ ok = bus.connect("org.freedesktop.login1", sessionPath, "org.freedesktop.login1.Session", "Lock", this,
+ SLOT(handleLockRequested()));
+
+ if (!ok) {
+ qWarning() << "LogindManager::LogindManager: failed to connect to Lock signal:" << bus.lastError().message();
+ }
+
+ ok = bus.connect("org.freedesktop.login1", sessionPath, "org.freedesktop.login1.Session", "Unlock", this,
+ SLOT(handleUnlockRequested()));
+
+ if (!ok) {
+ qWarning() << "LogindManager::LogindManager: failed to connect to Unlock signal:" << bus.lastError().message();
+ }
+}
+
+void LogindManager::handlePrepareForSleep(bool sleep) {
+ if (sleep) {
+ emit aboutToSleep();
+ } else {
+ emit resumed();
+ }
+}
+
+void LogindManager::handleLockRequested() {
+ emit lockRequested();
+}
+
+void LogindManager::handleUnlockRequested() {
+ emit unlockRequested();
+}
+
+} // namespace caelestia::internal
diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/logindmanager.hpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/logindmanager.hpp
new file mode 100644
index 0000000..72a3401
--- /dev/null
+++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/logindmanager.hpp
@@ -0,0 +1,27 @@
+#pragma once
+
+#include
+#include
+
+namespace caelestia::internal {
+
+class LogindManager : public QObject {
+ Q_OBJECT
+ QML_ELEMENT
+
+public:
+ explicit LogindManager(QObject* parent = nullptr);
+
+signals:
+ void aboutToSleep();
+ void resumed();
+ void lockRequested();
+ void unlockRequested();
+
+private slots:
+ void handlePrepareForSleep(bool sleep);
+ void handleLockRequested();
+ void handleUnlockRequested();
+};
+
+} // namespace caelestia::internal
diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Models/CMakeLists.txt b/.config/quickshell/caelestia/plugin/src/Caelestia/Models/CMakeLists.txt
new file mode 100644
index 0000000..640e29e
--- /dev/null
+++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Models/CMakeLists.txt
@@ -0,0 +1,8 @@
+qml_module(caelestia-models
+ URI Caelestia.Models
+ SOURCES
+ filesystemmodel.hpp filesystemmodel.cpp
+ LIBRARIES
+ Qt::Gui
+ Qt::Concurrent
+)
diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Models/filesystemmodel.cpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Models/filesystemmodel.cpp
new file mode 100644
index 0000000..e387ecd
--- /dev/null
+++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Models/filesystemmodel.cpp
@@ -0,0 +1,479 @@
+#include "filesystemmodel.hpp"
+
+#include
+#include
+#include
+
+namespace caelestia::models {
+
+FileSystemEntry::FileSystemEntry(const QString& path, const QString& relativePath, QObject* parent)
+ : QObject(parent)
+ , m_fileInfo(path)
+ , m_path(path)
+ , m_relativePath(relativePath)
+ , m_isImageInitialised(false)
+ , m_mimeTypeInitialised(false) {}
+
+QString FileSystemEntry::path() const {
+ return m_path;
+};
+
+QString FileSystemEntry::relativePath() const {
+ return m_relativePath;
+};
+
+QString FileSystemEntry::name() const {
+ return m_fileInfo.fileName();
+};
+
+QString FileSystemEntry::baseName() const {
+ return m_fileInfo.baseName();
+};
+
+QString FileSystemEntry::parentDir() const {
+ return m_fileInfo.absolutePath();
+};
+
+QString FileSystemEntry::suffix() const {
+ return m_fileInfo.completeSuffix();
+};
+
+qint64 FileSystemEntry::size() const {
+ return m_fileInfo.size();
+};
+
+bool FileSystemEntry::isDir() const {
+ return m_fileInfo.isDir();
+};
+
+bool FileSystemEntry::isImage() const {
+ if (!m_isImageInitialised) {
+ QImageReader reader(m_path);
+ m_isImage = reader.canRead();
+ m_isImageInitialised = true;
+ }
+ return m_isImage;
+}
+
+QString FileSystemEntry::mimeType() const {
+ if (!m_mimeTypeInitialised) {
+ const QMimeDatabase db;
+ m_mimeType = db.mimeTypeForFile(m_path).name();
+ m_mimeTypeInitialised = true;
+ }
+ return m_mimeType;
+}
+
+void FileSystemEntry::updateRelativePath(const QDir& dir) {
+ const auto relPath = dir.relativeFilePath(m_path);
+ if (m_relativePath != relPath) {
+ m_relativePath = relPath;
+ emit relativePathChanged();
+ }
+}
+
+FileSystemModel::FileSystemModel(QObject* parent)
+ : QAbstractListModel(parent)
+ , m_recursive(false)
+ , m_watchChanges(true)
+ , m_showHidden(false)
+ , m_filter(NoFilter) {
+ connect(&m_watcher, &QFileSystemWatcher::directoryChanged, this, &FileSystemModel::watchDirIfRecursive);
+ connect(&m_watcher, &QFileSystemWatcher::directoryChanged, this, &FileSystemModel::updateEntriesForDir);
+}
+
+int FileSystemModel::rowCount(const QModelIndex& parent) const {
+ if (parent != QModelIndex()) {
+ return 0;
+ }
+ return static_cast(m_entries.size());
+}
+
+QVariant FileSystemModel::data(const QModelIndex& index, int role) const {
+ if (role != Qt::UserRole || !index.isValid() || index.row() >= m_entries.size()) {
+ return QVariant();
+ }
+ return QVariant::fromValue(m_entries.at(index.row()));
+}
+
+QHash FileSystemModel::roleNames() const {
+ return { { Qt::UserRole, "modelData" } };
+}
+
+QString FileSystemModel::path() const {
+ return m_path;
+}
+
+void FileSystemModel::setPath(const QString& path) {
+ if (m_path == path) {
+ return;
+ }
+
+ m_path = path;
+ emit pathChanged();
+
+ m_dir.setPath(m_path);
+
+ for (const auto& entry : std::as_const(m_entries)) {
+ entry->updateRelativePath(m_dir);
+ }
+
+ update();
+}
+
+bool FileSystemModel::recursive() const {
+ return m_recursive;
+}
+
+void FileSystemModel::setRecursive(bool recursive) {
+ if (m_recursive == recursive) {
+ return;
+ }
+
+ m_recursive = recursive;
+ emit recursiveChanged();
+
+ update();
+}
+
+bool FileSystemModel::watchChanges() const {
+ return m_watchChanges;
+}
+
+void FileSystemModel::setWatchChanges(bool watchChanges) {
+ if (m_watchChanges == watchChanges) {
+ return;
+ }
+
+ m_watchChanges = watchChanges;
+ emit watchChangesChanged();
+
+ update();
+}
+
+bool FileSystemModel::showHidden() const {
+ return m_showHidden;
+}
+
+void FileSystemModel::setShowHidden(bool showHidden) {
+ if (m_showHidden == showHidden) {
+ return;
+ }
+
+ m_showHidden = showHidden;
+ emit showHiddenChanged();
+
+ update();
+}
+
+bool FileSystemModel::sortReverse() const {
+ return m_sortReverse;
+}
+
+void FileSystemModel::setSortReverse(bool sortReverse) {
+ if (m_sortReverse == sortReverse) {
+ return;
+ }
+
+ m_sortReverse = sortReverse;
+ emit sortReverseChanged();
+
+ update();
+}
+
+FileSystemModel::Filter FileSystemModel::filter() const {
+ return m_filter;
+}
+
+void FileSystemModel::setFilter(Filter filter) {
+ if (m_filter == filter) {
+ return;
+ }
+
+ m_filter = filter;
+ emit filterChanged();
+
+ update();
+}
+
+QStringList FileSystemModel::nameFilters() const {
+ return m_nameFilters;
+}
+
+void FileSystemModel::setNameFilters(const QStringList& nameFilters) {
+ if (m_nameFilters == nameFilters) {
+ return;
+ }
+
+ m_nameFilters = nameFilters;
+ emit nameFiltersChanged();
+
+ update();
+}
+
+QQmlListProperty FileSystemModel::entries() {
+ return QQmlListProperty(this, &m_entries);
+}
+
+void FileSystemModel::watchDirIfRecursive(const QString& path) {
+ if (m_recursive && m_watchChanges) {
+ const auto currentDir = m_dir;
+ const bool showHidden = m_showHidden;
+ const auto future = QtConcurrent::run([showHidden, path]() {
+ QDir::Filters filters = QDir::Dirs | QDir::NoDotAndDotDot;
+ if (showHidden) {
+ filters |= QDir::Hidden;
+ }
+
+ QDirIterator iter(path, filters, QDirIterator::Subdirectories);
+ QStringList dirs;
+ while (iter.hasNext()) {
+ dirs << iter.next();
+ }
+ return dirs;
+ });
+ const auto watcher = new QFutureWatcher(this);
+ connect(watcher, &QFutureWatcher::finished, this, [currentDir, showHidden, watcher, this]() {
+ const auto paths = watcher->result();
+ if (currentDir == m_dir && showHidden == m_showHidden && !paths.isEmpty()) {
+ // Ignore if dir or showHidden has changed
+ m_watcher.addPaths(paths);
+ }
+ watcher->deleteLater();
+ });
+ watcher->setFuture(future);
+ }
+}
+
+void FileSystemModel::update() {
+ updateWatcher();
+ updateEntries();
+}
+
+void FileSystemModel::updateWatcher() {
+ if (!m_watcher.directories().isEmpty()) {
+ m_watcher.removePaths(m_watcher.directories());
+ }
+
+ if (!m_watchChanges || m_path.isEmpty()) {
+ return;
+ }
+
+ m_watcher.addPath(m_path);
+ watchDirIfRecursive(m_path);
+}
+
+void FileSystemModel::updateEntries() {
+ if (m_path.isEmpty()) {
+ if (!m_entries.isEmpty()) {
+ beginResetModel();
+ qDeleteAll(m_entries);
+ m_entries.clear();
+ endResetModel();
+ emit entriesChanged();
+ }
+
+ return;
+ }
+
+ for (auto& future : m_futures) {
+ future.cancel();
+ }
+ m_futures.clear();
+
+ updateEntriesForDir(m_path);
+}
+
+void FileSystemModel::updateEntriesForDir(const QString& dir) {
+ const auto recursive = m_recursive;
+ const auto showHidden = m_showHidden;
+ const auto filter = m_filter;
+ const auto nameFilters = m_nameFilters;
+
+ QSet oldPaths;
+ for (const auto& entry : std::as_const(m_entries)) {
+ oldPaths << entry->path();
+ }
+
+ const auto future = QtConcurrent::run([=](QPromise, QSet>>& promise) {
+ const auto flags = recursive ? QDirIterator::Subdirectories : QDirIterator::NoIteratorFlags;
+
+ std::optional iter;
+
+ if (filter == Images) {
+ QStringList extraNameFilters = nameFilters;
+ const auto formats = QImageReader::supportedImageFormats();
+ for (const auto& format : formats) {
+ extraNameFilters << "*." + format;
+ }
+
+ QDir::Filters filters = QDir::Files;
+ if (showHidden) {
+ filters |= QDir::Hidden;
+ }
+
+ iter.emplace(dir, extraNameFilters, filters, flags);
+ } else {
+ QDir::Filters filters;
+
+ if (filter == Files) {
+ filters = QDir::Files;
+ } else if (filter == Dirs) {
+ filters = QDir::Dirs | QDir::NoDotAndDotDot;
+ } else {
+ filters = QDir::Dirs | QDir::Files | QDir::NoDotAndDotDot;
+ }
+
+ if (showHidden) {
+ filters |= QDir::Hidden;
+ }
+
+ if (nameFilters.isEmpty()) {
+ iter.emplace(dir, filters, flags);
+ } else {
+ iter.emplace(dir, nameFilters, filters, flags);
+ }
+ }
+
+ QSet newPaths;
+ while (iter->hasNext()) {
+ if (promise.isCanceled()) {
+ return;
+ }
+
+ QString path = iter->next();
+
+ if (filter == Images) {
+ QImageReader reader(path);
+ if (!reader.canRead()) {
+ continue;
+ }
+ }
+
+ newPaths.insert(path);
+ }
+
+ if (promise.isCanceled() || newPaths == oldPaths) {
+ return;
+ }
+
+ promise.addResult(qMakePair(oldPaths - newPaths, newPaths - oldPaths));
+ });
+
+ if (m_futures.contains(dir)) {
+ m_futures[dir].cancel();
+ }
+ m_futures.insert(dir, future);
+
+ const auto watcher = new QFutureWatcher, QSet>>(this);
+
+ connect(watcher, &QFutureWatcher, QSet>>::finished, this, [dir, watcher, this]() {
+ m_futures.remove(dir);
+
+ if (!watcher->future().isResultReadyAt(0)) {
+ watcher->deleteLater();
+ return;
+ }
+
+ const auto result = watcher->result();
+ applyChanges(result.first, result.second);
+
+ watcher->deleteLater();
+ });
+
+ watcher->setFuture(future);
+}
+
+void FileSystemModel::applyChanges(const QSet& removedPaths, const QSet& addedPaths) {
+ QList removedIndices;
+ for (int i = 0; i < m_entries.size(); ++i) {
+ if (removedPaths.contains(m_entries[i]->path())) {
+ removedIndices << i;
+ }
+ }
+ std::sort(removedIndices.begin(), removedIndices.end(), std::greater());
+
+ // Batch remove old entries
+ int start = -1;
+ int end = -1;
+ for (int idx : std::as_const(removedIndices)) {
+ if (start == -1) {
+ start = idx;
+ end = idx;
+ } else if (idx == end - 1) {
+ end = idx;
+ } else {
+ beginRemoveRows(QModelIndex(), end, start);
+ for (int i = start; i >= end; --i) {
+ m_entries.takeAt(i)->deleteLater();
+ }
+ endRemoveRows();
+
+ start = idx;
+ end = idx;
+ }
+ }
+ if (start != -1) {
+ beginRemoveRows(QModelIndex(), end, start);
+ for (int i = start; i >= end; --i) {
+ m_entries.takeAt(i)->deleteLater();
+ }
+ endRemoveRows();
+ }
+
+ // Create new entries
+ QList newEntries;
+ for (const auto& path : addedPaths) {
+ newEntries << new FileSystemEntry(path, m_dir.relativeFilePath(path), this);
+ }
+ std::sort(newEntries.begin(), newEntries.end(), [this](const FileSystemEntry* a, const FileSystemEntry* b) {
+ return compareEntries(a, b);
+ });
+
+ // Batch insert new entries
+ int insertStart = -1;
+ QList batchItems;
+ for (const auto& entry : std::as_const(newEntries)) {
+ const auto it = std::lower_bound(
+ m_entries.begin(), m_entries.end(), entry, [this](const FileSystemEntry* a, const FileSystemEntry* b) {
+ return compareEntries(a, b);
+ });
+ const auto row = static_cast(it - m_entries.begin());
+
+ if (insertStart == -1) {
+ insertStart = row;
+ batchItems << entry;
+ } else if (row == insertStart + batchItems.size()) {
+ batchItems << entry;
+ } else {
+ beginInsertRows(QModelIndex(), insertStart, insertStart + static_cast(batchItems.size()) - 1);
+ for (int i = 0; i < batchItems.size(); ++i) {
+ m_entries.insert(insertStart + i, batchItems[i]);
+ }
+ endInsertRows();
+
+ insertStart = row;
+ batchItems.clear();
+ batchItems << entry;
+ }
+ }
+ if (!batchItems.isEmpty()) {
+ beginInsertRows(QModelIndex(), insertStart, insertStart + static_cast(batchItems.size()) - 1);
+ for (int i = 0; i < batchItems.size(); ++i) {
+ m_entries.insert(insertStart + i, batchItems[i]);
+ }
+ endInsertRows();
+ }
+
+ emit entriesChanged();
+}
+
+bool FileSystemModel::compareEntries(const FileSystemEntry* a, const FileSystemEntry* b) const {
+ if (a->isDir() != b->isDir()) {
+ return m_sortReverse ^ a->isDir();
+ }
+ const auto cmp = a->relativePath().localeAwareCompare(b->relativePath());
+ return m_sortReverse ? cmp > 0 : cmp < 0;
+}
+
+} // namespace caelestia::models
diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Models/filesystemmodel.hpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Models/filesystemmodel.hpp
new file mode 100644
index 0000000..cf8eae8
--- /dev/null
+++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Models/filesystemmodel.hpp
@@ -0,0 +1,148 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace caelestia::models {
+
+class FileSystemEntry : public QObject {
+ Q_OBJECT
+ QML_ELEMENT
+ QML_UNCREATABLE("FileSystemEntry instances can only be retrieved from a FileSystemModel")
+
+ Q_PROPERTY(QString path READ path CONSTANT)
+ Q_PROPERTY(QString relativePath READ relativePath NOTIFY relativePathChanged)
+ Q_PROPERTY(QString name READ name CONSTANT)
+ Q_PROPERTY(QString baseName READ baseName CONSTANT)
+ Q_PROPERTY(QString parentDir READ parentDir CONSTANT)
+ Q_PROPERTY(QString suffix READ suffix CONSTANT)
+ Q_PROPERTY(qint64 size READ size CONSTANT)
+ Q_PROPERTY(bool isDir READ isDir CONSTANT)
+ Q_PROPERTY(bool isImage READ isImage CONSTANT)
+ Q_PROPERTY(QString mimeType READ mimeType CONSTANT)
+
+public:
+ explicit FileSystemEntry(const QString& path, const QString& relativePath, QObject* parent = nullptr);
+
+ [[nodiscard]] QString path() const;
+ [[nodiscard]] QString relativePath() const;
+ [[nodiscard]] QString name() const;
+ [[nodiscard]] QString baseName() const;
+ [[nodiscard]] QString parentDir() const;
+ [[nodiscard]] QString suffix() const;
+ [[nodiscard]] qint64 size() const;
+ [[nodiscard]] bool isDir() const;
+ [[nodiscard]] bool isImage() const;
+ [[nodiscard]] QString mimeType() const;
+
+ void updateRelativePath(const QDir& dir);
+
+signals:
+ void relativePathChanged();
+
+private:
+ const QFileInfo m_fileInfo;
+
+ const QString m_path;
+ QString m_relativePath;
+
+ mutable bool m_isImage;
+ mutable bool m_isImageInitialised;
+
+ mutable QString m_mimeType;
+ mutable bool m_mimeTypeInitialised;
+};
+
+class FileSystemModel : public QAbstractListModel {
+ Q_OBJECT
+ QML_ELEMENT
+
+ Q_PROPERTY(QString path READ path WRITE setPath NOTIFY pathChanged)
+ Q_PROPERTY(bool recursive READ recursive WRITE setRecursive NOTIFY recursiveChanged)
+ Q_PROPERTY(bool watchChanges READ watchChanges WRITE setWatchChanges NOTIFY watchChangesChanged)
+ Q_PROPERTY(bool showHidden READ showHidden WRITE setShowHidden NOTIFY showHiddenChanged)
+ Q_PROPERTY(bool sortReverse READ sortReverse WRITE setSortReverse NOTIFY sortReverseChanged)
+ Q_PROPERTY(Filter filter READ filter WRITE setFilter NOTIFY filterChanged)
+ Q_PROPERTY(QStringList nameFilters READ nameFilters WRITE setNameFilters NOTIFY nameFiltersChanged)
+
+ Q_PROPERTY(QQmlListProperty entries READ entries NOTIFY entriesChanged)
+
+public:
+ enum Filter {
+ NoFilter,
+ Images,
+ Files,
+ Dirs
+ };
+ Q_ENUM(Filter)
+
+ explicit FileSystemModel(QObject* parent = nullptr);
+
+ int rowCount(const QModelIndex& parent = QModelIndex()) const override;
+ QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override;
+ QHash roleNames() const override;
+
+ [[nodiscard]] QString path() const;
+ void setPath(const QString& path);
+
+ [[nodiscard]] bool recursive() const;
+ void setRecursive(bool recursive);
+
+ [[nodiscard]] bool watchChanges() const;
+ void setWatchChanges(bool watchChanges);
+
+ [[nodiscard]] bool showHidden() const;
+ void setShowHidden(bool showHidden);
+
+ [[nodiscard]] bool sortReverse() const;
+ void setSortReverse(bool sortReverse);
+
+ [[nodiscard]] Filter filter() const;
+ void setFilter(Filter filter);
+
+ [[nodiscard]] QStringList nameFilters() const;
+ void setNameFilters(const QStringList& nameFilters);
+
+ [[nodiscard]] QQmlListProperty entries();
+
+signals:
+ void pathChanged();
+ void recursiveChanged();
+ void watchChangesChanged();
+ void showHiddenChanged();
+ void sortReverseChanged();
+ void filterChanged();
+ void nameFiltersChanged();
+ void entriesChanged();
+
+private:
+ QDir m_dir;
+ QFileSystemWatcher m_watcher;
+ QList m_entries;
+ QHash, QSet>>> m_futures;
+
+ QString m_path;
+ bool m_recursive;
+ bool m_watchChanges;
+ bool m_showHidden;
+ bool m_sortReverse;
+ Filter m_filter;
+ QStringList m_nameFilters;
+
+ void watchDirIfRecursive(const QString& path);
+ void update();
+ void updateWatcher();
+ void updateEntries();
+ void updateEntriesForDir(const QString& dir);
+ void applyChanges(const QSet& removedPaths, const QSet& addedPaths);
+ [[nodiscard]] bool compareEntries(const FileSystemEntry* a, const FileSystemEntry* b) const;
+};
+
+} // namespace caelestia::models
diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Services/CMakeLists.txt b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/CMakeLists.txt
new file mode 100644
index 0000000..8ce868b
--- /dev/null
+++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/CMakeLists.txt
@@ -0,0 +1,14 @@
+qml_module(caelestia-services
+ URI Caelestia.Services
+ SOURCES
+ service.hpp service.cpp
+ serviceref.hpp serviceref.cpp
+ beattracker.hpp beattracker.cpp
+ audiocollector.hpp audiocollector.cpp
+ audioprovider.hpp audioprovider.cpp
+ cavaprovider.hpp cavaprovider.cpp
+ LIBRARIES
+ PkgConfig::Pipewire
+ PkgConfig::Aubio
+ PkgConfig::Cava
+)
diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Services/audiocollector.cpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/audiocollector.cpp
new file mode 100644
index 0000000..1563405
--- /dev/null
+++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/audiocollector.cpp
@@ -0,0 +1,246 @@
+#include "audiocollector.hpp"
+
+#include "service.hpp"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace caelestia::services {
+
+PipeWireWorker::PipeWireWorker(std::stop_token token, AudioCollector* collector)
+ : m_loop(nullptr)
+ , m_stream(nullptr)
+ , m_timer(nullptr)
+ , m_idle(true)
+ , m_token(token)
+ , m_collector(collector) {
+ pw_init(nullptr, nullptr);
+
+ m_loop = pw_main_loop_new(nullptr);
+ if (!m_loop) {
+ qWarning() << "PipeWireWorker::init: failed to create PipeWire main loop";
+ pw_deinit();
+ return;
+ }
+
+ timespec timeout = { 0, 10 * SPA_NSEC_PER_MSEC };
+ m_timer = pw_loop_add_timer(pw_main_loop_get_loop(m_loop), handleTimeout, this);
+ pw_loop_update_timer(pw_main_loop_get_loop(m_loop), m_timer, &timeout, &timeout, false);
+
+ auto props = pw_properties_new(
+ PW_KEY_MEDIA_TYPE, "Audio", PW_KEY_MEDIA_CATEGORY, "Capture", PW_KEY_MEDIA_ROLE, "Music", nullptr);
+ pw_properties_set(props, PW_KEY_STREAM_CAPTURE_SINK, "true");
+ pw_properties_setf(
+ props, PW_KEY_NODE_LATENCY, "%u/%u", nextPowerOf2(512 * ac::SAMPLE_RATE / 48000), ac::SAMPLE_RATE);
+ pw_properties_set(props, PW_KEY_NODE_PASSIVE, "true");
+ pw_properties_set(props, PW_KEY_NODE_VIRTUAL, "true");
+ pw_properties_set(props, PW_KEY_STREAM_DONT_REMIX, "false");
+ pw_properties_set(props, "channelmix.upmix", "true");
+
+ std::vector buffer(ac::CHUNK_SIZE);
+ spa_pod_builder b;
+ spa_pod_builder_init(&b, buffer.data(), static_cast(buffer.size()));
+
+ spa_audio_info_raw info{};
+ info.format = SPA_AUDIO_FORMAT_S16;
+ info.rate = ac::SAMPLE_RATE;
+ info.channels = 1;
+
+ const spa_pod* params[1];
+ params[0] = spa_format_audio_raw_build(&b, SPA_PARAM_EnumFormat, &info);
+
+ pw_stream_events events{};
+ events.state_changed = [](void* data, pw_stream_state, pw_stream_state state, const char*) {
+ auto* self = static_cast(data);
+ self->streamStateChanged(state);
+ };
+ events.process = [](void* data) {
+ auto* self = static_cast(data);
+ self->processStream();
+ };
+
+ m_stream = pw_stream_new_simple(pw_main_loop_get_loop(m_loop), "caelestia-shell", props, &events, this);
+
+ const int success = pw_stream_connect(m_stream, PW_DIRECTION_INPUT, PW_ID_ANY,
+ static_cast(
+ PW_STREAM_FLAG_AUTOCONNECT | PW_STREAM_FLAG_MAP_BUFFERS | PW_STREAM_FLAG_RT_PROCESS),
+ params, 1);
+ if (success < 0) {
+ qWarning() << "PipeWireWorker::init: failed to connect stream";
+ pw_stream_destroy(m_stream);
+ pw_main_loop_destroy(m_loop);
+ pw_deinit();
+ return;
+ }
+
+ pw_main_loop_run(m_loop);
+
+ pw_stream_destroy(m_stream);
+ pw_main_loop_destroy(m_loop);
+ pw_deinit();
+}
+
+void PipeWireWorker::handleTimeout(void* data, uint64_t expirations) {
+ auto* self = static_cast(data);
+
+ if (self->m_token.stop_requested()) {
+ pw_main_loop_quit(self->m_loop);
+ return;
+ }
+
+ if (!self->m_idle) {
+ if (expirations < 10) {
+ self->m_collector->clearBuffer();
+ } else {
+ self->m_idle = true;
+ timespec timeout = { 0, 500 * SPA_NSEC_PER_MSEC };
+ pw_loop_update_timer(pw_main_loop_get_loop(self->m_loop), self->m_timer, &timeout, &timeout, false);
+ }
+ }
+}
+
+void PipeWireWorker::streamStateChanged(pw_stream_state state) {
+ m_idle = false;
+ switch (state) {
+ case PW_STREAM_STATE_PAUSED: {
+ timespec timeout = { 0, 10 * SPA_NSEC_PER_MSEC };
+ pw_loop_update_timer(pw_main_loop_get_loop(m_loop), m_timer, &timeout, &timeout, false);
+ break;
+ }
+ case PW_STREAM_STATE_STREAMING:
+ pw_loop_update_timer(pw_main_loop_get_loop(m_loop), m_timer, nullptr, nullptr, false);
+ break;
+ case PW_STREAM_STATE_ERROR:
+ pw_main_loop_quit(m_loop);
+ break;
+ default:
+ break;
+ }
+}
+
+void PipeWireWorker::processStream() {
+ if (m_token.stop_requested()) {
+ pw_main_loop_quit(m_loop);
+ return;
+ }
+
+ pw_buffer* buffer = pw_stream_dequeue_buffer(m_stream);
+ if (buffer == nullptr) {
+ return;
+ }
+
+ const spa_buffer* buf = buffer->buffer;
+ const qint16* samples = reinterpret_cast(buf->datas[0].data);
+ if (samples == nullptr) {
+ return;
+ }
+
+ const quint32 count = buf->datas[0].chunk->size / 2;
+ m_collector->loadChunk(samples, count);
+
+ pw_stream_queue_buffer(m_stream, buffer);
+}
+
+unsigned int PipeWireWorker::nextPowerOf2(unsigned int n) {
+ if (n == 0) {
+ return 1;
+ }
+
+ n--;
+ n |= n >> 1;
+ n |= n >> 2;
+ n |= n >> 4;
+ n |= n >> 8;
+ n |= n >> 16;
+ n++;
+
+ return n;
+}
+
+AudioCollector& AudioCollector::instance() {
+ static AudioCollector instance;
+ return instance;
+}
+
+void AudioCollector::clearBuffer() {
+ auto* writeBuffer = m_writeBuffer.load(std::memory_order_relaxed);
+ std::fill(writeBuffer->begin(), writeBuffer->end(), 0.0f);
+
+ auto* oldRead = m_readBuffer.exchange(writeBuffer, std::memory_order_acq_rel);
+ m_writeBuffer.store(oldRead, std::memory_order_release);
+}
+
+void AudioCollector::loadChunk(const qint16* samples, quint32 count) {
+ if (count > ac::CHUNK_SIZE) {
+ count = ac::CHUNK_SIZE;
+ }
+
+ auto* writeBuffer = m_writeBuffer.load(std::memory_order_relaxed);
+ std::transform(samples, samples + count, writeBuffer->begin(), [](qint16 sample) {
+ return sample / 32768.0f;
+ });
+
+ auto* oldRead = m_readBuffer.exchange(writeBuffer, std::memory_order_acq_rel);
+ m_writeBuffer.store(oldRead, std::memory_order_release);
+}
+
+quint32 AudioCollector::readChunk(float* out, quint32 count) {
+ if (count == 0 || count > ac::CHUNK_SIZE) {
+ count = ac::CHUNK_SIZE;
+ }
+
+ auto* readBuffer = m_readBuffer.load(std::memory_order_acquire);
+ std::memcpy(out, readBuffer->data(), count * sizeof(float));
+
+ return count;
+}
+
+quint32 AudioCollector::readChunk(double* out, quint32 count) {
+ if (count == 0 || count > ac::CHUNK_SIZE) {
+ count = ac::CHUNK_SIZE;
+ }
+
+ auto* readBuffer = m_readBuffer.load(std::memory_order_acquire);
+ std::transform(readBuffer->begin(), readBuffer->begin() + count, out, [](float sample) {
+ return static_cast(sample);
+ });
+
+ return count;
+}
+
+AudioCollector::AudioCollector(QObject* parent)
+ : Service(parent)
+ , m_buffer1(ac::CHUNK_SIZE)
+ , m_buffer2(ac::CHUNK_SIZE)
+ , m_readBuffer(&m_buffer1)
+ , m_writeBuffer(&m_buffer2) {}
+
+AudioCollector::~AudioCollector() {
+ stop();
+}
+
+void AudioCollector::start() {
+ if (m_thread.joinable()) {
+ return;
+ }
+
+ clearBuffer();
+
+ m_thread = std::jthread([this](std::stop_token token) {
+ PipeWireWorker worker(token, this);
+ });
+}
+
+void AudioCollector::stop() {
+ if (m_thread.joinable()) {
+ m_thread.request_stop();
+ m_thread.join();
+ }
+}
+
+} // namespace caelestia::services
diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Services/audiocollector.hpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/audiocollector.hpp
new file mode 100644
index 0000000..cd63afa
--- /dev/null
+++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/audiocollector.hpp
@@ -0,0 +1,76 @@
+#pragma once
+
+#include "service.hpp"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace caelestia::services {
+
+namespace ac {
+
+constexpr quint32 SAMPLE_RATE = 44100;
+constexpr quint32 CHUNK_SIZE = 512;
+
+} // namespace ac
+
+class AudioCollector;
+
+class PipeWireWorker {
+public:
+ explicit PipeWireWorker(std::stop_token token, AudioCollector* collector);
+
+ void run();
+
+private:
+ pw_main_loop* m_loop;
+ pw_stream* m_stream;
+ spa_source* m_timer;
+ bool m_idle;
+
+ std::stop_token m_token;
+ AudioCollector* m_collector;
+
+ static void handleTimeout(void* data, uint64_t expirations);
+ void streamStateChanged(pw_stream_state state);
+ void processStream();
+
+ [[nodiscard]] unsigned int nextPowerOf2(unsigned int n);
+};
+
+class AudioCollector : public Service {
+ Q_OBJECT
+
+public:
+ AudioCollector(const AudioCollector&) = delete;
+ AudioCollector& operator=(const AudioCollector&) = delete;
+
+ static AudioCollector& instance();
+
+ void clearBuffer();
+ void loadChunk(const qint16* samples, quint32 count);
+ quint32 readChunk(float* out, quint32 count = 0);
+ quint32 readChunk(double* out, quint32 count = 0);
+
+private:
+ explicit AudioCollector(QObject* parent = nullptr);
+ ~AudioCollector();
+
+ std::jthread m_thread;
+ std::vector m_buffer1;
+ std::vector m_buffer2;
+ std::atomic*> m_readBuffer;
+ std::atomic*> m_writeBuffer;
+ quint32 m_sampleCount;
+
+ void reload();
+ void start() override;
+ void stop() override;
+};
+
+} // namespace caelestia::services
diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Services/audioprovider.cpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/audioprovider.cpp
new file mode 100644
index 0000000..1fac9ee
--- /dev/null
+++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/audioprovider.cpp
@@ -0,0 +1,78 @@
+#include "audioprovider.hpp"
+
+#include "audiocollector.hpp"
+#include "service.hpp"
+#include
+#include
+
+namespace caelestia::services {
+
+AudioProcessor::AudioProcessor(QObject* parent)
+ : QObject(parent) {}
+
+AudioProcessor::~AudioProcessor() {
+ stop();
+}
+
+void AudioProcessor::init() {
+ m_timer = new QTimer(this);
+ m_timer->setInterval(static_cast(ac::CHUNK_SIZE * 1000.0 / ac::SAMPLE_RATE));
+ connect(m_timer, &QTimer::timeout, this, &AudioProcessor::process);
+}
+
+void AudioProcessor::start() {
+ QMetaObject::invokeMethod(&AudioCollector::instance(), &AudioCollector::ref, Qt::QueuedConnection, this);
+ if (m_timer) {
+ m_timer->start();
+ }
+}
+
+void AudioProcessor::stop() {
+ if (m_timer) {
+ m_timer->stop();
+ }
+ QMetaObject::invokeMethod(&AudioCollector::instance(), &AudioCollector::unref, Qt::QueuedConnection, this);
+}
+
+AudioProvider::AudioProvider(QObject* parent)
+ : Service(parent)
+ , m_processor(nullptr)
+ , m_thread(nullptr) {}
+
+AudioProvider::~AudioProvider() {
+ if (m_thread) {
+ m_thread->quit();
+ m_thread->wait();
+ }
+}
+
+void AudioProvider::init() {
+ if (!m_processor) {
+ qWarning() << "AudioProvider::init: attempted to init with no processor set";
+ return;
+ }
+
+ m_thread = new QThread(this);
+ m_processor->moveToThread(m_thread);
+
+ connect(m_thread, &QThread::started, m_processor, &AudioProcessor::init);
+ connect(m_thread, &QThread::finished, m_processor, &AudioProcessor::deleteLater);
+ connect(m_thread, &QThread::finished, m_thread, &QThread::deleteLater);
+
+ m_thread->start();
+}
+
+void AudioProvider::start() {
+ if (m_processor) {
+ AudioCollector::instance(); // Create instance on main thread
+ QMetaObject::invokeMethod(m_processor, &AudioProcessor::start);
+ }
+}
+
+void AudioProvider::stop() {
+ if (m_processor) {
+ QMetaObject::invokeMethod(m_processor, &AudioProcessor::stop);
+ }
+}
+
+} // namespace caelestia::services
diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Services/audioprovider.hpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/audioprovider.hpp
new file mode 100644
index 0000000..5bf9bb0
--- /dev/null
+++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/audioprovider.hpp
@@ -0,0 +1,48 @@
+#pragma once
+
+#include "service.hpp"
+#include
+#include
+
+namespace caelestia::services {
+
+class AudioProcessor : public QObject {
+ Q_OBJECT
+
+public:
+ explicit AudioProcessor(QObject* parent = nullptr);
+ ~AudioProcessor();
+
+ void init();
+
+public slots:
+ void start();
+ void stop();
+
+protected:
+ virtual void process() = 0;
+
+private:
+ QTimer* m_timer;
+};
+
+class AudioProvider : public Service {
+ Q_OBJECT
+
+public:
+ explicit AudioProvider(QObject* parent = nullptr);
+ ~AudioProvider();
+
+protected:
+ AudioProcessor* m_processor;
+
+ void init();
+
+private:
+ QThread* m_thread;
+
+ void start() override;
+ void stop() override;
+};
+
+} // namespace caelestia::services
diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Services/beattracker.cpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/beattracker.cpp
new file mode 100644
index 0000000..93addc6
--- /dev/null
+++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/beattracker.cpp
@@ -0,0 +1,58 @@
+#include "beattracker.hpp"
+
+#include "audiocollector.hpp"
+#include "audioprovider.hpp"
+#include
+
+namespace caelestia::services {
+
+BeatProcessor::BeatProcessor(QObject* parent)
+ : AudioProcessor(parent)
+ , m_tempo(new_aubio_tempo("default", 1024, ac::CHUNK_SIZE, ac::SAMPLE_RATE))
+ , m_in(new_fvec(ac::CHUNK_SIZE))
+ , m_out(new_fvec(2)) {};
+
+BeatProcessor::~BeatProcessor() {
+ if (m_tempo) {
+ del_aubio_tempo(m_tempo);
+ }
+ if (m_in) {
+ del_fvec(m_in);
+ }
+ del_fvec(m_out);
+}
+
+void BeatProcessor::process() {
+ if (!m_tempo || !m_in) {
+ return;
+ }
+
+ AudioCollector::instance().readChunk(m_in->data);
+
+ aubio_tempo_do(m_tempo, m_in, m_out);
+ if (!qFuzzyIsNull(m_out->data[0])) {
+ emit beat(aubio_tempo_get_bpm(m_tempo));
+ }
+}
+
+BeatTracker::BeatTracker(QObject* parent)
+ : AudioProvider(parent)
+ , m_bpm(120) {
+ m_processor = new BeatProcessor();
+ init();
+
+ connect(static_cast(m_processor), &BeatProcessor::beat, this, &BeatTracker::updateBpm);
+}
+
+smpl_t BeatTracker::bpm() const {
+ return m_bpm;
+}
+
+void BeatTracker::updateBpm(smpl_t bpm) {
+ if (!qFuzzyCompare(bpm + 1.0f, m_bpm + 1.0f)) {
+ m_bpm = bpm;
+ emit bpmChanged();
+ }
+}
+
+} // namespace caelestia::services
diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Services/beattracker.hpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/beattracker.hpp
new file mode 100644
index 0000000..94738ce
--- /dev/null
+++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/beattracker.hpp
@@ -0,0 +1,49 @@
+#pragma once
+
+#include "audioprovider.hpp"
+#include
+#include
+
+namespace caelestia::services {
+
+class BeatProcessor : public AudioProcessor {
+ Q_OBJECT
+
+public:
+ explicit BeatProcessor(QObject* parent = nullptr);
+ ~BeatProcessor();
+
+signals:
+ void beat(smpl_t bpm);
+
+protected:
+ void process() override;
+
+private:
+ aubio_tempo_t* m_tempo;
+ fvec_t* m_in;
+ fvec_t* m_out;
+};
+
+class BeatTracker : public AudioProvider {
+ Q_OBJECT
+ QML_ELEMENT
+
+ Q_PROPERTY(smpl_t bpm READ bpm NOTIFY bpmChanged)
+
+public:
+ explicit BeatTracker(QObject* parent = nullptr);
+
+ [[nodiscard]] smpl_t bpm() const;
+
+signals:
+ void bpmChanged();
+ void beat(smpl_t bpm);
+
+private:
+ smpl_t m_bpm;
+
+ void updateBpm(smpl_t bpm);
+};
+
+} // namespace caelestia::services
diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Services/cavaprovider.cpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/cavaprovider.cpp
new file mode 100644
index 0000000..7b6cc1f
--- /dev/null
+++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/cavaprovider.cpp
@@ -0,0 +1,140 @@
+#include "cavaprovider.hpp"
+
+#include "audiocollector.hpp"
+#include "audioprovider.hpp"
+#include
+#include
+#include
+
+namespace caelestia::services {
+
+CavaProcessor::CavaProcessor(QObject* parent)
+ : AudioProcessor(parent)
+ , m_plan(nullptr)
+ , m_in(new double[ac::CHUNK_SIZE])
+ , m_out(nullptr)
+ , m_bars(0) {};
+
+CavaProcessor::~CavaProcessor() {
+ cleanup();
+ delete[] m_in;
+}
+
+void CavaProcessor::process() {
+ if (!m_plan || m_bars == 0 || !m_out) {
+ return;
+ }
+
+ const int count = static_cast(AudioCollector::instance().readChunk(m_in));
+
+ // Process in data via cava
+ cava_execute(m_in, count, m_out, m_plan);
+
+ // Apply monstercat filter
+ QVector values(m_bars);
+
+ // Left to right pass
+ const double inv = 1.0 / 1.5;
+ double carry = 0.0;
+ for (int i = 0; i < m_bars; ++i) {
+ carry = std::max(m_out[i], carry * inv);
+ values[i] = carry;
+ }
+
+ // Right to left pass and combine
+ carry = 0.0;
+ for (int i = m_bars - 1; i >= 0; --i) {
+ carry = std::max(m_out[i], carry * inv);
+ values[i] = std::max(values[i], carry);
+ }
+
+ // Update values
+ if (values != m_values) {
+ m_values = std::move(values);
+ emit valuesChanged(m_values);
+ }
+}
+
+void CavaProcessor::setBars(int bars) {
+ if (bars < 0) {
+ qWarning() << "CavaProcessor::setBars: bars must be greater than 0. Setting to 0.";
+ bars = 0;
+ }
+
+ if (m_bars != bars) {
+ m_bars = bars;
+ reload();
+ }
+}
+
+void CavaProcessor::reload() {
+ cleanup();
+ initCava();
+}
+
+void CavaProcessor::cleanup() {
+ if (m_plan) {
+ cava_destroy(m_plan);
+ m_plan = nullptr;
+ }
+
+ if (m_out) {
+ delete[] m_out;
+ m_out = nullptr;
+ }
+}
+
+void CavaProcessor::initCava() {
+ if (m_plan || m_bars == 0) {
+ return;
+ }
+
+ m_plan = cava_init(m_bars, ac::SAMPLE_RATE, 1, 1, 0.85, 50, 10000);
+ m_out = new double[static_cast(m_bars)];
+}
+
+CavaProvider::CavaProvider(QObject* parent)
+ : AudioProvider(parent)
+ , m_bars(0)
+ , m_values(m_bars, 0.0) {
+ m_processor = new CavaProcessor();
+ init();
+
+ connect(static_cast(m_processor), &CavaProcessor::valuesChanged, this, &CavaProvider::updateValues);
+}
+
+int CavaProvider::bars() const {
+ return m_bars;
+}
+
+void CavaProvider::setBars(int bars) {
+ if (bars < 0) {
+ qWarning() << "CavaProvider::setBars: bars must be greater than 0. Setting to 0.";
+ bars = 0;
+ }
+
+ if (m_bars == bars) {
+ return;
+ }
+
+ m_values.resize(bars, 0.0);
+ m_bars = bars;
+ emit barsChanged();
+ emit valuesChanged();
+
+ QMetaObject::invokeMethod(
+ static_cast(m_processor), &CavaProcessor::setBars, Qt::QueuedConnection, bars);
+}
+
+QVector CavaProvider::values() const {
+ return m_values;
+}
+
+void CavaProvider::updateValues(QVector values) {
+ if (values != m_values) {
+ m_values = values;
+ emit valuesChanged();
+ }
+}
+
+} // namespace caelestia::services
diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Services/cavaprovider.hpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/cavaprovider.hpp
new file mode 100644
index 0000000..c45e33f
--- /dev/null
+++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/cavaprovider.hpp
@@ -0,0 +1,64 @@
+#pragma once
+
+#include "audioprovider.hpp"
+#include
+#include
+
+namespace caelestia::services {
+
+class CavaProcessor : public AudioProcessor {
+ Q_OBJECT
+
+public:
+ explicit CavaProcessor(QObject* parent = nullptr);
+ ~CavaProcessor();
+
+ void setBars(int bars);
+
+signals:
+ void valuesChanged(QVector values);
+
+protected:
+ void process() override;
+
+private:
+ struct cava_plan* m_plan;
+ double* m_in;
+ double* m_out;
+
+ int m_bars;
+ QVector m_values;
+
+ void reload();
+ void initCava();
+ void cleanup();
+};
+
+class CavaProvider : public AudioProvider {
+ Q_OBJECT
+ QML_ELEMENT
+
+ Q_PROPERTY(int bars READ bars WRITE setBars NOTIFY barsChanged)
+
+ Q_PROPERTY(QVector values READ values NOTIFY valuesChanged)
+
+public:
+ explicit CavaProvider(QObject* parent = nullptr);
+
+ [[nodiscard]] int bars() const;
+ void setBars(int bars);
+
+ [[nodiscard]] QVector values() const;
+
+signals:
+ void barsChanged();
+ void valuesChanged();
+
+private:
+ int m_bars;
+ QVector m_values;
+
+ void updateValues(QVector values);
+};
+
+} // namespace caelestia::services
diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Services/service.cpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/service.cpp
new file mode 100644
index 0000000..bc21567
--- /dev/null
+++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/service.cpp
@@ -0,0 +1,26 @@
+#include "service.hpp"
+
+#include
+#include
+
+namespace caelestia::services {
+
+Service::Service(QObject* parent)
+ : QObject(parent) {}
+
+void Service::ref(QObject* sender) {
+ if (m_refs.isEmpty()) {
+ start();
+ }
+
+ QObject::connect(sender, &QObject::destroyed, this, &Service::unref);
+ m_refs << sender;
+}
+
+void Service::unref(QObject* sender) {
+ if (m_refs.remove(sender) && m_refs.isEmpty()) {
+ stop();
+ }
+}
+
+} // namespace caelestia::services
diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Services/service.hpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/service.hpp
new file mode 100644
index 0000000..f8af03a
--- /dev/null
+++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/service.hpp
@@ -0,0 +1,24 @@
+#pragma once
+
+#include
+#include
+
+namespace caelestia::services {
+
+class Service : public QObject {
+ Q_OBJECT
+
+public:
+ explicit Service(QObject* parent = nullptr);
+
+ void ref(QObject* sender);
+ void unref(QObject* sender);
+
+private:
+ QSet m_refs;
+
+ virtual void start() = 0;
+ virtual void stop() = 0;
+};
+
+} // namespace caelestia::services
diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Services/serviceref.cpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/serviceref.cpp
new file mode 100644
index 0000000..db1a3f2
--- /dev/null
+++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/serviceref.cpp
@@ -0,0 +1,36 @@
+#include "serviceref.hpp"
+
+#include "service.hpp"
+
+namespace caelestia::services {
+
+ServiceRef::ServiceRef(Service* service, QObject* parent)
+ : QObject(parent)
+ , m_service(service) {
+ if (m_service) {
+ m_service->ref(this);
+ }
+}
+
+Service* ServiceRef::service() const {
+ return m_service;
+}
+
+void ServiceRef::setService(Service* service) {
+ if (m_service == service) {
+ return;
+ }
+
+ if (m_service) {
+ m_service->unref(this);
+ }
+
+ m_service = service;
+ emit serviceChanged();
+
+ if (m_service) {
+ m_service->ref(this);
+ }
+}
+
+} // namespace caelestia::services
diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Services/serviceref.hpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/serviceref.hpp
new file mode 100644
index 0000000..d4d305c
--- /dev/null
+++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/serviceref.hpp
@@ -0,0 +1,28 @@
+#pragma once
+
+#include "service.hpp"
+#include
+#include
+
+namespace caelestia::services {
+
+class ServiceRef : public QObject {
+ Q_OBJECT
+ QML_ELEMENT
+
+ Q_PROPERTY(caelestia::services::Service* service READ service WRITE setService NOTIFY serviceChanged)
+
+public:
+ explicit ServiceRef(Service* service = nullptr, QObject* parent = nullptr);
+
+ [[nodiscard]] Service* service() const;
+ void setService(Service* service);
+
+signals:
+ void serviceChanged();
+
+private:
+ QPointer m_service;
+};
+
+} // namespace caelestia::services
diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/appdb.cpp b/.config/quickshell/caelestia/plugin/src/Caelestia/appdb.cpp
new file mode 100644
index 0000000..b074cf4
--- /dev/null
+++ b/.config/quickshell/caelestia/plugin/src/Caelestia/appdb.cpp
@@ -0,0 +1,312 @@
+#include "appdb.hpp"
+
+#include
+#include
+#include
+
+namespace caelestia {
+
+AppEntry::AppEntry(QObject* entry, unsigned int frequency, QObject* parent)
+ : QObject(parent)
+ , m_entry(entry)
+ , m_frequency(frequency) {
+ const auto mo = m_entry->metaObject();
+ const auto tmo = metaObject();
+
+ for (const auto& prop :
+ { "name", "comment", "execString", "startupClass", "genericName", "categories", "keywords" }) {
+ const auto metaProp = mo->property(mo->indexOfProperty(prop));
+ const auto thisMetaProp = tmo->property(tmo->indexOfProperty(prop));
+ QObject::connect(m_entry, metaProp.notifySignal(), this, thisMetaProp.notifySignal());
+ }
+
+ QObject::connect(m_entry, &QObject::destroyed, this, [this]() {
+ m_entry = nullptr;
+ deleteLater();
+ });
+}
+
+QObject* AppEntry::entry() const {
+ return m_entry;
+}
+
+quint32 AppEntry::frequency() const {
+ return m_frequency;
+}
+
+void AppEntry::setFrequency(unsigned int frequency) {
+ if (m_frequency != frequency) {
+ m_frequency = frequency;
+ emit frequencyChanged();
+ }
+}
+
+void AppEntry::incrementFrequency() {
+ m_frequency++;
+ emit frequencyChanged();
+}
+
+QString AppEntry::id() const {
+ if (!m_entry) {
+ return "";
+ }
+ return m_entry->property("id").toString();
+}
+
+QString AppEntry::name() const {
+ if (!m_entry) {
+ return "";
+ }
+ return m_entry->property("name").toString();
+}
+
+QString AppEntry::comment() const {
+ if (!m_entry) {
+ return "";
+ }
+ return m_entry->property("comment").toString();
+}
+
+QString AppEntry::execString() const {
+ if (!m_entry) {
+ return "";
+ }
+ return m_entry->property("execString").toString();
+}
+
+QString AppEntry::startupClass() const {
+ if (!m_entry) {
+ return "";
+ }
+ return m_entry->property("startupClass").toString();
+}
+
+QString AppEntry::genericName() const {
+ if (!m_entry) {
+ return "";
+ }
+ return m_entry->property("genericName").toString();
+}
+
+QString AppEntry::categories() const {
+ if (!m_entry) {
+ return "";
+ }
+ return m_entry->property("categories").toStringList().join(" ");
+}
+
+QString AppEntry::keywords() const {
+ if (!m_entry) {
+ return "";
+ }
+ return m_entry->property("keywords").toStringList().join(" ");
+}
+
+AppDb::AppDb(QObject* parent)
+ : QObject(parent)
+ , m_timer(new QTimer(this))
+ , m_uuid(QUuid::createUuid().toString()) {
+ m_timer->setSingleShot(true);
+ m_timer->setInterval(300);
+ QObject::connect(m_timer, &QTimer::timeout, this, &AppDb::updateApps);
+
+ auto db = QSqlDatabase::addDatabase("QSQLITE", m_uuid);
+ db.setDatabaseName(":memory:");
+ db.open();
+
+ QSqlQuery query(db);
+ query.exec("CREATE TABLE IF NOT EXISTS frequencies (id TEXT PRIMARY KEY, frequency INTEGER)");
+}
+
+QString AppDb::uuid() const {
+ return m_uuid;
+}
+
+QString AppDb::path() const {
+ return m_path;
+}
+
+void AppDb::setPath(const QString& path) {
+ auto newPath = path.isEmpty() ? ":memory:" : path;
+
+ if (m_path == newPath) {
+ return;
+ }
+
+ m_path = newPath;
+ emit pathChanged();
+
+ auto db = QSqlDatabase::database(m_uuid, false);
+ db.close();
+ db.setDatabaseName(newPath);
+ db.open();
+
+ QSqlQuery query(db);
+ query.exec("CREATE TABLE IF NOT EXISTS frequencies (id TEXT PRIMARY KEY, frequency INTEGER)");
+
+ updateAppFrequencies();
+}
+
+QObjectList AppDb::entries() const {
+ return m_entries;
+}
+
+void AppDb::setEntries(const QObjectList& entries) {
+ if (m_entries == entries) {
+ return;
+ }
+
+ m_entries = entries;
+ emit entriesChanged();
+
+ m_timer->start();
+}
+
+QStringList AppDb::favouriteApps() const {
+ return m_favouriteApps;
+}
+
+void AppDb::setFavouriteApps(const QStringList& favApps) {
+ if (m_favouriteApps == favApps) {
+ return;
+ }
+
+ m_favouriteApps = favApps;
+ emit favouriteAppsChanged();
+ m_favouriteAppsRegex.clear();
+ m_favouriteAppsRegex.reserve(m_favouriteApps.size());
+ for (const QString& item : std::as_const(m_favouriteApps)) {
+ const QRegularExpression re(regexifyString(item));
+ if (re.isValid()) {
+ m_favouriteAppsRegex << re;
+ } else {
+ qWarning() << "AppDb::setFavouriteApps: Regular expression is not valid: " << re.pattern();
+ }
+ }
+
+ emit appsChanged();
+}
+
+QString AppDb::regexifyString(const QString& original) const {
+ if (original.startsWith('^') && original.endsWith('$'))
+ return original;
+
+ const QString escaped = QRegularExpression::escape(original);
+ return QStringLiteral("^%1$").arg(escaped);
+}
+
+QQmlListProperty AppDb::apps() {
+ return QQmlListProperty(this, &getSortedApps());
+}
+
+void AppDb::incrementFrequency(const QString& id) {
+ auto db = QSqlDatabase::database(m_uuid);
+ QSqlQuery query(db);
+
+ query.prepare("INSERT INTO frequencies (id, frequency) "
+ "VALUES (:id, 1) "
+ "ON CONFLICT (id) DO UPDATE SET frequency = frequency + 1");
+ query.bindValue(":id", id);
+ query.exec();
+
+ auto* app = m_apps.value(id);
+ if (app) {
+ const auto before = getSortedApps();
+
+ app->incrementFrequency();
+
+ if (before != getSortedApps()) {
+ emit appsChanged();
+ }
+ } else {
+ qWarning() << "AppDb::incrementFrequency: could not find app with id" << id;
+ }
+}
+
+QList& AppDb::getSortedApps() const {
+ m_sortedApps = m_apps.values();
+ std::sort(m_sortedApps.begin(), m_sortedApps.end(), [this](AppEntry* a, AppEntry* b) {
+ bool aIsFav = isFavourite(a);
+ bool bIsFav = isFavourite(b);
+ if (aIsFav != bIsFav) {
+ return aIsFav;
+ }
+ if (a->frequency() != b->frequency()) {
+ return a->frequency() > b->frequency();
+ }
+ return a->name().localeAwareCompare(b->name()) < 0;
+ });
+ return m_sortedApps;
+}
+
+bool AppDb::isFavourite(const AppEntry* app) const {
+ for (const QRegularExpression& re : m_favouriteAppsRegex) {
+ if (re.match(app->id()).hasMatch()) {
+ return true;
+ }
+ }
+ return false;
+}
+
+quint32 AppDb::getFrequency(const QString& id) const {
+ auto db = QSqlDatabase::database(m_uuid);
+ QSqlQuery query(db);
+
+ query.prepare("SELECT frequency FROM frequencies WHERE id = :id");
+ query.bindValue(":id", id);
+
+ if (query.exec() && query.next()) {
+ return query.value(0).toUInt();
+ }
+
+ return 0;
+}
+
+void AppDb::updateAppFrequencies() {
+ const auto before = getSortedApps();
+
+ for (auto* app : std::as_const(m_apps)) {
+ app->setFrequency(getFrequency(app->id()));
+ }
+
+ if (before != getSortedApps()) {
+ emit appsChanged();
+ }
+}
+
+void AppDb::updateApps() {
+ bool dirty = false;
+
+ for (const auto& entry : std::as_const(m_entries)) {
+ const auto id = entry->property("id").toString();
+ if (!m_apps.contains(id)) {
+ dirty = true;
+ auto* const newEntry = new AppEntry(entry, getFrequency(id), this);
+ QObject::connect(newEntry, &QObject::destroyed, this, [id, this]() {
+ if (m_apps.remove(id)) {
+ emit appsChanged();
+ }
+ });
+ m_apps.insert(id, newEntry);
+ }
+ }
+
+ QSet newIds;
+ for (const auto& entry : std::as_const(m_entries)) {
+ newIds.insert(entry->property("id").toString());
+ }
+
+ for (auto it = m_apps.keyBegin(); it != m_apps.keyEnd(); ++it) {
+ const auto& id = *it;
+ if (!newIds.contains(id)) {
+ dirty = true;
+ m_apps.take(id)->deleteLater();
+ }
+ }
+
+ if (dirty) {
+ emit appsChanged();
+ }
+}
+
+} // namespace caelestia
diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/appdb.hpp b/.config/quickshell/caelestia/plugin/src/Caelestia/appdb.hpp
new file mode 100644
index 0000000..ce5f270
--- /dev/null
+++ b/.config/quickshell/caelestia/plugin/src/Caelestia/appdb.hpp
@@ -0,0 +1,116 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace caelestia {
+
+class AppEntry : public QObject {
+ Q_OBJECT
+ QML_ELEMENT
+ QML_UNCREATABLE("AppEntry instances can only be retrieved from an AppDb")
+
+ // The actual DesktopEntry, but we don't have access to the type so it's a QObject
+ Q_PROPERTY(QObject* entry READ entry CONSTANT)
+
+ Q_PROPERTY(quint32 frequency READ frequency NOTIFY frequencyChanged)
+ Q_PROPERTY(QString id READ id CONSTANT)
+ Q_PROPERTY(QString name READ name NOTIFY nameChanged)
+ Q_PROPERTY(QString comment READ comment NOTIFY commentChanged)
+ Q_PROPERTY(QString execString READ execString NOTIFY execStringChanged)
+ Q_PROPERTY(QString startupClass READ startupClass NOTIFY startupClassChanged)
+ Q_PROPERTY(QString genericName READ genericName NOTIFY genericNameChanged)
+ Q_PROPERTY(QString categories READ categories NOTIFY categoriesChanged)
+ Q_PROPERTY(QString keywords READ keywords NOTIFY keywordsChanged)
+
+public:
+ explicit AppEntry(QObject* entry, quint32 frequency, QObject* parent = nullptr);
+
+ [[nodiscard]] QObject* entry() const;
+
+ [[nodiscard]] quint32 frequency() const;
+ void setFrequency(quint32 frequency);
+ void incrementFrequency();
+
+ [[nodiscard]] QString id() const;
+ [[nodiscard]] QString name() const;
+ [[nodiscard]] QString comment() const;
+ [[nodiscard]] QString execString() const;
+ [[nodiscard]] QString startupClass() const;
+ [[nodiscard]] QString genericName() const;
+ [[nodiscard]] QString categories() const;
+ [[nodiscard]] QString keywords() const;
+
+signals:
+ void frequencyChanged();
+ void nameChanged();
+ void commentChanged();
+ void execStringChanged();
+ void startupClassChanged();
+ void genericNameChanged();
+ void categoriesChanged();
+ void keywordsChanged();
+
+private:
+ QObject* m_entry;
+ quint32 m_frequency;
+};
+
+class AppDb : public QObject {
+ Q_OBJECT
+ QML_ELEMENT
+
+ Q_PROPERTY(QString uuid READ uuid CONSTANT)
+ Q_PROPERTY(QString path READ path WRITE setPath NOTIFY pathChanged REQUIRED)
+ Q_PROPERTY(QObjectList entries READ entries WRITE setEntries NOTIFY entriesChanged REQUIRED)
+ Q_PROPERTY(QStringList favouriteApps READ favouriteApps WRITE setFavouriteApps NOTIFY favouriteAppsChanged REQUIRED)
+ Q_PROPERTY(QQmlListProperty apps READ apps NOTIFY appsChanged)
+
+public:
+ explicit AppDb(QObject* parent = nullptr);
+
+ [[nodiscard]] QString uuid() const;
+
+ [[nodiscard]] QString path() const;
+ void setPath(const QString& path);
+
+ [[nodiscard]] QObjectList entries() const;
+ void setEntries(const QObjectList& entries);
+
+ [[nodiscard]] QStringList favouriteApps() const;
+ void setFavouriteApps(const QStringList& favApps);
+
+ [[nodiscard]] QQmlListProperty apps();
+
+ Q_INVOKABLE void incrementFrequency(const QString& id);
+
+signals:
+ void pathChanged();
+ void entriesChanged();
+ void favouriteAppsChanged();
+ void appsChanged();
+
+private:
+ QTimer* m_timer;
+
+ const QString m_uuid;
+ QString m_path;
+ QObjectList m_entries;
+ QStringList m_favouriteApps; // unedited string list from qml
+ QList m_favouriteAppsRegex; // pre-regexified m_favouriteApps list
+ QHash m_apps;
+ mutable QList m_sortedApps;
+
+ QString regexifyString(const QString& original) const;
+ QList& getSortedApps() const;
+ bool isFavourite(const AppEntry* app) const;
+ quint32 getFrequency(const QString& id) const;
+ void updateAppFrequencies();
+ void updateApps();
+};
+
+} // namespace caelestia
diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/cutils.cpp b/.config/quickshell/caelestia/plugin/src/Caelestia/cutils.cpp
new file mode 100644
index 0000000..6e3bfa9
--- /dev/null
+++ b/.config/quickshell/caelestia/plugin/src/Caelestia/cutils.cpp
@@ -0,0 +1,131 @@
+#include "cutils.hpp"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace caelestia {
+
+void CUtils::saveItem(QQuickItem* target, const QUrl& path) {
+ this->saveItem(target, path, QRect(), QJSValue(), QJSValue());
+}
+
+void CUtils::saveItem(QQuickItem* target, const QUrl& path, const QRect& rect) {
+ this->saveItem(target, path, rect, QJSValue(), QJSValue());
+}
+
+void CUtils::saveItem(QQuickItem* target, const QUrl& path, QJSValue onSaved) {
+ this->saveItem(target, path, QRect(), onSaved, QJSValue());
+}
+
+void CUtils::saveItem(QQuickItem* target, const QUrl& path, QJSValue onSaved, QJSValue onFailed) {
+ this->saveItem(target, path, QRect(), onSaved, onFailed);
+}
+
+void CUtils::saveItem(QQuickItem* target, const QUrl& path, const QRect& rect, QJSValue onSaved) {
+ this->saveItem(target, path, rect, onSaved, QJSValue());
+}
+
+void CUtils::saveItem(QQuickItem* target, const QUrl& path, const QRect& rect, QJSValue onSaved, QJSValue onFailed) {
+ if (!target) {
+ qWarning() << "CUtils::saveItem: a target is required";
+ return;
+ }
+
+ if (!path.isLocalFile()) {
+ qWarning() << "CUtils::saveItem:" << path << "is not a local file";
+ return;
+ }
+
+ if (!target->window()) {
+ qWarning() << "CUtils::saveItem: unable to save target" << target << "without a window";
+ return;
+ }
+
+ auto scaledRect = rect;
+ const qreal scale = target->window()->devicePixelRatio();
+ if (rect.isValid() && !qFuzzyCompare(scale + 1.0, 2.0)) {
+ scaledRect =
+ QRectF(rect.left() * scale, rect.top() * scale, rect.width() * scale, rect.height() * scale).toRect();
+ }
+
+ const QSharedPointer grabResult = target->grabToImage();
+
+ QObject::connect(grabResult.data(), &QQuickItemGrabResult::ready, this,
+ [grabResult, scaledRect, path, onSaved, onFailed, this]() {
+ const auto future = QtConcurrent::run([=]() {
+ QImage image = grabResult->image();
+
+ if (scaledRect.isValid()) {
+ image = image.copy(scaledRect);
+ }
+
+ const QString file = path.toLocalFile();
+ const QString parent = QFileInfo(file).absolutePath();
+ return QDir().mkpath(parent) && image.save(file);
+ });
+
+ auto* watcher = new QFutureWatcher(this);
+ auto* engine = qmlEngine(this);
+
+ QObject::connect(watcher, &QFutureWatcher::finished, this, [=]() {
+ if (watcher->result()) {
+ if (onSaved.isCallable()) {
+ onSaved.call(
+ { QJSValue(path.toLocalFile()), engine->toScriptValue(QVariant::fromValue(path)) });
+ }
+ } else {
+ qWarning() << "CUtils::saveItem: failed to save" << path;
+ if (onFailed.isCallable()) {
+ onFailed.call({ engine->toScriptValue(QVariant::fromValue(path)) });
+ }
+ }
+ watcher->deleteLater();
+ });
+ watcher->setFuture(future);
+ });
+}
+
+bool CUtils::copyFile(const QUrl& source, const QUrl& target, bool overwrite) const {
+ if (!source.isLocalFile()) {
+ qWarning() << "CUtils::copyFile: source" << source << "is not a local file";
+ return false;
+ }
+ if (!target.isLocalFile()) {
+ qWarning() << "CUtils::copyFile: target" << target << "is not a local file";
+ return false;
+ }
+
+ if (overwrite && QFile::exists(target.toLocalFile())) {
+ if (!QFile::remove(target.toLocalFile())) {
+ qWarning() << "CUtils::copyFile: overwrite was specified but failed to remove" << target.toLocalFile();
+ return false;
+ }
+ }
+
+ return QFile::copy(source.toLocalFile(), target.toLocalFile());
+}
+
+bool CUtils::deleteFile(const QUrl& path) const {
+ if (!path.isLocalFile()) {
+ qWarning() << "CUtils::deleteFile: path" << path << "is not a local file";
+ return false;
+ }
+
+ return QFile::remove(path.toLocalFile());
+}
+
+QString CUtils::toLocalFile(const QUrl& url) const {
+ if (!url.isLocalFile()) {
+ qWarning() << "CUtils::toLocalFile: given url is not a local file" << url;
+ return QString();
+ }
+
+ return url.toLocalFile();
+}
+
+} // namespace caelestia
diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/cutils.hpp b/.config/quickshell/caelestia/plugin/src/Caelestia/cutils.hpp
new file mode 100644
index 0000000..027226d
--- /dev/null
+++ b/.config/quickshell/caelestia/plugin/src/Caelestia/cutils.hpp
@@ -0,0 +1,29 @@
+#pragma once
+
+#include
+#include
+#include
+
+namespace caelestia {
+
+class CUtils : public QObject {
+ Q_OBJECT
+ QML_ELEMENT
+ QML_SINGLETON
+
+public:
+ // clang-format off
+ Q_INVOKABLE void saveItem(QQuickItem* target, const QUrl& path);
+ Q_INVOKABLE void saveItem(QQuickItem* target, const QUrl& path, const QRect& rect);
+ Q_INVOKABLE void saveItem(QQuickItem* target, const QUrl& path, QJSValue onSaved);
+ Q_INVOKABLE void saveItem(QQuickItem* target, const QUrl& path, QJSValue onSaved, QJSValue onFailed);
+ Q_INVOKABLE void saveItem(QQuickItem* target, const QUrl& path, const QRect& rect, QJSValue onSaved);
+ Q_INVOKABLE void saveItem(QQuickItem* target, const QUrl& path, const QRect& rect, QJSValue onSaved, QJSValue onFailed);
+ // clang-format on
+
+ Q_INVOKABLE bool copyFile(const QUrl& source, const QUrl& target, bool overwrite = true) const;
+ Q_INVOKABLE bool deleteFile(const QUrl& path) const;
+ Q_INVOKABLE QString toLocalFile(const QUrl& url) const;
+};
+
+} // namespace caelestia
diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/imageanalyser.cpp b/.config/quickshell/caelestia/plugin/src/Caelestia/imageanalyser.cpp
new file mode 100644
index 0000000..880b078
--- /dev/null
+++ b/.config/quickshell/caelestia/plugin/src/Caelestia/imageanalyser.cpp
@@ -0,0 +1,223 @@
+#include "imageanalyser.hpp"
+
+#include
+#include