.
================================================
FILE: README.md
================================================
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": {
"activeWindow": {
"compact": false,
"inverted": false,
"showOnHover": true
},
"clock": {
"background": false,
"showDate": false,
"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"
}
],
"windowIcons": [
{
"regex": "steam(_app_(default|[0-9]+))?",
"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,
"hideNotifs": 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",
"lyricsDir": "~/Music/lyrics"
},
"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
}
]
},
"quickToggles": [
{
"id": "wifi",
"enabled": true
},
{
"id": "bluetooth",
"enabled": true
},
{
"id": "mic",
"enabled": true
},
{
"enabled": true,
"id": "settings"
},
{
"id": "gameMode",
"enabled": true
},
{
"id": "dnd",
"enabled": true
},
{
"id": "vpn",
"enabled": true
}
]
}
}
```
### 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 📈
================================================
FILE: assets/pam.d/fprint
================================================
#%PAM-1.0
auth required pam_fprintd.so max-tries=1
================================================
FILE: assets/pam.d/passwd
================================================
#%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
================================================
FILE: assets/shaders/fade.frag
================================================
#version 440
layout(location = 0) in vec2 qt_TexCoord0;
layout(location = 0) out vec4 fragColor;
layout(std140, binding = 0) uniform buf {
mat4 qt_Matrix;
float qt_Opacity;
float fadeMargin;
};
layout(binding = 1) uniform sampler2D source;
void main() {
vec4 tex = texture(source, qt_TexCoord0);
float factor = 1.0;
float margin = 0.1;
if (qt_TexCoord0.y < margin) {
factor = qt_TexCoord0.y / margin;
} else if (qt_TexCoord0.y > (1.0 - margin)) {
factor = (1.0 - qt_TexCoord0.y) / margin;
}
fragColor = tex * factor * qt_Opacity;
}
================================================
FILE: assets/shaders/opacitymask.frag
================================================
#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;
}
================================================
FILE: assets/wrap_term_launch.sh
================================================
#!/usr/bin/env sh
cat ~/.local/state/caelestia/sequences.txt 2>/dev/null
exec "$@"
================================================
FILE: components/Anim.qml
================================================
import qs.config
import QtQuick
NumberAnimation {
duration: Appearance.anim.durations.normal
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.anim.curves.standard
}
================================================
FILE: components/CAnim.qml
================================================
import qs.config
import QtQuick
ColorAnimation {
duration: Appearance.anim.durations.normal
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.anim.curves.standard
}
================================================
FILE: components/ConnectionHeader.qml
================================================
import qs.components
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
}
}
================================================
FILE: components/ConnectionInfoSection.qml
================================================
import qs.components
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
}
}
================================================
FILE: components/DashboardState.qml
================================================
import Quickshell
PersistentProperties {
property int currentTab
property date currentDate: new Date()
}
================================================
FILE: components/DrawerVisibilities.qml
================================================
import Quickshell
PersistentProperties {
property bool bar
property bool osd
property bool session
property bool launcher
property bool dashboard
property bool utilities
property bool sidebar
}
================================================
FILE: components/Logo.qml
================================================
import QtQuick
import QtQuick.Shapes
import qs.services
Item {
id: root
readonly property real designWidth: 128
readonly property real designHeight: 90.38
property color topColour: Colours.palette.m3primary
property color bottomColour: Colours.palette.m3onSurface
implicitWidth: designWidth
implicitHeight: designHeight
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"
}
}
}
}
================================================
FILE: components/MaterialIcon.qml
================================================
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
})
}
================================================
FILE: components/PropertyRow.qml
================================================
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
}
}
================================================
FILE: components/SectionContainer.qml
================================================
import qs.components
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
}
}
================================================
FILE: components/SectionHeader.qml
================================================
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
}
}
================================================
FILE: components/StateLayer.qml
================================================
import qs.services
import qs.config
import QtQuick
MouseArea {
id: root
property bool disabled
property bool showHoverBackground: true
property color color: Colours.palette.m3onSurface
// Pick up radius from parent if it has one (parent can be anything with a radius property)
property real radius: parent?.radius ?? 0 // qmllint disable missing-property
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
}
}
}
}
================================================
FILE: components/StyledClippingRect.qml
================================================
import Quickshell.Widgets
import QtQuick
ClippingRectangle {
id: root
color: "transparent"
Behavior on color {
CAnim {}
}
}
================================================
FILE: components/StyledRect.qml
================================================
import QtQuick
Rectangle {
id: root
color: "transparent"
Behavior on color {
CAnim {}
}
}
================================================
FILE: components/StyledText.qml
================================================
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
}
}
================================================
FILE: components/containers/StyledFlickable.qml
================================================
import ".."
import QtQuick
Flickable {
id: root
maximumFlickVelocity: 3000
rebound: Transition {
Anim {
properties: "x,y"
}
}
}
================================================
FILE: components/containers/StyledListView.qml
================================================
import ".."
import QtQuick
ListView {
id: root
maximumFlickVelocity: 3000
rebound: Transition {
Anim {
properties: "x,y"
}
}
}
================================================
FILE: components/containers/StyledWindow.qml
================================================
import Quickshell
import Quickshell.Wayland
PanelWindow {
required property string name
WlrLayershell.namespace: `caelestia-${name}`
color: "transparent"
}
================================================
FILE: components/controls/CircularIndicator.qml
================================================
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;
}
}
}
================================================
FILE: components/controls/CircularProgress.qml
================================================
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
}
}
}
}
================================================
FILE: components/controls/CollapsibleSection.qml
================================================
import ".."
import qs.components
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
default property alias content: contentColumn.data
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 {
function onClicked(): void {
root.toggleRequested();
root.expanded = !root.expanded;
}
anchors.fill: parent
color: Colours.palette.m3onSurface
radius: Appearance.rounding.normal
showHoverBackground: false
}
}
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
}
}
}
}
================================================
FILE: components/controls/CustomMouseArea.qml
================================================
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;
}
}
}
================================================
FILE: components/controls/CustomSpinBox.qml
================================================
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
property bool isEditing: false
property string displayText: root.value.toString()
signal valueModified(value: real)
spacing: Appearance.spacing.small
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
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);
}
color: Colours.palette.m3onPrimary
onPressAndHold: timer.start()
onReleased: timer.stop()
}
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
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);
}
color: Colours.palette.m3onPrimary
onPressAndHold: timer.start()
onReleased: timer.stop()
}
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();
}
}
}
================================================
FILE: components/controls/FilledSlider.qml
================================================
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
}
}
}
================================================
FILE: components/controls/IconButton.qml
================================================
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
function onClicked(): void {
if (root.toggle)
root.internalChecked = !root.internalChecked;
root.clicked();
}
color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour
disabled: root.disabled
}
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
}
}
}
================================================
FILE: components/controls/IconTextButton.qml
================================================
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
function onClicked(): void {
if (root.toggle)
root.internalChecked = !root.internalChecked;
root.clicked();
}
color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour
}
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 {}
}
}
================================================
FILE: components/controls/Menu.qml
================================================
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 {
function onClicked(): void {
root.itemSelected(item.modelData);
root.active = item.modelData;
root.expanded = false;
}
color: item.active ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface
disabled: !root.expanded
}
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 {
asynchronous: true
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
}
}
}
================================================
FILE: components/controls/MenuItem.qml
================================================
import QtQuick
QtObject {
required property string text
property string icon
property string trailingIcon
property string activeIcon: icon
property string activeText: text
signal clicked
}
================================================
FILE: components/controls/SpinBoxRow.qml
================================================
import ".."
import qs.components
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);
}
}
}
}
================================================
FILE: components/controls/SplitButton.qml
================================================
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
function onClicked(): void {
root.active?.clicked();
}
rect.topRightRadius: parent.topRightRadius
rect.bottomRightRadius: parent.bottomRightRadius
color: root.textColour
disabled: root.disabled
}
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
function onClicked(): void {
root.expanded = !root.expanded;
}
rect.topLeftRadius: parent.topLeftRadius
rect.bottomLeftRadius: parent.bottomLeftRadius
color: root.textColour
disabled: root.disabled
}
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
}
}
}
================================================
FILE: components/controls/SplitButtonRow.qml
================================================
pragma ComponentBehavior: Bound
import ".."
import qs.components
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);
}
}
}
}
================================================
FILE: components/controls/StyledInputField.qml
================================================
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();
}
}
}
}
================================================
FILE: components/controls/StyledRadioButton.qml
================================================
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 {
function onClicked(): void {
root.click();
}
anchors.margins: -Appearance.padding.smaller
color: root.checked ? Colours.palette.m3onSurface : Colours.palette.m3primary
z: -1
}
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
}
}
================================================
FILE: components/controls/StyledScrollBar.qml
================================================
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
property bool _updatingFromFlickable: false
property bool _updatingFromUser: false
onHoveredChanged: {
if (hovered)
shouldBeActive = true;
else
shouldBeActive = flickable.moving;
}
// 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;
}
}
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 {}
}
}
// Sync nonAnimPosition with flickable when not animating
Connections {
function onContentYChanged() {
if (!root.animating && !fullMouse.pressed) {
root._updatingFromFlickable = true;
const contentHeight = root.flickable.contentHeight;
const height = root.flickable.height;
if (contentHeight > height) {
root.nonAnimPosition = Math.max(0, Math.min(1, root.flickable.contentY / (contentHeight - height)));
} else {
root.nonAnimPosition = 0;
}
root._updatingFromFlickable = false;
}
}
target: root.flickable
}
Connections {
function onMovingChanged(): void {
if (root.flickable.moving)
root.shouldBeActive = true;
else
hideDelay.restart();
}
target: root.flickable
}
Timer {
id: hideDelay
interval: 600
onTriggered: root.shouldBeActive = root.flickable.moving || root.hovered
}
CustomMouseArea {
id: fullMouse
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));
}
}
}
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));
}
}
}
}
Behavior on position {
enabled: !fullMouse.pressed
Anim {}
}
}
================================================
FILE: components/controls/StyledSlider.qml
================================================
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
}
}
}
================================================
FILE: components/controls/StyledSwitch.qml
================================================
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
}
}
================================================
FILE: components/controls/StyledTextField.qml
================================================
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 {
function onCursorPositionChanged(): void {
if (root.activeFocus && root.cursorVisible) {
cursor.opacity = 1;
cursor.disableBlink = true;
enableBlink.restart();
}
}
target: root
}
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 {}
}
}
================================================
FILE: components/controls/SwitchRow.qml
================================================
import ".."
import qs.components
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);
}
}
}
}
================================================
FILE: components/controls/TextButton.qml
================================================
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
function onClicked(): void {
if (root.toggle)
root.internalChecked = !root.internalChecked;
root.clicked();
}
color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour
}
StyledText {
id: label
anchors.centerIn: parent
color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour
}
Behavior on radius {
Anim {}
}
}
================================================
FILE: components/controls/ToggleButton.qml
================================================
pragma ComponentBehavior: Bound
import ".."
import qs.components
import qs.components.controls
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;
}
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`]
Connections {
function onContainsMouseChanged() {
const newHovered = toggleStateLayer.containsMouse;
if (root.hovered !== newHovered) {
root.hovered = newHovered;
}
}
target: toggleStateLayer
}
StateLayer {
id: toggleStateLayer
function onClicked(): void {
root.clicked();
}
color: root.toggled ? Colours.palette[`m3on${root.accent}`] : Colours.palette[`m3on${root.accent}Container`]
}
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 {
asynchronous: true
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
asynchronous: true
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
}
}
================================================
FILE: components/controls/ToggleRow.qml
================================================
import qs.components
import qs.components.controls
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
}
}
================================================
FILE: components/controls/Tooltip.qml
================================================
import ".."
import qs.components.effects
import qs.services
import qs.config
import QtQuick
import QtQuick.Controls
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
}
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;
});
}
// 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);
}
}
Component.onCompleted: {
if (tooltipVisible) {
updatePosition();
}
}
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
}
}
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
}
}
Connections {
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();
}
target: root.target
}
// Monitor hover state
Connections {
function onHoveredChanged() {
if (target.hovered) {
showTimer.start();
if (timeout > 0) {
hideTimer.stop();
hideTimer.start();
}
} else {
showTimer.stop();
hideTimer.stop();
tooltipVisible = false;
}
}
target: root.target
}
}
================================================
FILE: components/effects/ColouredIcon.qml
================================================
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
}
}
================================================
FILE: components/effects/Colouriser.qml
================================================
import ".."
import QtQuick
import QtQuick.Effects
MultiEffect {
property color sourceColor: "black"
colorization: 1
brightness: 1 - sourceColor.hslLightness
Behavior on colorizationColor {
CAnim {}
}
}
================================================
FILE: components/effects/Elevation.qml
================================================
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 {}
}
}
================================================
FILE: components/effects/InnerBorder.qml
================================================
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
}
}
}
================================================
FILE: components/effects/OpacityMask.qml
================================================
import Quickshell
import QtQuick
ShaderEffect {
required property Item source
required property Item maskSource
fragmentShader: Quickshell.shellPath("assets/shaders/opacitymask.frag.qsb")
}
================================================
FILE: components/filedialog/CurrentItem.qml
================================================
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 {
function onCurrentItemChanged(): void {
if (root.currentItem)
content.text = qsTr(`"%1" selected`).arg(root.currentItem.modelData.name);
}
target: root
}
}
}
Behavior on implicitWidth {
enabled: !!root.currentItem
Anim {}
}
Behavior on implicitHeight {
Anim {}
}
}
================================================
FILE: components/filedialog/DialogButtons.qml
================================================
import qs.components
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 {
function onClicked(): void {
root.dialog.accepted(root.folder.currentItem.modelData.path);
}
disabled: !root.dialog.selectionValid
}
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")
}
}
}
}
================================================
FILE: components/filedialog/FileDialog.qml
================================================
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 {}
}
}
}
================================================
FILE: components/filedialog/FolderContents.qml
================================================
pragma ComponentBehavior: Bound
import qs.components
import qs.components.filedialog
import qs.components.controls
import qs.components.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
readonly property FileEntry currentItem: view.currentItem as FileEntry
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 {
asynchronous: true
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 as FileEntry).modelData.path);
}
Keys.onEnterPressed: {
if (root.dialog.selectionValid)
root.dialog.accepted((currentItem as FileEntry).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: FileEntry {}
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
}
component FileEntry: 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 {
function onClicked(): void {
view.currentIndex = item.index;
}
onDoubleClicked: {
if (item.modelData.isDir)
root.dialog.cwd.push(item.modelData.name);
else if (root.dialog.selectionValid)
root.dialog.accepted(item.modelData.path);
}
}
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 {}
}
}
}
================================================
FILE: components/filedialog/HeaderBar.qml
================================================
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 {
function onClicked(): void {
root.dialog.cwd.pop();
}
radius: Appearance.rounding.small
disabled: root.dialog.cwd.length === 1
}
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 {
asynchronous: true
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 {
asynchronous: true
anchors.fill: parent
active: folder.index < root.dialog.cwd.length - 1
sourceComponent: StateLayer {
function onClicked(): void {
root.dialog.cwd = root.dialog.cwd.slice(0, folder.index + 1);
}
radius: Appearance.rounding.small
}
}
Loader {
id: homeIcon
asynchronous: true
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
}
}
}
}
}
================================================
FILE: components/filedialog/Sidebar.qml
================================================
pragma ComponentBehavior: Bound
import qs.components
import qs.components.filedialog
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 {
function onClicked(): void {
if (place.modelData === "Home")
root.dialog.cwd = ["Home"];
else
root.dialog.cwd = ["Home", place.modelData];
}
color: place.selected ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface
}
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
}
}
}
}
}
}
================================================
FILE: components/filedialog/Sizes.qml
================================================
pragma Singleton
import Quickshell
Singleton {
property int itemWidth: 103
property int sidebarWidth: 200
}
================================================
FILE: components/images/CachingIconImage.qml
================================================
pragma ComponentBehavior: Bound
import qs.utils
import Quickshell.Widgets
import QtQuick
Item {
id: root
// Easier (and more efficient) to ignore it than to check type and cast
readonly property int status: loader.item?.status ?? Image.Null // qmllint disable missing-property
readonly property real actualSize: Math.min(width, height)
property real implicitSize
property url source
implicitWidth: implicitSize
implicitHeight: implicitSize
Loader {
id: loader
asynchronous: true
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
}
}
}
================================================
FILE: components/images/CachingImage.qml
================================================
import qs.utils
import Caelestia.Internal
import Quickshell
import QtQuick
Image {
id: root
property alias path: manager.path
asynchronous: true
fillMode: Image.PreserveAspectCrop
Connections {
function onDevicePixelRatioChanged(): void {
manager.updateSource();
}
target: QsWindow.window
}
CachingImageManager {
id: manager
item: root
cacheDir: Qt.resolvedUrl(Paths.imagecache)
}
}
================================================
FILE: components/misc/CustomShortcut.qml
================================================
import Quickshell.Hyprland
GlobalShortcut {
appid: "caelestia"
}
================================================
FILE: components/misc/Ref.qml
================================================
import QtQuick
QtObject {
required property var service
Component.onCompleted: service.refCount++
Component.onDestruction: service.refCount--
}
================================================
FILE: components/widgets/ExtraIndicator.qml
================================================
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
}
}
}
================================================
FILE: config/Appearance.qml
================================================
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
}
================================================
FILE: config/AppearanceConfig.qml
================================================
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
}
}
================================================
FILE: config/BackgroundConfig.qml
================================================
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
}
}
================================================
FILE: config/BarConfig.qml
================================================
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 int maxWindowIcons: 0 // 0 = unlimited
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: []
property list windowIcons: [
{
regex: "steam(_app_(default|[0-9]+))?",
icon: "sports_esports"
}
]
}
component ActiveWindow: JsonObject {
property bool compact: false
property bool inverted: false
property bool showOnHover: true
}
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 background: false
property bool showDate: false
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
}
}
================================================
FILE: config/BorderConfig.qml
================================================
import Quickshell.Io
import qs.config
JsonObject {
property int thickness: Config.appearance.padding.normal
property int rounding: Config.appearance.rounding.large
readonly property int minThickness: 2
readonly property int clampedThickness: Math.max(minThickness, thickness)
}
================================================
FILE: config/Config.qml
================================================
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
property bool recentlySaved: false
// Public save function - call this to persist config changes
function save(): void {
saveTimer.restart();
recentlySaved = true;
recentSaveCooldown.restart();
}
// 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,
excludedScreens: general.excludedScreens,
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,
maxWindowIcons: bar.workspaces.maxWindowIcons,
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,
windowIcons: bar.workspaces.windowIcons
},
activeWindow: {
compact: bar.activeWindow.compact,
inverted: bar.activeWindow.inverted,
showOnHover: bar.activeWindow.showOnHover
},
tray: {
background: bar.tray.background,
recolour: bar.tray.recolour,
compact: bar.tray.compact,
iconSubs: bar.tray.iconSubs,
hiddenIcons: bar.tray.hiddenIcons
},
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: {
background: bar.clock.background,
showDate: bar.clock.showDate,
showIcon: bar.clock.showIcon
},
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,
mediaUpdateInterval: dashboard.mediaUpdateInterval,
resourceUpdateInterval: dashboard.resourceUpdateInterval,
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
}
};
}
function serializeControlCenter(): var {
return {};
}
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
},
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
};
}
function serializeOsd(): var {
return {
enabled: osd.enabled,
hideDelay: osd.hideDelay,
enableBrightness: osd.enableBrightness,
enableMicrophone: osd.enableMicrophone
};
}
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
}
};
}
function serializeWinfo(): var {
return {};
}
function serializeLock(): var {
return {
recolourLogo: lock.recolourLogo,
enableFprint: lock.enableFprint,
maxFprintTries: lock.maxFprintTries,
hideNotifs: lock.hideNotifs
};
}
function serializeUtilities(): var {
return {
enabled: utilities.enabled,
maxToasts: utilities.maxToasts,
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
},
quickToggles: utilities.quickToggles
};
}
function serializeSidebar(): var {
return {
enabled: sidebar.enabled,
dragThreshold: sidebar.dragThreshold
};
}
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,
showLyrics: services.showLyrics
};
}
function serializePaths(): var {
return {
wallpaperDir: paths.wallpaperDir,
lyricsDir: paths.lyricsDir,
sessionGif: paths.sessionGif,
mediaGif: paths.mediaGif
};
}
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 = root.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: {
root.recentlySaved = false;
}
}
FileView {
id: fileView
path: `${Paths.config}/shell.json`
watchChanges: true
onFileChanged: {
// Prevent reload loop - don't reload if we just saved
if (!root.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 && !root.recentlySaved && elapsed > 0) {
Toaster.toast(qsTr("Config loaded"), qsTr("Config loaded in %1ms").arg(elapsed), "rule_settings");
} else if (adapter.utilities.toasts.configLoaded && root.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 {}
}
}
}
================================================
FILE: config/ControlCenterConfig.qml
================================================
import Quickshell.Io
JsonObject {
property Sizes sizes: Sizes {}
component Sizes: JsonObject {
property real heightMult: 0.7
property real ratio: 16 / 9
}
}
================================================
FILE: config/DashboardConfig.qml
================================================
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 bool showDashboard: true
property bool showMedia: true
property bool showPerformance: true
property bool showWeather: true
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
}
}
================================================
FILE: config/GeneralConfig.qml
================================================
import Quickshell.Io
JsonObject {
property string logo: ""
property list excludedScreens: []
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
}
}
================================================
FILE: config/LauncherConfig.qml
================================================
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
}
]
}
================================================
FILE: config/LockConfig.qml
================================================
import Quickshell.Io
JsonObject {
property bool recolourLogo: false
property bool enableFprint: true
property int maxFprintTries: 3
property bool hideNotifs: false
property Sizes sizes: Sizes {}
component Sizes: JsonObject {
property real heightMult: 0.7
property real ratio: 16 / 9
property int centerWidth: 600
}
}
================================================
FILE: config/NotifsConfig.qml
================================================
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
}
}
================================================
FILE: config/OsdConfig.qml
================================================
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
}
}
================================================
FILE: config/ServiceConfig.qml
================================================
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"
}
]
property bool showLyrics: true
}
================================================
FILE: config/SessionConfig.qml
================================================
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
}
}
================================================
FILE: config/SidebarConfig.qml
================================================
import Quickshell.Io
JsonObject {
property bool enabled: true
property int dragThreshold: 80
property Sizes sizes: Sizes {}
component Sizes: JsonObject {
property int width: 430
}
}
================================================
FILE: config/UserPaths.qml
================================================
import qs.utils
import Quickshell.Io
JsonObject {
property string wallpaperDir: `${Paths.pictures}/Wallpapers`
property string lyricsDir: `${Paths.home}/Music/lyrics/`
property string sessionGif: "root:/assets/kurukuru.gif"
property string mediaGif: "root:/assets/bongocat.gif"
}
================================================
FILE: config/UtilitiesConfig.qml
================================================
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"]
}
property list quickToggles: [
{
id: "wifi",
enabled: true
},
{
id: "bluetooth",
enabled: true
},
{
id: "mic",
enabled: true
},
{
id: "settings",
enabled: true
},
{
id: "gameMode",
enabled: true
},
{
id: "dnd",
enabled: true
},
{
id: "vpn",
enabled: false
}
]
}
================================================
FILE: config/WInfoConfig.qml
================================================
import Quickshell.Io
JsonObject {
property Sizes sizes: Sizes {}
component Sizes: JsonObject {
property real heightMult: 0.7
property real detailsWidth: 500
}
}
================================================
FILE: extras/CMakeLists.txt
================================================
# 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})
================================================
FILE: extras/version.cpp
================================================
#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
<< ", distributed 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;
}
================================================
FILE: flake.nix
================================================
{
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;
};
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;
};
}
================================================
FILE: modules/BatteryMonitor.qml
================================================
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 {
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;
}
}
target: UPower
}
Connections {
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();
}
}
target: UPower.displayDevice
}
Timer {
id: hibernateTimer
interval: 5000
onTriggered: Quickshell.execDetached(["systemctl", "hibernate"])
}
}
================================================
FILE: modules/IdleMonitors.qml
================================================
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)
}
}
}
================================================
FILE: modules/Shortcuts.qml
================================================
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 {
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");
}
target: "drawers"
}
IpcHandler {
function open(): void {
WindowFactory.create();
}
target: "controlCenter"
}
IpcHandler {
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);
}
target: "toaster"
}
}
================================================
FILE: modules/areapicker/AreaPicker.qml
================================================
pragma ComponentBehavior: Bound
import qs.components.containers
import qs.components.misc
import qs.services
import Quickshell
import Quickshell.Wayland
import Quickshell.Io
Scope {
LazyLoader {
id: root
property bool freeze
property bool closing
property bool clipboardOnly
Variants {
model: Screens.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 {
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;
}
target: "picker"
}
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;
}
}
}
================================================
FILE: modules/areapicker/Picker.qml
================================================
pragma ComponentBehavior: Bound
import qs.components
import qs.services
import qs.config
import Caelestia
import Quickshell
import Quickshell.Io
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
}
}
Process {
running: true
command: ["hyprctl", "cursorpos", "-j"]
stdout: StdioCollector {
onStreamFinished: {
const pos = JSON.parse(text);
root.checkClientRects(pos.x - root.screen.x, pos.y - root.screen.y);
}
}
}
Loader {
id: screencopy
asynchronous: true
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
}
}
================================================
FILE: modules/background/Background.qml
================================================
pragma ComponentBehavior: Bound
import qs.components.containers
import qs.services
import qs.config
import Quickshell
import Quickshell.Wayland
import QtQuick
Loader {
asynchronous: true
active: Config.background.enabled
sourceComponent: Variants {
model: Screens.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
asynchronous: true
anchors.fill: parent
active: Config.background.wallpaperEnabled
sourceComponent: Wallpaper {}
}
Visualiser {
anchors.fill: parent
screen: win.modelData
wallpaper: wallpaper
}
}
Loader {
id: clockLoader
asynchronous: true
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
}
}
}
}
}
================================================
FILE: modules/background/DesktopClock.qml
================================================
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 {
asynchronous: true
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 {
asynchronous: true
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
}
}
}
================================================
FILE: modules/background/Visualiser.qml
================================================
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 {
asynchronous: true
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 {
asynchronous: true
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
}
}
}
}
}
================================================
FILE: modules/background/Wallpaper.qml
================================================
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
property bool completed
onSourceChanged: {
if (!source)
current = null;
else if (current === one)
two.update();
else
one.update();
}
Component.onCompleted: {
if (source)
Qt.callLater(() => {
one.update();
completed = true;
});
}
Loader {
asynchronous: true
anchors.fill: parent
active: root.completed && !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 {
function onClicked(): void {
dialog.open();
}
radius: parent.radius
color: Colours.palette.m3onPrimary
}
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"
}
}
}
}
================================================
FILE: modules/bar/Bar.qml
================================================
pragma ComponentBehavior: Bound
import qs.components
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 DrawerVisibilities 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 loader = repeater.itemAt(i) as WrappedLoader;
if (loader?.enabled && loader.id === "tray") {
(loader.item as Tray).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;
if (id === "statusIcons" && Config.bar.popouts.statusIcons) {
const items = (ch.item as StatusIcons).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) {
const tray = ch.item as Tray;
if (!Config.bar.tray.compact || (tray.expanded && !tray.expandIcon.contains(mapToItem(tray.expandIcon, tray.implicitWidth / 2, y)))) {
const index = Math.floor(((y - top - tray.padding * 2 + tray.spacing) / tray.layout.implicitHeight) * tray.items.count);
const trayItem = tray.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;
tray.expanded = true;
}
} else if (id === "activeWindow" && Config.bar.popouts.activeWindow && Config.bar.activeWindow.showOnHover) {
popouts.currentName = id.toLowerCase();
popouts.currentCenter = (ch.item as Item).mapToItem(root, 0, (ch.item as Item).implicitHeight / 2).y ?? 0;
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 {
Layout.fillWidth: true
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;
}
asynchronous: true
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
}
}
================================================
FILE: modules/bar/BarWrapper.qml
================================================
pragma ComponentBehavior: Bound
import qs.components
import qs.config
import qs.modules.bar.popouts as BarPopouts
import Quickshell
import QtQuick
Item {
id: root
required property ShellScreen screen
required property DrawerVisibilities visibilities
required property BarPopouts.Wrapper popouts
required property bool disabled
readonly property int clampedWidth: Math.max(Config.border.minThickness, implicitWidth)
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 as Bar)?.closeTray();
}
function checkPopout(y: real): void {
(content.item as Bar)?.checkPopout(y);
}
function handleWheel(y: real, angleDelta: point): void {
(content.item as Bar)?.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
}
}
}
================================================
FILE: modules/bar/components/ActiveWindow.qml
================================================
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 string windowTitle: {
const title = Hypr.activeToplevel?.title;
if (!title)
return qsTr("Desktop");
if (Config.bar.activeWindow.compact) {
// " - " (standard hyphen), " — " (em dash), " – " (en dash)
const parts = title.split(/\s+[\-\u2013\u2014]\s+/);
if (parts.length > 1)
return parts[parts.length - 1].trim();
}
return title;
}
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
Loader {
asynchronous: true
anchors.fill: parent
active: !Config.bar.activeWindow.showOnHover
sourceComponent: MouseArea {
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
onPositionChanged: {
const popouts = root.bar.popouts;
if (popouts.hasCurrent && popouts.currentName !== "activewindow")
popouts.hasCurrent = false;
}
onClicked: {
const popouts = root.bar.popouts;
if (popouts.hasCurrent) {
popouts.hasCurrent = false;
} else {
popouts.currentName = "activewindow";
popouts.currentCenter = root.mapToItem(root.bar, 0, root.implicitHeight / 2).y;
popouts.hasCurrent = true;
}
}
}
}
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: root.windowTitle
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 ? -text.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 {}
}
}
}
================================================
FILE: modules/bar/components/Clock.qml
================================================
pragma ComponentBehavior: Bound
import qs.components
import qs.services
import qs.config
import QtQuick
StyledRect {
id: root
readonly property color colour: Colours.palette.m3tertiary
readonly property int padding: Config.bar.clock.background ? Appearance.padding.normal : Appearance.padding.small
implicitWidth: Config.bar.sizes.innerWidth
implicitHeight: layout.implicitHeight + root.padding * 2
color: Qt.alpha(Colours.tPalette.m3surfaceContainer, Config.bar.clock.background ? Colours.tPalette.m3surfaceContainer.a : 0)
radius: Appearance.rounding.full
Column {
id: layout
anchors.centerIn: parent
spacing: Appearance.spacing.small
Loader {
asynchronous: true
anchors.horizontalCenter: parent.horizontalCenter
active: Config.bar.clock.showIcon
visible: active
sourceComponent: MaterialIcon {
text: "calendar_month"
color: root.colour
}
}
StyledText {
anchors.horizontalCenter: parent.horizontalCenter
visible: Config.bar.clock.showDate
horizontalAlignment: StyledText.AlignHCenter
text: Time.format("ddd\nd")
font.pointSize: Appearance.font.size.smaller
font.family: Appearance.font.family.sans
color: root.colour
}
Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
visible: Config.bar.clock.showDate
height: visible ? 1 : 0
width: parent.width * 0.8
color: root.colour
opacity: 0.2
}
StyledText {
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
}
}
}
================================================
FILE: modules/bar/components/OsIcon.qml
================================================
import qs.components
import qs.components.effects
import qs.services
import qs.config
import qs.utils
import QtQuick
Item {
id: root
implicitWidth: Math.round(Appearance.font.size.large * 1.2)
implicitHeight: Math.round(Appearance.font.size.large * 1.2)
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
const visibilities = Visibilities.getForActive();
visibilities.launcher = !visibilities.launcher;
}
}
Loader {
asynchronous: true
anchors.centerIn: parent
sourceComponent: SysInfo.isDefaultLogo ? caelestiaLogo : distroIcon
}
Component {
id: caelestiaLogo
Logo {
implicitWidth: Math.round(Appearance.font.size.large * 1.6)
implicitHeight: Math.round(Appearance.font.size.large * 1.6)
}
}
Component {
id: distroIcon
ColouredIcon {
source: SysInfo.osLogo
implicitSize: Math.round(Appearance.font.size.large * 1.2)
colour: Colours.palette.m3tertiary
}
}
}
================================================
FILE: modules/bar/components/Power.qml
================================================
import qs.components
import qs.services
import qs.config
import QtQuick
Item {
id: root
required property DrawerVisibilities visibilities
implicitWidth: icon.implicitHeight + Appearance.padding.small * 2
implicitHeight: icon.implicitHeight
StateLayer {
// Cursed workaround to make the height larger than the parent
function onClicked(): void {
root.visibilities.session = !root.visibilities.session;
}
anchors.fill: undefined
anchors.centerIn: parent
implicitWidth: implicitHeight
implicitHeight: icon.implicitHeight + Appearance.padding.small * 2
radius: Appearance.rounding.full
}
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
}
}
================================================
FILE: modules/bar/components/Settings.qml
================================================
import qs.components
import qs.modules.controlcenter
import qs.services
import qs.config
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
function onClicked(): void {
WindowFactory.create(null, {
active: "network"
});
}
anchors.fill: undefined
anchors.centerIn: parent
implicitWidth: implicitHeight
implicitHeight: icon.implicitHeight + Appearance.padding.small * 2
radius: Appearance.rounding.full
}
MaterialIcon {
id: icon
anchors.centerIn: parent
anchors.horizontalCenterOffset: -1
text: "settings"
color: Colours.palette.m3onSurface
font.bold: true
font.pointSize: Appearance.font.size.normal
}
}
================================================
FILE: modules/bar/components/SettingsIcon.qml
================================================
import qs.components
import qs.modules.controlcenter
import qs.services
import qs.config
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
function onClicked(): void {
WindowFactory.create(null, {
active: "network"
});
}
anchors.fill: undefined
anchors.centerIn: parent
implicitWidth: implicitHeight
implicitHeight: icon.implicitHeight + Appearance.padding.small * 2
radius: Appearance.rounding.full
}
MaterialIcon {
id: icon
anchors.centerIn: parent
anchors.horizontalCenterOffset: -1
text: "settings"
color: Colours.palette.m3onSurface
font.bold: true
font.pointSize: Appearance.font.size.normal
}
}
================================================
FILE: modules/bar/components/StatusIcons.qml
================================================
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
asynchronous: true
Layout.alignment: Qt.AlignHCenter
visible: active
}
}
================================================
FILE: modules/bar/components/Tray.qml
================================================
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
asynchronous: true
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
}
}
}
================================================
FILE: modules/bar/components/TrayItem.qml
================================================
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
}
}
================================================
FILE: modules/bar/components/workspaces/ActiveIndicator.qml
================================================
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) as Workspace)?.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) as Workspace;
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
}
}
================================================
FILE: modules/bar/components/workspaces/OccupiedBg.qml
================================================
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 {}
}
}
================================================
FILE: modules/bar/components/workspaces/SpecialWorkspaces.qml
================================================
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 as SpecialWsDelegate)?.size ?? 0
Behavior on y {
Anim {}
}
}
delegate: SpecialWsDelegate {}
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"
}
}
}
component SpecialWsDelegate: 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 {
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;
}
target: ws.modelData
}
Connections {
function onShowWindowsOnSpecialWorkspacesChanged(): void {
if (ws.modelData)
ws.hasWindows = Config.bar.workspaces.showWindowsOnSpecialWorkspaces && ws.modelData.lastIpcObject.windows > 0;
}
target: Config.bar.workspaces
}
Loader {
id: label
asynchronous: true
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
asynchronous: true
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: {
const windows = Hypr.toplevels.values.filter(c => c.workspace?.id === ws.wsId);
const maxIcons = Config.bar.workspaces.maxWindowIcons;
return maxIcons > 0 ? windows.slice(0, maxIcons) : windows;
}
}
MaterialIcon {
required property var modelData
grade: 0
text: Icons.getAppCategoryIcon(modelData.lastIpcObject.class, "terminal")
color: Colours.palette.m3onSurfaceVariant
}
}
}
Behavior on Layout.preferredHeight {
Anim {}
}
}
}
Loader {
asynchronous: true
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 as SpecialWsDelegate)?.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) as SpecialWsDelegate;
if (ws?.modelData)
Hypr.dispatch(`togglespecialworkspace ${ws.modelData.name.slice(8)}`);
else
Hypr.dispatch("togglespecialworkspace special");
}
}
}
================================================
FILE: modules/bar/components/workspaces/Workspace.qml
================================================
pragma ComponentBehavior: Bound
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
asynchronous: true
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: {
const ws = root.ws;
const windows = Hypr.toplevels.values.filter(c => c.workspace?.id === ws);
const maxIcons = Config.bar.workspaces.maxWindowIcons;
return maxIcons > 0 ? windows.slice(0, maxIcons) : windows;
}
}
MaterialIcon {
required property var modelData
grade: 0
text: Icons.getAppCategoryIcon(modelData.lastIpcObject.class, "terminal")
color: Colours.palette.m3onSurfaceVariant
}
}
}
}
Behavior on Layout.preferredHeight {
Anim {}
}
}
================================================
FILE: modules/bar/components/workspaces/Workspaces.qml
================================================
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: {
const occ = {};
for (const ws of Hypr.workspaces.values)
occ[ws.id] = ws.lastIpcObject.windows > 0;
return occ;
}
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 {
asynchronous: true
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 {
asynchronous: true
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) as Workspace)?.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
asynchronous: true
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
}
}
}
================================================
FILE: modules/bar/popouts/ActiveWindow.qml
================================================
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 PopoutState popouts
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
asynchronous: true
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 {
function onClicked(): void {
root.popouts.detachRequested("winfo");
}
radius: Appearance.rounding.normal
}
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
}
}
}
}
================================================
FILE: modules/bar/popouts/Audio.qml
================================================
pragma ComponentBehavior: Bound
import qs.components
import qs.components.controls
import qs.services
import qs.config
import Quickshell.Services.Pipewire
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
Item {
id: root
required property PopoutState popouts
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.popouts.detachRequested("audio")
}
}
}
================================================
FILE: modules/bar/popouts/Background.qml
================================================
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 {}
}
}
================================================
FILE: modules/bar/popouts/Battery.qml
================================================
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 {
asynchronous: true
anchors.horizontalCenter: parent.horizontalCenter
active: PowerProfiles.degradationReason !== PerformanceDegradationReason.None
height: active ? ((item as 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 {
function onClicked(): void {
PowerProfiles.profile = parent.profile;
}
radius: Appearance.rounding.full
color: profiles.current === parent.icon ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface
}
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 {}
}
}
}
}
================================================
FILE: modules/bar/popouts/Bluetooth.qml
================================================
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
ColumnLayout {
id: root
required property PopoutState popouts
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 {
function onClicked(): void {
device.modelData.connected = !device.modelData.connected;
}
color: device.modelData.state === BluetoothDeviceState.Connected ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface
disabled: device.loading
}
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 {
asynchronous: true
active: device.modelData.bonded
sourceComponent: Item {
implicitWidth: connectBtn.implicitWidth
implicitHeight: connectBtn.implicitHeight
StateLayer {
function onClicked(): void {
device.modelData.forget();
}
radius: Appearance.rounding.full
}
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.popouts.detachRequested("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
}
}
}
================================================
FILE: modules/bar/popouts/Content.qml
================================================
pragma ComponentBehavior: Bound
import qs.components
import qs.config
import Quickshell
import Quickshell.Services.SystemTray
import QtQuick
import "./kblayout"
Item {
id: root
required property PopoutState popouts
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 {
popouts: root.popouts
}
}
Popout {
id: networkPopout
name: "network"
sourceComponent: Network {
popouts: root.popouts
view: "wireless"
}
}
Popout {
name: "ethernet"
sourceComponent: Network {
popouts: root.popouts
view: "ethernet"
}
}
Popout {
id: passwordPopout
name: "wirelesspassword"
sourceComponent: WirelessPassword {
id: passwordComponent
popouts: root.popouts
network: (networkPopout.item as Network)?.passwordNetwork ?? null
}
Connections {
function onCurrentNameChanged() {
// Update network immediately when password popout becomes active
if (root.popouts.currentName === "wirelesspassword") {
// Set network immediately if available
if ((networkPopout.item as Network)?.passwordNetwork) {
if (passwordPopout.item) {
(passwordPopout.item as WirelessPassword).network = (networkPopout.item as Network).passwordNetwork;
}
}
// Also try after a short delay in case networkPopout.item wasn't ready
Qt.callLater(() => {
if (passwordPopout.item && (networkPopout.item as Network)?.passwordNetwork) {
(passwordPopout.item as WirelessPassword).network = (networkPopout.item as Network).passwordNetwork;
}
}, 100);
}
}
target: root.popouts
}
Connections {
function onItemChanged() {
// When network popout loads, update password popout if it's active
if (root.popouts.currentName === "wirelesspassword" && passwordPopout.item) {
Qt.callLater(() => {
if ((networkPopout.item as Network)?.passwordNetwork) {
(passwordPopout.item as WirelessPassword).network = (networkPopout.item as Network).passwordNetwork;
}
});
}
}
target: networkPopout
}
}
Popout {
name: "bluetooth"
sourceComponent: Bluetooth {
popouts: root.popouts
}
}
Popout {
name: "battery"
sourceComponent: Battery {}
}
Popout {
name: "audio"
sourceComponent: Audio {
popouts: root.popouts
}
}
Popout {
name: "kblayout"
sourceComponent: KbLayout {}
}
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 {
function onHasCurrentChanged(): void {
if (root.popouts.hasCurrent && trayMenu.shouldBeActive) {
trayMenu.sourceComponent = null;
trayMenu.sourceComponent = trayMenuComp;
}
}
target: root.popouts
}
Component {
id: trayMenuComp
TrayMenu {
popouts: root.popouts
trayItem: trayMenu.modelData.menu // qmllint disable unresolved-type
}
}
}
}
}
component Popout: Loader {
id: popout
required property string name
readonly property bool shouldBeActive: root.popouts.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"
}
}
}
]
}
}
================================================
FILE: modules/bar/popouts/LockStatus.qml
================================================
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")
}
}
================================================
FILE: modules/bar/popouts/Network.qml
================================================
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 PopoutState popouts
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 {
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.popouts.currentName = "wirelesspassword";
});
// Clear connecting state if connection succeeds immediately (saved profile)
// This is handled by the onActiveChanged connection below
}
}
color: networkItem.modelData.active ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface
disabled: networkItem.loading || !Nmcli.wifiEnabled
}
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 {
function onClicked(): void {
Nmcli.rescanWifi();
}
color: Colours.palette.m3onPrimaryContainer
disabled: Nmcli.scanning || !Nmcli.wifiEnabled
}
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 {
function onClicked(): void {
if (ethernetItem.modelData.connected && ethernetItem.modelData.connection) {
Nmcli.disconnectEthernet(ethernetItem.modelData.connection, () => {});
} else {
Nmcli.connectEthernet(ethernetItem.modelData.connection || "", ethernetItem.modelData.interface || "", () => {});
}
}
color: ethernetItem.modelData.connected ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface
disabled: ethernetItem.loading
}
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 {
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.popouts.currentName === "wirelesspassword") {
root.popouts.currentName = "network";
}
}
}
}
function onScanningChanged(): void {
if (!Nmcli.scanning)
scanIcon.rotation = 0;
}
target: Nmcli
}
Connections {
function onCurrentNameChanged(): void {
// Clear password network when leaving password dialog
if (root.popouts.currentName !== "wirelesspassword" && root.showPasswordDialog) {
root.showPasswordDialog = false;
root.passwordNetwork = null;
}
}
target: root.popouts
}
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
}
}
}
================================================
FILE: modules/bar/popouts/PopoutState.qml
================================================
import QtQuick
QtObject {
property string currentName
property bool hasCurrent
signal detachRequested(mode: string)
}
================================================
FILE: modules/bar/popouts/TrayMenu.qml
================================================
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 PopoutState popouts
required property QsMenuHandle trayItem
implicitWidth: currentItem?.implicitWidth ?? 0
implicitHeight: currentItem?.implicitHeight ?? 0
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
asynchronous: true
anchors.left: parent.left
anchors.right: parent.right
active: !item.modelData.isSeparator
sourceComponent: Item {
implicitHeight: label.implicitHeight
StateLayer {
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;
}
}
anchors.margins: -Appearance.padding.small / 2
anchors.leftMargin: -Appearance.padding.smaller
anchors.rightMargin: -Appearance.padding.smaller
radius: item.radius
disabled: !item.modelData.enabled
}
Loader {
id: icon
asynchronous: true
anchors.left: parent.left
active: item.modelData.icon !== ""
sourceComponent: IconImage {
asynchronous: true
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
asynchronous: true
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 {
asynchronous: true
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 {
function onClicked(): void {
root.pop();
}
radius: parent.radius
color: Colours.palette.m3onSecondaryContainer
}
}
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 {}
}
}
================================================
FILE: modules/bar/popouts/WirelessPassword.qml
================================================
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 PopoutState popouts
property var network: null
property bool isClosing: false
readonly property bool shouldBeVisible: root.popouts.currentName === "wirelesspassword"
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);
}
}
}
}
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.popouts.currentName === "wirelesspassword") {
root.popouts.currentName = "network";
}
}
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()
Connections {
function onCurrentNameChanged() {
if (root.popouts.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();
});
}
}
target: root.popouts
}
Timer {
id: focusTimer
interval: 150
onTriggered: {
root.forceActiveFocus();
passwordContainer.forceActiveFocus();
}
}
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
Keys.onEscapePressed: root.closeDialog()
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
}
}
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 {
property int attempts: 0
interval: 50
running: root.shouldBeVisible && (!root.network || !root.network.ssid)
repeat: true
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
property string passwordBuffer: ""
objectName: "passwordContainer"
Layout.topMargin: Appearance.spacing.large
Layout.fillWidth: true
implicitHeight: Math.max(48, charList.implicitHeight + Appearance.padding.normal * 2)
focus: true
activeFocusOnTab: true
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 {
function onShouldBeVisibleChanged(): void {
if (root.shouldBeVisible) {
// Use Timer for actual delay to ensure focus works correctly
passwordFocusTimer.start();
passwordContainer.passwordBuffer = "";
connectButton.hasError = false;
}
}
target: root
}
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 {
function onClicked(): void {
passwordContainer.forceActiveFocus();
}
hoverEnabled: false
cursorShape: Qt.IBeamCursor
radius: Appearance.rounding.normal
}
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();
}
}
}
}
}
Timer {
id: connectionMonitor
property int repeatCount: 0
interval: 1000
repeat: true
triggeredOnStart: false
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.popouts.currentName === "wirelesspassword") {
root.popouts.currentName = "network";
}
closeDialog();
}
}
}
}
Connections {
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);
}
}
target: Nmcli
}
}
================================================
FILE: modules/bar/popouts/Wrapper.qml
================================================
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 as Content)?.current ?? null
property alias currentName: popoutState.currentName
property real currentCenter
property alias hasCurrent: popoutState.hasCurrent
readonly property PopoutState state: popoutState
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 as Content)?.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;
}
}
PopoutState {
id: popoutState
onDetachRequested: mode => root.detach(mode)
}
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 {
popouts: popoutState
}
}
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 {
function close(): void {
root.close();
}
screen: root.screen
active: root.queuedMode
}
}
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"
}
}
}
]
}
}
================================================
FILE: modules/bar/popouts/kblayout/KbLayout.qml
================================================
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.components
import qs.services
import qs.config
ColumnLayout {
id: root
function refresh() {
kb.refresh();
}
spacing: Appearance.spacing.small
width: Config.bar.sizes.kbLayoutWidth
Component.onCompleted: kb.start()
KbLayoutModel {
id: kb
}
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 {
id: kbDelegate
required property int layoutIndex
required property string label
readonly property bool isDisabled: layoutIndex > 3
width: list.width
height: Math.max(36, rowText.implicitHeight + Appearance.padding.small * 2)
ToolTip.visible: isDisabled && layer.containsMouse
ToolTip.text: "XKB limitation: maximum 4 layouts allowed"
StateLayer {
id: layer
function onClicked(): void {
if (!kbDelegate.isDisabled)
kb.switchTo(kbDelegate.layoutIndex);
}
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
implicitHeight: parent.height - 4
radius: Appearance.rounding.full
enabled: !kbDelegate.isDisabled
}
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: kbDelegate.label
elide: Text.ElideRight
opacity: kbDelegate.isDisabled ? 0.4 : 1.0
}
}
}
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 {
function onActiveLabelChanged() {
if (!activeRow.visible)
return;
popIn.restart();
}
target: kb
}
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
}
}
}
}
}
================================================
FILE: modules/bar/popouts/kblayout/KbLayoutModel.qml
================================================
pragma ComponentBehavior: Bound
import QtQuick
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;
}
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})`;
}
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;
}
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: model._buildXmlMap(text)
}
onRunningChanged: if (!running && (typeof _xkbXmlBase.exitCode !== "undefined") && _xkbXmlBase.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: model._buildXmlMap(text)
}
}
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) {
model._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)
model._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;
model.activeIndex = idx >= 0 ? idx : -1;
model.activeLabel = (idx >= 0 && idx < _layoutsModel.count) ? _layoutsModel.get(idx).label : "";
} catch (e) {
model.activeIndex = -1;
model.activeLabel = "";
}
model._rebuildVisible();
}
}
}
Process {
id: _switchProc
onRunningChanged: if (!running)
_fetchActiveLayouts.running = true
}
}
================================================
FILE: modules/controlcenter/ControlCenter.qml
================================================
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 bool initialOpeningComplete: panes.initialOpeningComplete
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
asynchronous: true
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 {
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);
}
anchors.fill: parent
}
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
}
}
}
================================================
FILE: modules/controlcenter/NavRail.qml
================================================
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
asynchronous: true
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
function onClicked(): void {
root.session.root.close();
WindowFactory.create(null, {
active: root.session.active,
navExpanded: root.session.navExpanded
});
}
color: Colours.palette.m3onPrimaryContainer
}
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 {
function onClicked(): void {
// Prevent tab switching during initial opening animation to avoid blank pages
if (!root.initialOpeningComplete) {
return;
}
root.session.active = item.label;
}
color: item.active ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface
}
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
}
}
}
}
================================================
FILE: modules/controlcenter/PaneRegistry.qml
================================================
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;
}
}
================================================
FILE: modules/controlcenter/Panes.qml
================================================
pragma ComponentBehavior: Bound
import qs.components
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 {
function onActiveIndexChanged(): void {
root.focus = true;
}
target: root.session
}
ColumnLayout {
id: layout
property bool animationComplete: true
property bool initialOpeningComplete: false
spacing: 0
y: -root.session.activeIndex * root.height
clip: true
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 {
function onActiveIndexChanged(): void {
layout.animationComplete = false;
animationDelayTimer.restart();
}
target: root.session
}
}
component Pane: Item {
id: pane
required property int paneIndex
required property string componentPath
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;
}
implicitWidth: root.width
implicitHeight: root.height
Loader {
id: loader
anchors.fill: parent
asynchronous: true
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 {
function onActiveIndexChanged(): void {
pane.updateActive();
}
target: root.session
}
Connections {
function onInitialOpeningCompleteChanged(): void {
pane.updateActive();
}
function onAnimationCompleteChanged(): void {
pane.updateActive();
}
target: layout
}
}
}
================================================
FILE: modules/controlcenter/Session.qml
================================================
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]
}
================================================
FILE: modules/controlcenter/WindowFactory.qml
================================================
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
function close(): void {
win.destroy();
}
anchors.fill: parent
screen: win.screen
floating: true
}
Behavior on color {
CAnim {}
}
}
}
}
================================================
FILE: modules/controlcenter/WindowTitle.qml
================================================
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 {
function onClicked(): void {
QsWindow.window.destroy();
}
radius: Appearance.rounding.full
}
MaterialIcon {
id: closeIcon
anchors.centerIn: parent
text: "close"
}
}
}
================================================
FILE: modules/controlcenter/appearance/AppearancePane.qml
================================================
pragma ComponentBehavior: Bound
import ".."
import "../components"
import "./sections"
import qs.components
import qs.components.controls
import qs.components.containers
import qs.config
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
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();
}
anchors.fill: parent
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
asynchronous: true
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
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
anchors.left: parent.left
anchors.right: parent.right
spacing: Appearance.spacing.small
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
}
}
================================================
FILE: modules/controlcenter/appearance/sections/AnimationsSection.qml
================================================
pragma ComponentBehavior: Bound
import "../../components"
import qs.components
import qs.components.controls
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: root.rootPane.animDurationsScale
from: 0.1
to: 5.0
decimals: 1
suffix: "×"
validator: DoubleValidator {
bottom: 0.1
top: 5.0
}
onValueModified: newValue => {
root.rootPane.animDurationsScale = newValue;
root.rootPane.saveConfig();
}
}
}
}
================================================
FILE: modules/controlcenter/appearance/sections/BackgroundSection.qml
================================================
pragma ComponentBehavior: Bound
import "../../components"
import qs.components
import qs.components.controls
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: root.rootPane.backgroundEnabled
onToggled: checked => {
root.rootPane.backgroundEnabled = checked;
root.rootPane.saveConfig();
}
}
SwitchRow {
label: qsTr("Wallpaper enabled")
checked: root.rootPane.wallpaperEnabled
onToggled: checked => {
root.rootPane.wallpaperEnabled = checked;
root.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: root.rootPane.desktopClockEnabled
onToggled: checked => {
root.rootPane.desktopClockEnabled = checked;
root.rootPane.saveConfig();
}
}
SectionContainer {
id: posContainer
readonly property var pos: (root.rootPane.desktopClockPosition || "top-left").split('-')
readonly property string currentV: pos[0]
readonly property string currentH: pos[1]
function updateClockPos(v, h) {
root.rootPane.desktopClockPosition = v + "-" + h;
root.rootPane.saveConfig();
}
contentSpacing: Appearance.spacing.small
z: 1
StyledText {
text: qsTr("Positioning")
font.pointSize: Appearance.font.size.larger
font.weight: 500
}
SplitButtonRow {
label: qsTr("Vertical Position")
enabled: root.rootPane.desktopClockEnabled
menuItems: [
MenuItem {
property string val: "top"
text: qsTr("Top")
icon: "vertical_align_top"
},
MenuItem {
property string val: "middle"
text: qsTr("Middle")
icon: "vertical_align_center"
},
MenuItem {
property string val: "bottom"
text: qsTr("Bottom")
icon: "vertical_align_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: root.rootPane.desktopClockEnabled
expandedZ: 99
menuItems: [
MenuItem {
property string val: "left"
text: qsTr("Left")
icon: "align_horizontal_left"
},
MenuItem {
property string val: "center"
text: qsTr("Center")
icon: "align_horizontal_center"
},
MenuItem {
property string val: "right"
text: qsTr("Right")
icon: "align_horizontal_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: root.rootPane.desktopClockInvertColors
onToggled: checked => {
root.rootPane.desktopClockInvertColors = checked;
root.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: root.rootPane.desktopClockShadowEnabled
onToggled: checked => {
root.rootPane.desktopClockShadowEnabled = checked;
root.rootPane.saveConfig();
}
}
SectionContainer {
contentSpacing: Appearance.spacing.normal
SliderInput {
Layout.fillWidth: true
label: qsTr("Opacity")
value: root.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 => {
root.rootPane.desktopClockShadowOpacity = newValue / 100;
root.rootPane.saveConfig();
}
}
}
SectionContainer {
contentSpacing: Appearance.spacing.normal
SliderInput {
Layout.fillWidth: true
label: qsTr("Blur")
value: root.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 => {
root.rootPane.desktopClockShadowBlur = newValue / 100;
root.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: root.rootPane.desktopClockBackgroundEnabled
onToggled: checked => {
root.rootPane.desktopClockBackgroundEnabled = checked;
root.rootPane.saveConfig();
}
}
SwitchRow {
label: qsTr("Blur enabled")
checked: root.rootPane.desktopClockBackgroundBlur
onToggled: checked => {
root.rootPane.desktopClockBackgroundBlur = checked;
root.rootPane.saveConfig();
}
}
SectionContainer {
contentSpacing: Appearance.spacing.normal
SliderInput {
Layout.fillWidth: true
label: qsTr("Opacity")
value: root.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 => {
root.rootPane.desktopClockBackgroundOpacity = newValue / 100;
root.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: root.rootPane.visualiserEnabled
onToggled: checked => {
root.rootPane.visualiserEnabled = checked;
root.rootPane.saveConfig();
}
}
SwitchRow {
label: qsTr("Visualiser auto hide")
checked: root.rootPane.visualiserAutoHide
onToggled: checked => {
root.rootPane.visualiserAutoHide = checked;
root.rootPane.saveConfig();
}
}
SectionContainer {
contentSpacing: Appearance.spacing.normal
SliderInput {
Layout.fillWidth: true
label: qsTr("Visualiser rounding")
value: root.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 => {
root.rootPane.visualiserRounding = Math.round(newValue);
root.rootPane.saveConfig();
}
}
}
SectionContainer {
contentSpacing: Appearance.spacing.normal
SliderInput {
Layout.fillWidth: true
label: qsTr("Visualiser spacing")
value: root.rootPane.visualiserSpacing
from: 0
to: 2
validator: DoubleValidator {
bottom: 0
top: 2
}
onValueModified: newValue => {
root.rootPane.visualiserSpacing = newValue;
root.rootPane.saveConfig();
}
}
}
}
================================================
FILE: modules/controlcenter/appearance/sections/BorderSection.qml
================================================
pragma ComponentBehavior: Bound
import "../../components"
import qs.components
import qs.components.controls
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: root.rootPane.borderRounding
from: 0.1
to: 100
decimals: 1
suffix: "px"
validator: DoubleValidator {
bottom: 0.1
top: 100
}
onValueModified: newValue => {
root.rootPane.borderRounding = newValue;
root.rootPane.saveConfig();
}
}
}
SectionContainer {
contentSpacing: Appearance.spacing.normal
SliderInput {
Layout.fillWidth: true
label: qsTr("Border thickness")
value: root.rootPane.borderThickness
from: 0
to: 100
decimals: 1
suffix: "px"
validator: DoubleValidator {
bottom: 0.1
top: 100
}
onValueModified: newValue => {
root.rootPane.borderThickness = newValue;
root.rootPane.saveConfig();
}
}
}
}
================================================
FILE: modules/controlcenter/appearance/sections/ColorSchemeSection.qml
================================================
pragma ComponentBehavior: Bound
import "../../../launcher/services"
import qs.components
import qs.components.controls
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 {
id: schemeDelegate
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 = schemeDelegate.modelData.name;
const flavour = schemeDelegate.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(`#${schemeDelegate.modelData.colours?.outline}`, 0.5)
color: `#${schemeDelegate.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: `#${schemeDelegate.modelData.colours?.primary}`
radius: Appearance.rounding.full
}
}
}
Column {
Layout.fillWidth: true
spacing: 0
StyledText {
text: schemeDelegate.modelData.flavour ?? ""
font.pointSize: Appearance.font.size.normal
}
StyledText {
text: schemeDelegate.modelData.name ?? ""
font.pointSize: Appearance.font.size.small
color: Colours.palette.m3outline
elide: Text.ElideRight
anchors.left: parent.left
anchors.right: parent.right
}
}
Loader {
asynchronous: true
active: schemeDelegate.isCurrent
sourceComponent: MaterialIcon {
text: "check"
color: Colours.palette.m3onSurfaceVariant
font.pointSize: Appearance.font.size.large
}
}
}
implicitHeight: schemeRow.implicitHeight + Appearance.padding.normal * 2
}
}
}
}
================================================
FILE: modules/controlcenter/appearance/sections/ColorVariantSection.qml
================================================
pragma ComponentBehavior: Bound
import "../../../launcher/services"
import qs.components
import qs.components.controls
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 {
id: variantDelegate
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 = variantDelegate.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: variantDelegate.modelData.icon
font.pointSize: Appearance.font.size.large
fill: variantDelegate.modelData.variant === Schemes.currentVariant ? 1 : 0
}
StyledText {
Layout.fillWidth: true
text: variantDelegate.modelData.name
font.weight: variantDelegate.modelData.variant === Schemes.currentVariant ? 500 : 400
}
MaterialIcon {
visible: variantDelegate.modelData.variant === Schemes.currentVariant
text: "check"
color: Colours.palette.m3primary
font.pointSize: Appearance.font.size.large
}
}
implicitHeight: variantRow.implicitHeight + Appearance.padding.normal * 2
}
}
}
}
================================================
FILE: modules/controlcenter/appearance/sections/FontsSection.qml
================================================
pragma ComponentBehavior: Bound
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: sansFontSection
title: qsTr("Sans-serif font family")
expanded: true
showBackground: true
nested: true
Loader {
Layout.fillWidth: true
Layout.preferredHeight: item ? Math.min(item.contentHeight, 300) : 0
asynchronous: true
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 {
id: sansDelegate
required property string modelData
required property int index
readonly property bool isCurrent: modelData === root.rootPane.fontFamilySans
width: ListView.view.width
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 {
root.rootPane.fontFamilySans = sansDelegate.modelData;
root.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: sansDelegate.modelData
font.pointSize: Appearance.font.size.normal
}
Item {
Layout.fillWidth: true
}
Loader {
asynchronous: true
active: sansDelegate.isCurrent
sourceComponent: MaterialIcon {
text: "check"
color: Colours.palette.m3onSurfaceVariant
font.pointSize: Appearance.font.size.large
}
}
}
implicitHeight: fontFamilySansRow.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
asynchronous: true
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 {
id: monoDelegate
required property string modelData
required property int index
readonly property bool isCurrent: modelData === root.rootPane.fontFamilyMono
width: ListView.view.width
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 {
root.rootPane.fontFamilyMono = monoDelegate.modelData;
root.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: monoDelegate.modelData
font.pointSize: Appearance.font.size.normal
}
Item {
Layout.fillWidth: true
}
Loader {
asynchronous: true
active: monoDelegate.isCurrent
sourceComponent: MaterialIcon {
text: "check"
color: Colours.palette.m3onSurfaceVariant
font.pointSize: Appearance.font.size.large
}
}
}
implicitHeight: fontFamilyMonoRow.implicitHeight + Appearance.padding.normal * 2
}
}
}
}
CollapsibleSection {
id: materialFontSection
title: qsTr("Material font family")
expanded: false
showBackground: true
nested: true
Loader {
id: materialFontLoader
Layout.fillWidth: true
Layout.preferredHeight: item ? Math.min(item.contentHeight, 300) : 0
asynchronous: true
active: materialFontSection.expanded
sourceComponent: StyledListView {
id: materialFontList
property alias contentHeight: materialFontList.contentHeight
clip: true
spacing: Appearance.spacing.small / 2
model: Qt.fontFamilies().filter(f => f.startsWith("Material Symbols"))
StyledScrollBar.vertical: StyledScrollBar {
flickable: materialFontList
}
delegate: StyledRect {
id: materialDelegate
required property string modelData
required property int index
readonly property bool isCurrent: modelData === root.rootPane.fontFamilyMaterial
width: ListView.view.width
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 {
root.rootPane.fontFamilyMaterial = materialDelegate.modelData;
root.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: materialDelegate.modelData
font.pointSize: Appearance.font.size.normal
}
Item {
Layout.fillWidth: true
}
Loader {
asynchronous: true
active: materialDelegate.isCurrent
sourceComponent: MaterialIcon {
text: "check"
color: Colours.palette.m3onSurfaceVariant
font.pointSize: Appearance.font.size.large
}
}
}
implicitHeight: fontFamilyMaterialRow.implicitHeight + Appearance.padding.normal * 2
}
}
}
}
SectionContainer {
contentSpacing: Appearance.spacing.normal
SliderInput {
Layout.fillWidth: true
label: qsTr("Font size scale")
value: root.rootPane.fontSizeScale
from: 0.7
to: 1.5
decimals: 2
suffix: "×"
validator: DoubleValidator {
bottom: 0.7
top: 1.5
}
onValueModified: newValue => {
root.rootPane.fontSizeScale = newValue;
root.rootPane.saveConfig();
}
}
}
}
================================================
FILE: modules/controlcenter/appearance/sections/ScalesSection.qml
================================================
pragma ComponentBehavior: Bound
import "../../components"
import qs.components
import qs.components.controls
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: root.rootPane.paddingScale
from: 0.5
to: 2.0
decimals: 1
suffix: "×"
validator: DoubleValidator {
bottom: 0.5
top: 2.0
}
onValueModified: newValue => {
root.rootPane.paddingScale = newValue;
root.rootPane.saveConfig();
}
}
}
SectionContainer {
contentSpacing: Appearance.spacing.normal
SliderInput {
Layout.fillWidth: true
label: qsTr("Rounding scale")
value: root.rootPane.roundingScale
from: 0.1
to: 5.0
decimals: 1
suffix: "×"
validator: DoubleValidator {
bottom: 0.1
top: 5.0
}
onValueModified: newValue => {
root.rootPane.roundingScale = newValue;
root.rootPane.saveConfig();
}
}
}
SectionContainer {
contentSpacing: Appearance.spacing.normal
SliderInput {
Layout.fillWidth: true
label: qsTr("Spacing scale")
value: root.rootPane.spacingScale
from: 0.1
to: 2.0
decimals: 1
suffix: "×"
validator: DoubleValidator {
bottom: 0.1
top: 2.0
}
onValueModified: newValue => {
root.rootPane.spacingScale = newValue;
root.rootPane.saveConfig();
}
}
}
}
================================================
FILE: modules/controlcenter/appearance/sections/ThemeModeSection.qml
================================================
pragma ComponentBehavior: Bound
import qs.components.controls
import qs.services
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");
}
}
}
================================================
FILE: modules/controlcenter/appearance/sections/TransparencySection.qml
================================================
pragma ComponentBehavior: Bound
import "../../components"
import qs.components
import qs.components.controls
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: root.rootPane.transparencyEnabled
onToggled: checked => {
root.rootPane.transparencyEnabled = checked;
root.rootPane.saveConfig();
}
}
SectionContainer {
contentSpacing: Appearance.spacing.normal
SliderInput {
Layout.fillWidth: true
label: qsTr("Transparency base")
value: root.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 => {
root.rootPane.transparencyBase = newValue / 100;
root.rootPane.saveConfig();
}
}
}
SectionContainer {
contentSpacing: Appearance.spacing.normal
SliderInput {
Layout.fillWidth: true
label: qsTr("Transparency layers")
value: root.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 => {
root.rootPane.transparencyLayers = newValue / 100;
root.rootPane.saveConfig();
}
}
}
}
================================================
FILE: modules/controlcenter/audio/AudioPane.qml
================================================
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
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 {
id: outputDeviceDelegate
required property var modelData
Layout.fillWidth: true
color: Audio.sink?.id === outputDeviceDelegate.modelData.id ? Colours.layer(Colours.palette.m3surfaceContainer, 2) : "transparent"
radius: Appearance.rounding.normal
StateLayer {
function onClicked(): void {
Audio.setAudioSink(outputDeviceDelegate.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 === outputDeviceDelegate.modelData.id ? "speaker" : "speaker_group"
font.pointSize: Appearance.font.size.large
fill: Audio.sink?.id === outputDeviceDelegate.modelData.id ? 1 : 0
}
StyledText {
Layout.fillWidth: true
elide: Text.ElideRight
maximumLineCount: 1
text: outputDeviceDelegate.modelData.description || qsTr("Unknown")
font.weight: Audio.sink?.id === outputDeviceDelegate.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 {
id: inputDeviceDelegate
required property var modelData
Layout.fillWidth: true
color: Audio.source?.id === inputDeviceDelegate.modelData.id ? Colours.layer(Colours.palette.m3surfaceContainer, 2) : "transparent"
radius: Appearance.rounding.normal
StateLayer {
function onClicked(): void {
Audio.setAudioSource(inputDeviceDelegate.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 === inputDeviceDelegate.modelData.id ? 1 : 0
}
StyledText {
Layout.fillWidth: true
elide: Text.ElideRight
maximumLineCount: 1
text: inputDeviceDelegate.modelData.description || qsTr("Unknown")
font.weight: Audio.source?.id === inputDeviceDelegate.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 {
function onVolumeChanged() {
if (!outputVolumeInput.hasFocus) {
outputVolumeInput.text = Math.round(Audio.volume * 100).toString();
}
}
target: Audio
}
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 {
function onSourceVolumeChanged() {
if (!inputVolumeInput.hasFocus) {
inputVolumeInput.text = Math.round(Audio.sourceVolume * 100).toString();
}
}
target: Audio
}
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 {
id: streamDelegate
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(streamDelegate.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(streamDelegate.modelData)
Component.onCompleted: {
text = Math.round(Audio.getStreamVolume(streamDelegate.modelData) * 100).toString();
}
Connections {
function onAudioChanged() {
if (!streamVolumeInput.hasFocus && streamDelegate.modelData?.audio) {
streamVolumeInput.text = Math.round(streamDelegate.modelData.audio.volume * 100).toString();
}
}
target: streamDelegate.modelData
}
onTextEdited: text => {
if (hasFocus) {
const val = parseInt(text);
if (!isNaN(val) && val >= 0 && val <= 100) {
Audio.setStreamVolume(streamDelegate.modelData, val / 100);
}
}
}
onEditingFinished: {
const val = parseInt(text);
if (isNaN(val) || val < 0 || val > 100) {
text = Math.round(Audio.getStreamVolume(streamDelegate.modelData) * 100).toString();
}
}
}
StyledText {
text: "%"
color: Colours.palette.m3outline
font.pointSize: Appearance.font.size.normal
opacity: Audio.getStreamMuted(streamDelegate.modelData) ? 0.5 : 1
}
StyledRect {
implicitWidth: implicitHeight
implicitHeight: streamMuteIcon.implicitHeight + Appearance.padding.normal * 2
radius: Appearance.rounding.normal
color: Audio.getStreamMuted(streamDelegate.modelData) ? Colours.palette.m3secondary : Colours.palette.m3secondaryContainer
StateLayer {
function onClicked(): void {
Audio.setStreamMuted(streamDelegate.modelData, !Audio.getStreamMuted(streamDelegate.modelData));
}
}
MaterialIcon {
id: streamMuteIcon
anchors.centerIn: parent
text: Audio.getStreamMuted(streamDelegate.modelData) ? "volume_off" : "volume_up"
color: Audio.getStreamMuted(streamDelegate.modelData) ? Colours.palette.m3onSecondary : Colours.palette.m3onSecondaryContainer
}
}
}
StyledSlider {
id: streamSlider
Layout.fillWidth: true
implicitHeight: Appearance.padding.normal * 3
value: Audio.getStreamVolume(streamDelegate.modelData)
enabled: !Audio.getStreamMuted(streamDelegate.modelData)
opacity: enabled ? 1 : 0.5
onMoved: {
Audio.setStreamVolume(streamDelegate.modelData, value);
if (!streamVolumeInput.hasFocus) {
streamVolumeInput.text = Math.round(value * 100).toString();
}
}
Connections {
function onAudioChanged() {
if (streamDelegate.modelData?.audio) {
streamSlider.value = streamDelegate.modelData.audio.volume;
}
}
target: streamDelegate.modelData
}
}
}
}
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
}
}
}
}
}
}
}
}
================================================
FILE: modules/controlcenter/bluetooth/BtPane.qml
================================================
pragma ComponentBehavior: Bound
import ".."
import "../components"
import "."
import qs.components.controls
import qs.components.containers
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
}
}
}
}
================================================
FILE: modules/controlcenter/bluetooth/Details.qml
================================================
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 {
function onClicked(): void {
root.session.bt.editingDeviceName = false;
deviceNameEdit.text = Qt.binding(() => root.device?.name ?? "");
}
color: Colours.palette.m3onSecondaryContainer
disabled: !root.session.bt.editingDeviceName
}
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 {
function onClicked(): void {
root.session.bt.editingDeviceName = !root.session.bt.editingDeviceName;
if (root.session.bt.editingDeviceName)
deviceNameEdit.forceActiveFocus();
else
deviceNameEdit.accepted();
}
color: root.session.bt.editingDeviceName ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface
}
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
function onClicked(): void {
root.session.bt.fabMenuOpen = !root.session.bt.fabMenuOpen;
}
color: root.session.bt.fabMenuOpen ? Colours.palette.m3onPrimary : Colours.palette.m3onPrimaryContainer
}
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
}
}
}
================================================
FILE: modules/controlcenter/bluetooth/DeviceList.qml
================================================
pragma ComponentBehavior: Bound
import ".."
import "../components"
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
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 {
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();
}
}
}
color: device.connected ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface
disabled: device.loading
}
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
}
================================================
FILE: modules/controlcenter/bluetooth/Settings.qml
================================================
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 {
function onClicked(): void {
adapterPickerButton.expanded = !adapterPickerButton.expanded;
}
radius: Appearance.rounding.small
}
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 {
function onClicked(): void {
adapterPickerButton.expanded = false;
root.session.bt.currentAdapter = adapter.modelData;
}
disabled: !adapterPickerButton.expanded
}
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 {
function onClicked(): void {
root.session.bt.editingAdapterName = false;
adapterNameEdit.text = Qt.binding(() => root.session.bt.currentAdapter?.name ?? "");
}
color: Colours.palette.m3onSecondaryContainer
disabled: !root.session.bt.editingAdapterName
}
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 {
function onClicked(): void {
root.session.bt.editingAdapterName = !root.session.bt.editingAdapterName;
if (root.session.bt.editingAdapterName)
adapterNameEdit.forceActiveFocus();
else
adapterNameEdit.accepted();
}
color: root.session.bt.editingAdapterName ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface
}
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
}
}
}
================================================
FILE: modules/controlcenter/components/ConnectedButtonGroup.qml
================================================
pragma ComponentBehavior: Bound
import qs.components
import qs.components.controls
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, state: bool?}
property var rootItem: null // The root item that contains the properties we want to bind to
property string title: "" // Optional title text
property int rows: 1 // Number of rows
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
}
GridLayout {
id: buttonGrid
Layout.alignment: Qt.AlignHCenter
rowSpacing: Appearance.spacing.small
columnSpacing: Appearance.spacing.small
rows: root.rows
columns: Math.ceil(root.options.length / root.rows)
Repeater {
id: repeater
model: root.options
delegate: TextButton {
id: button
required property int index
required property var modelData
property bool _checked: false
Layout.fillWidth: true
text: modelData.label
checked: _checked
toggle: false
type: TextButton.Tonal
// Create binding in Component.onCompleted
Component.onCompleted: {
if (button.modelData.state !== undefined && button.modelData.state) {
_checked = button.modelData.state;
} else if (root.rootItem && button.modelData.propertyName) {
const propName = button.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 (button.modelData.onToggled && root.rootItem && button.modelData.propertyName) {
const currentValue = root.rootItem[button.modelData.propertyName] ?? false;
button.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
}
}
}
}
}
}
}
================================================
FILE: modules/controlcenter/components/DeviceDetails.qml
================================================
pragma ComponentBehavior: Bound
import ".."
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
asynchronous: true
sourceComponent: root.headerComponent
visible: root.headerComponent !== null
}
Loader {
id: topContentLoader
Layout.fillWidth: true
asynchronous: true
sourceComponent: root.topContent
visible: root.topContent !== null
}
Repeater {
model: root.sections
Loader {
required property Component modelData
Layout.fillWidth: true
asynchronous: true
sourceComponent: modelData
}
}
Loader {
id: bottomContentLoader
Layout.fillWidth: true
asynchronous: true
sourceComponent: root.bottomContent
visible: root.bottomContent !== null
}
}
}
================================================
FILE: modules/controlcenter/components/DeviceList.qml
================================================
pragma ComponentBehavior: Bound
import ".."
import qs.components
import qs.components.containers
import qs.services
import qs.config
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
asynchronous: 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 {
asynchronous: true
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
}
}
================================================
FILE: modules/controlcenter/components/PaneTransition.qml
================================================
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
}
}
}
================================================
FILE: modules/controlcenter/components/ReadonlySlider.qml
================================================
import qs.components
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
}
}
}
================================================
FILE: modules/controlcenter/components/SettingsHeader.qml
================================================
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
}
}
}
================================================
FILE: modules/controlcenter/components/SliderInput.qml
================================================
pragma ComponentBehavior: Bound
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 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
property bool _initialized: false
signal valueModified(real newValue)
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);
}
spacing: Appearance.spacing.small
Component.onCompleted: {
// Set initialized flag after a brief delay to allow component to fully load
Qt.callLater(() => {
_initialized = true;
});
}
// 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);
}
}
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);
}
}
}
}
================================================
FILE: modules/controlcenter/components/SplitPaneLayout.qml
================================================
pragma ComponentBehavior: Bound
import qs.components.effects
import qs.config
import Quickshell.Widgets
import QtQuick
import QtQuick.Layouts
RowLayout {
id: root
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
spacing: 0
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
asynchronous: true
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
asynchronous: true
sourceComponent: root.rightContent
Component.onCompleted: {
for (const key in root.rightLoaderProperties) {
rightLoader[key] = root.rightLoaderProperties[key];
}
}
}
}
InnerBorder {
id: rightBorder
leftThickness: Appearance.padding.normal / 2
}
}
}
================================================
FILE: modules/controlcenter/components/SplitPaneWithDetails.qml
================================================
pragma ComponentBehavior: Bound
import QtQuick
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
asynchronous: true
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
asynchronous: true
z: 1000
sourceComponent: root.overlayComponent
active: root.overlayComponent !== null
}
}
================================================
FILE: modules/controlcenter/components/WallpaperGrid.qml
================================================
pragma ComponentBehavior: Bound
import ".."
import qs.components
import qs.components.controls
import qs.components.images
import qs.services
import qs.config
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 {
id: wpDelegate
required property var modelData
required property int index
readonly property bool isCurrent: modelData && modelData.path === Wallpapers.actualCurrent
readonly property real itemMargin: Appearance.spacing.normal / 2
readonly property real itemRadius: Appearance.rounding.normal
width: root.cellWidth
height: root.cellHeight
StateLayer {
function onClicked(): void {
Wallpapers.setWallpaper(wpDelegate.modelData.path);
}
anchors.fill: parent
anchors.leftMargin: wpDelegate.itemMargin
anchors.rightMargin: wpDelegate.itemMargin
anchors.topMargin: wpDelegate.itemMargin
anchors.bottomMargin: wpDelegate.itemMargin
radius: wpDelegate.itemRadius
}
StyledClippingRect {
id: image
anchors.fill: parent
anchors.leftMargin: wpDelegate.itemMargin
anchors.rightMargin: wpDelegate.itemMargin
anchors.topMargin: wpDelegate.itemMargin
anchors.bottomMargin: wpDelegate.itemMargin
color: Colours.tPalette.m3surfaceContainer
radius: wpDelegate.itemRadius
antialiasing: true
layer.enabled: true
layer.smooth: true
CachingImage {
id: cachingImage
path: wpDelegate.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 ? wpDelegate.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: wpDelegate.itemMargin
anchors.rightMargin: wpDelegate.itemMargin
anchors.topMargin: wpDelegate.itemMargin
anchors.bottomMargin: wpDelegate.itemMargin
color: "transparent"
radius: wpDelegate.itemRadius + border.width
border.width: wpDelegate.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: wpDelegate.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: wpDelegate.modelData.name
font.pointSize: Appearance.font.size.smaller
font.weight: 500
color: wpDelegate.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;
}
}
}
}
================================================
FILE: modules/controlcenter/dashboard/DashboardPane.qml
================================================
pragma ComponentBehavior: Bound
import ".."
import qs.components
import qs.components.controls
import qs.components.effects
import qs.components.containers
import qs.config
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 mediaUpdateInterval: Config.dashboard.mediaUpdateInterval ?? 1000
property int resourceUpdateInterval: Config.dashboard.resourceUpdateInterval ?? 1000
property int dragThreshold: Config.dashboard.dragThreshold ?? 50
// Dashboard Tabs
property bool showDashboard: Config.dashboard.showDashboard ?? true
property bool showMedia: Config.dashboard.showMedia ?? true
property bool showPerformance: Config.dashboard.showPerformance ?? true
property bool showWeather: Config.dashboard.showWeather ?? true
// 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
function saveConfig() {
Config.dashboard.enabled = root.enabled;
Config.dashboard.showOnHover = root.showOnHover;
Config.dashboard.mediaUpdateInterval = root.mediaUpdateInterval;
Config.dashboard.resourceUpdateInterval = root.resourceUpdateInterval;
Config.dashboard.dragThreshold = root.dragThreshold;
Config.dashboard.showDashboard = root.showDashboard;
Config.dashboard.showMedia = root.showMedia;
Config.dashboard.showPerformance = root.showPerformance;
Config.dashboard.showWeather = root.showWeather;
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();
}
anchors.fill: parent
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
asynchronous: true
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
}
}
}
}
}
================================================
FILE: modules/controlcenter/dashboard/GeneralSection.qml
================================================
import "../components"
import qs.components
import qs.components.controls
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();
}
}
RowLayout {
Layout.fillWidth: true
spacing: Appearance.spacing.normal
SwitchRow {
Layout.fillWidth: true
label: qsTr("Show Dashboard tab")
checked: root.rootItem.showDashboard
onToggled: checked => {
root.rootItem.showDashboard = checked;
root.rootItem.saveConfig();
}
}
SwitchRow {
Layout.fillWidth: true
label: qsTr("Show Media tab")
checked: root.rootItem.showMedia
onToggled: checked => {
root.rootItem.showMedia = checked;
root.rootItem.saveConfig();
}
}
SwitchRow {
Layout.fillWidth: true
label: qsTr("Show Performance tab")
checked: root.rootItem.showPerformance
onToggled: checked => {
root.rootItem.showPerformance = checked;
root.rootItem.saveConfig();
}
}
SwitchRow {
Layout.fillWidth: true
label: qsTr("Show Weather tab")
checked: root.rootItem.showWeather
onToggled: checked => {
root.rootItem.showWeather = checked;
root.rootItem.saveConfig();
}
}
}
SliderInput {
Layout.fillWidth: true
label: qsTr("Media update interval")
value: root.rootItem.mediaUpdateInterval
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.mediaUpdateInterval = 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();
}
}
}
================================================
FILE: modules/controlcenter/dashboard/PerformanceSection.qml
================================================
import "../components"
import QtQuick
import QtQuick.Layouts
import Quickshell.Services.UPower
import qs.components
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;
}
}
SliderInput {
Layout.fillWidth: true
label: qsTr("Resource update interval")
value: root.rootItem.resourceUpdateInterval
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.resourceUpdateInterval = Math.round(newValue);
root.rootItem.saveConfig();
}
}
}
================================================
FILE: modules/controlcenter/launcher/LauncherPane.qml
================================================
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 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
property string searchText: ""
property list filteredApps: []
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();
}
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);
}
function updateFilteredApps() {
filteredApps = filterApps(searchText);
}
anchors.fill: parent
onSelectedAppChanged: {
session.launcher.active = selectedApp;
updateToggleState();
}
onSearchTextChanged: {
updateFilteredApps();
}
Component.onCompleted: {
updateFilteredApps();
}
Connections {
function onActiveChanged() {
root.selectedApp = root.session.launcher.active;
root.updateToggleState();
}
target: root.session.launcher
}
AppDb {
id: allAppsDb
path: `${Paths.state}/apps.sqlite`
favouriteApps: Config.launcher.favouriteApps
entries: DesktopEntries.applications.values
}
Connections {
function onAppsChanged() {
root.updateFilteredApps();
}
target: allAppsDb
}
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: appsListView
}
delegate: StyledRect {
id: appDelegate
required property var modelData
readonly property bool isSelected: root.selectedApp === modelData
width: parent ? parent.width : 0
implicitHeight: 40
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 = appDelegate.modelData;
}
}
RowLayout {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: Appearance.padding.normal
spacing: Appearance.spacing.normal
IconImage {
asynchronous: true
Layout.alignment: Qt.AlignVCenter
implicitSize: 32
source: {
const entry = appDelegate.modelData.entry;
return entry ? Quickshell.iconPath(entry.icon, "image-missing") : "image-missing";
}
}
StyledText {
Layout.fillWidth: true
text: appDelegate.modelData.name || appDelegate.modelData.entry?.name || qsTr("Unknown")
font.pointSize: Appearance.font.size.normal
}
Loader {
readonly property bool isHidden: appDelegate.modelData ? Strings.testRegexList(Config.launcher.hiddenApps, appDelegate.modelData.id) : false
readonly property bool isFav: appDelegate.modelData ? Strings.testRegexList(Config.launcher.favouriteApps, appDelegate.modelData.id) : false
Layout.alignment: Qt.AlignVCenter
asynchronous: true
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;
}
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;
}
}
Loader {
id: rightLauncherLoader
property var displayedApp: rightLauncherPane.displayedApp
anchors.fill: parent
asynchronous: true
opacity: 1
scale: 1
transformOrigin: Item.Center
clip: false
sourceComponent: rightLauncherPane.targetComponent
active: true
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
}
]
}
}
}
}
}
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
readonly property var displayedApp: parent?.displayedApp ?? null // qmllint disable missing-property
anchors.fill: parent
spacing: Appearance.spacing.normal
SettingsHeader {
Layout.leftMargin: Appearance.padding.large * 2
Layout.rightMargin: Appearance.padding.large * 2
Layout.topMargin: Appearance.padding.large * 2
visible: appDetailsLayout.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: appDetailsLayout.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
asynchronous: true
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: appDetailsLayout.displayedApp.displayedApp ? (appDetailsLayout.displayedApp.displayedApp.displayedApp.name || appDetailsLayout.displayedApp.displayedApp.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();
}
}
}
}
}
}
}
}
}
================================================
FILE: modules/controlcenter/launcher/Settings.qml
================================================
pragma ComponentBehavior: Bound
import ".."
import "../components"
import qs.components
import qs.components.controls
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)
}
}
}
================================================
FILE: modules/controlcenter/network/EthernetDetails.qml
================================================
pragma ComponentBehavior: Bound
import ".."
import "../components"
import qs.components
import qs.components.controls
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
}
}
}
}
]
}
================================================
FILE: modules/controlcenter/network/EthernetList.qml
================================================
pragma ComponentBehavior: Bound
import ".."
import "../components"
import qs.components
import qs.components.controls
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 = ethernetItem.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: ethernetItem.modelData.connected ? Colours.palette.m3primaryContainer : Colours.tPalette.m3surfaceContainerHigh
StyledRect {
anchors.fill: parent
radius: parent.radius
color: Qt.alpha(ethernetItem.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: ethernetItem.modelData.connected ? 1 : 0
color: ethernetItem.modelData.connected ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface
Behavior on fill {
Anim {}
}
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 0
StyledText {
Layout.fillWidth: true
text: ethernetItem.modelData.interface || qsTr("Unknown")
elide: Text.ElideRight
}
RowLayout {
Layout.fillWidth: true
spacing: Appearance.spacing.smaller
StyledText {
Layout.fillWidth: true
text: ethernetItem.modelData.connected ? qsTr("Connected") : qsTr("Disconnected")
color: ethernetItem.modelData.connected ? Colours.palette.m3primary : Colours.palette.m3outline
font.pointSize: Appearance.font.size.small
font.weight: ethernetItem.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, ethernetItem.modelData.connected ? 1 : 0)
StateLayer {
function onClicked(): void {
if (ethernetItem.modelData.connected && ethernetItem.modelData.connection) {
Nmcli.disconnectEthernet(ethernetItem.modelData.connection, () => {});
} else {
Nmcli.connectEthernet(ethernetItem.modelData.connection || "", ethernetItem.modelData.interface || "", () => {});
}
}
color: ethernetItem.modelData.connected ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface
}
MaterialIcon {
id: connectIcon
anchors.centerIn: parent
animate: true
text: ethernetItem.modelData.connected ? "link_off" : "link"
color: ethernetItem.modelData.connected ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface
}
}
}
}
}
onItemSelected: function (item) {
session.ethernet.active = item;
}
}
================================================
FILE: modules/controlcenter/network/EthernetPane.qml
================================================
pragma ComponentBehavior: Bound
import ".."
import "../components"
import qs.components.containers
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
}
}
}
}
================================================
FILE: modules/controlcenter/network/EthernetSettings.qml
================================================
pragma ComponentBehavior: Bound
import ".."
import "../components"
import qs.components
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
}
}
}
}
================================================
FILE: modules/controlcenter/network/NetworkSettings.qml
================================================
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.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
}
}
}
}
================================================
FILE: modules/controlcenter/network/NetworkingPane.qml
================================================
pragma ComponentBehavior: Bound
import ".."
import "../components"
import "."
import qs.components
import qs.components.controls
import qs.components.containers
import qs.services
import qs.config
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
asynchronous: true
sourceComponent: Component {
VpnList {
session: root.session
showHeader: false
}
}
}
}
CollapsibleSection {
id: ethernetListSection
Layout.fillWidth: true
title: qsTr("Ethernet")
expanded: true
Loader {
Layout.fillWidth: true
asynchronous: true
sourceComponent: Component {
EthernetList {
session: root.session
showHeader: false
}
}
}
}
CollapsibleSection {
id: wirelessListSection
Layout.fillWidth: true
title: qsTr("Wireless")
expanded: true
Loader {
Layout.fillWidth: true
asynchronous: 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 {
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();
}
target: root.session && root.session.vpn ? root.session.vpn : null
enabled: target !== null
}
Connections {
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();
}
target: root.session && root.session.ethernet ? root.session.ethernet : null
enabled: target !== null
}
Connections {
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();
}
target: root.session && root.session.network ? root.session.network : null
enabled: target !== null
}
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
}
}
================================================
FILE: modules/controlcenter/network/VpnDetails.qml
================================================
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.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: ""
function closeWithAnimation(): void {
close();
}
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
}
}
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();
}
}
}
}
}
}
================================================
FILE: modules/controlcenter/network/VpnList.qml
================================================
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 {
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();
});
}
}
target: VPN
}
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 {
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();
});
}
}
}
enabled: !VPN.connecting
}
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: ""
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();
}
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
}
}
}
Overlay.modal: Rectangle {
color: Qt.rgba(0, 0, 0, 0.4 * vpnDialog.opacity)
}
onClosed: {
currentState = "selection";
}
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();
}
}
}
}
}
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
}
}
}
}
}
================================================
FILE: modules/controlcenter/network/VpnSettings.qml
================================================
pragma ComponentBehavior: Bound
import ".."
import "../components"
import qs.components
import qs.components.controls
import qs.services
import qs.config
import Quickshell
import QtQuick
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();
}
}
}
}
================================================
FILE: modules/controlcenter/network/WirelessDetails.qml
================================================
pragma ComponentBehavior: Bound
import ".."
import "../components"
import qs.components
import qs.components.controls
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
function checkSavedProfile(): void {
if (network && network.ssid) {
Nmcli.loadSavedConnections(() => {});
}
}
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;
}
}
device: network
Component.onCompleted: {
updateDeviceDetails();
checkSavedProfile();
}
onNetworkChanged: {
connectionUpdateTimer.stop();
if (network && network.ssid) {
connectionUpdateTimer.start();
}
updateDeviceDetails();
checkSavedProfile();
}
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
}
}
}
}
]
Connections {
function onActiveChanged() {
root.updateDeviceDetails();
}
function onWirelessDeviceDetailsChanged() {
if (root.network && root.network.ssid) {
const isActive = root.network.active || (Nmcli.active && Nmcli.active.ssid === root.network.ssid);
if (isActive && Nmcli.wirelessDeviceDetails && Nmcli.wirelessDeviceDetails !== null) {
connectionUpdateTimer.stop();
}
}
}
target: Nmcli
}
Timer {
id: connectionUpdateTimer
interval: 500
repeat: true
running: root.network && root.network.ssid
onTriggered: {
if (root.network) {
const isActive = root.network.active || (Nmcli.active && Nmcli.active.ssid === root.network.ssid);
if (isActive) {
if (!Nmcli.wirelessDeviceDetails || Nmcli.wirelessDeviceDetails === null) {
Nmcli.getWirelessDeviceDetails("", () => {});
} else {
connectionUpdateTimer.stop();
}
} else {
if (Nmcli.wirelessDeviceDetails !== null) {
Nmcli.wirelessDeviceDetails = null;
}
}
}
}
}
}
================================================
FILE: modules/controlcenter/network/WirelessList.qml
================================================
pragma ComponentBehavior: Bound
import ".."
import "../components"
import qs.components
import qs.components.controls
import qs.services
import qs.config
import qs.utils
import Quickshell
import QtQuick
import QtQuick.Layouts
DeviceList {
id: root
required property Session session
function checkSavedProfileForNetwork(ssid: string): void {
if (ssid && ssid.length > 0) {
Nmcli.loadSavedConnections(() => {});
}
}
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 {
id: networkDelegate
required property var modelData
width: ListView.view ? ListView.view.width : undefined
color: Qt.alpha(Colours.tPalette.m3surfaceContainer, root.activeItem === networkDelegate.modelData ? Colours.tPalette.m3surfaceContainer.a : 0)
radius: Appearance.rounding.normal
StateLayer {
function onClicked(): void {
root.session.network.active = networkDelegate.modelData;
if (networkDelegate.modelData && networkDelegate.modelData.ssid) {
root.checkSavedProfileForNetwork(networkDelegate.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: networkDelegate.modelData.active ? Colours.palette.m3primaryContainer : Colours.tPalette.m3surfaceContainerHigh
MaterialIcon {
id: icon
anchors.centerIn: parent
text: Icons.getNetworkIcon(networkDelegate.modelData.strength, networkDelegate.modelData.isSecure)
font.pointSize: Appearance.font.size.large
fill: networkDelegate.modelData.active ? 1 : 0
color: networkDelegate.modelData.active ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 0
StyledText {
Layout.fillWidth: true
elide: Text.ElideRight
maximumLineCount: 1
text: networkDelegate.modelData.ssid || qsTr("Unknown")
}
RowLayout {
Layout.fillWidth: true
spacing: Appearance.spacing.smaller
StyledText {
Layout.fillWidth: true
text: {
if (networkDelegate.modelData.active)
return qsTr("Connected");
if (networkDelegate.modelData.isSecure && networkDelegate.modelData.security && networkDelegate.modelData.security.length > 0) {
return networkDelegate.modelData.security;
}
if (networkDelegate.modelData.isSecure)
return qsTr("Secured");
return qsTr("Open");
}
color: networkDelegate.modelData.active ? Colours.palette.m3primary : Colours.palette.m3outline
font.pointSize: Appearance.font.size.small
font.weight: networkDelegate.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, networkDelegate.modelData.active ? 1 : 0)
StateLayer {
function onClicked(): void {
if (networkDelegate.modelData.active) {
Nmcli.disconnectFromNetwork();
} else {
NetworkConnection.handleConnect(networkDelegate.modelData, root.session, null);
}
}
}
MaterialIcon {
id: connectIcon
anchors.centerIn: parent
text: networkDelegate.modelData.active ? "link_off" : "link"
color: networkDelegate.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);
}
}
}
================================================
FILE: modules/controlcenter/network/WirelessPane.qml
================================================
pragma ComponentBehavior: Bound
import ".."
import "../components"
import qs.components.containers
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
}
}
}
================================================
FILE: modules/controlcenter/network/WirelessPasswordDialog.qml
================================================
pragma ComponentBehavior: Bound
import ".."
import qs.components
import qs.components.controls
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
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);
}
}
}
}
function closeDialog(): void {
if (isClosing) {
return;
}
isClosing = true;
passwordContainer.passwordBuffer = "";
connectButton.connecting = false;
connectButton.hasError = false;
connectButton.text = qsTr("Connect");
connectionMonitor.stop();
}
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: root.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: 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 {
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
property string passwordBuffer: ""
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;
}
}
Connections {
function onShowPasswordDialogChanged(): void {
if (root.session.network.showPasswordDialog) {
Qt.callLater(() => {
passwordContainer.forceActiveFocus();
passwordContainer.passwordBuffer = "";
connectButton.hasError = false;
});
}
}
target: root.session.network
}
Connections {
function onVisibleChanged(): void {
if (root.visible) {
Qt.callLater(() => {
passwordContainer.forceActiveFocus();
});
}
}
target: root
}
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 {
function onClicked(): void {
passwordContainer.forceActiveFocus();
}
hoverEnabled: false
cursorShape: Qt.IBeamCursor
}
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();
}
}
}
}
}
Timer {
id: connectionMonitor
property int repeatCount: 0
interval: 1000
repeat: true
triggeredOnStart: false
onTriggered: {
repeatCount++;
root.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");
root.closeDialog();
}
}
}
}
Connections {
function onActiveChanged() {
if (root.visible) {
root.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);
}
}
target: Nmcli
}
}
================================================
FILE: modules/controlcenter/network/WirelessSettings.qml
================================================
pragma ComponentBehavior: Bound
import ".."
import "../components"
import qs.components
import qs.components.controls
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")
}
}
}
================================================
FILE: modules/controlcenter/state/BluetoothState.qml
================================================
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
}
================================================
FILE: modules/controlcenter/state/EthernetState.qml
================================================
import QtQuick
QtObject {
id: root
property var active: null
}
================================================
FILE: modules/controlcenter/state/LauncherState.qml
================================================
import QtQuick
QtObject {
id: root
property var active: null
}
================================================
FILE: modules/controlcenter/state/NetworkState.qml
================================================
import QtQuick
QtObject {
id: root
property var active: null
property bool showPasswordDialog: false
property var pendingNetwork: null
}
================================================
FILE: modules/controlcenter/state/VpnState.qml
================================================
import QtQuick
QtObject {
property var active: null
}
================================================
FILE: modules/controlcenter/taskbar/TaskbarPane.qml
================================================
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.Widgets
import QtQuick
import QtQuick.Layouts
Item {
id: root
required property Session session
property bool activeWindowCompact: Config.bar.activeWindow.compact ?? false
property bool activeWindowInverted: Config.bar.activeWindow.inverted ?? false
property bool clockShowIcon: Config.bar.clock.showIcon ?? true
property bool clockBackground: Config.bar.clock.background ?? false
property bool clockShowDate: Config.bar.clock.showDate ?? false
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 int workspacesMaxWindowIcons: Config.bar.workspaces.maxWindowIcons ?? 0
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
property list monitorNames: Hypr.monitorNames()
property list excludedScreens: Config.bar.excludedScreens ?? []
function saveConfig(entryIndex, entryEnabled) {
Config.bar.activeWindow.compact = root.activeWindowCompact;
Config.bar.activeWindow.inverted = root.activeWindowInverted;
Config.bar.clock.background = root.clockBackground;
Config.bar.clock.showDate = root.clockShowDate;
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.maxWindowIcons = root.workspacesMaxWindowIcons;
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;
Config.bar.excludedScreens = root.excludedScreens;
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();
}
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
});
}
}
}
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
asynchronous: true
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: workspacesMaxWindowIconsRow.implicitHeight + Appearance.padding.large * 2
radius: Appearance.rounding.normal
color: Colours.layer(Colours.palette.m3surfaceContainer, 2)
Behavior on implicitHeight {
Anim {}
}
RowLayout {
id: workspacesMaxWindowIconsRow
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("Max window icons")
}
CustomSpinBox {
min: 0
max: 20
value: root.workspacesMaxWindowIcons
onValueModified: value => {
root.workspacesMaxWindowIcons = value;
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("Background")
checked: root.clockBackground
onToggled: checked => {
root.clockBackground = checked;
root.saveConfig();
}
}
SwitchRow {
label: qsTr("Show date")
checked: root.clockShowDate
onToggled: checked => {
root.clockShowDate = checked;
root.saveConfig();
}
}
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();
}
}
}
}
SectionContainer {
Layout.fillWidth: true
alignTop: true
StyledText {
text: qsTr("Active window")
font.pointSize: Appearance.font.size.normal
}
SwitchRow {
label: qsTr("Compact")
checked: root.activeWindowCompact
onToggled: checked => {
root.activeWindowCompact = checked;
root.saveConfig();
}
}
SwitchRow {
label: qsTr("Inverted")
checked: root.activeWindowInverted
onToggled: checked => {
root.activeWindowInverted = checked;
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();
}
}
]
}
}
SectionContainer {
Layout.fillWidth: true
alignTop: true
StyledText {
text: qsTr("Monitors")
font.pointSize: Appearance.font.size.normal
}
ConnectedButtonGroup {
rootItem: root
// max 3 options per line
rows: Math.ceil(root.monitorNames.length / 3)
options: root.monitorNames.map(e => ({
label: qsTr(e),
propertyName: `monitor${e}`,
onToggled: function (_) {
// if the given monitor is in the excluded list, it should be added back
let addedBack = excludedScreens.includes(e);
if (addedBack) {
const index = excludedScreens.indexOf(e);
if (index !== -1) {
excludedScreens.splice(index, 1);
}
} else {
if (!excludedScreens.includes(e)) {
excludedScreens.push(e);
}
}
root.saveConfig();
},
state: !Strings.testRegexList(root.excludedScreens, e)
}))
}
}
}
}
}
}
}
}
================================================
FILE: modules/dashboard/Background.qml
================================================
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 {}
}
}
================================================
FILE: modules/dashboard/Content.qml
================================================
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 DrawerVisibilities visibilities
readonly property bool needsKeyboard: {
const count = repeater.count;
for (let i = 0; i < count; i++) {
const item = repeater.itemAt(i) as Loader;
if (item?.sourceComponent === mediaComponent && (item?.item as MediaWrapper)?.needsKeyboard)
return true;
}
return false;
}
required property DashboardState state
required property FileDialog facePicker
readonly property var dashboardTabs: {
const allTabs = [
{
component: dashComponent,
iconName: "dashboard",
text: qsTr("Dashboard"),
enabled: Config.dashboard.showDashboard
},
{
component: mediaComponent,
iconName: "queue_music",
text: qsTr("Media"),
enabled: Config.dashboard.showMedia
},
{
component: performanceComponent,
iconName: "speed",
text: qsTr("Performance"),
enabled: Config.dashboard.showPerformance && (Config.dashboard.performance.showCpu || Config.dashboard.performance.showGpu || Config.dashboard.performance.showMemory || Config.dashboard.performance.showStorage || Config.dashboard.performance.showNetwork || Config.dashboard.performance.showBattery)
},
{
component: weatherComponent,
iconName: "cloud",
text: qsTr("Weather"),
enabled: Config.dashboard.showWeather
}
];
return allTabs.filter(tab => tab.enabled);
}
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
tabs: root.dashboardTabs
}
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: {
repeater.count; // Trigger update on count change
return repeater.itemAt(currentIndex);
}
anchors.fill: parent
flickableDirection: Flickable.HorizontalFlick
implicitWidth: currentItem?.implicitWidth ?? 0
implicitHeight: currentItem?.implicitHeight ?? 0
contentX: currentItem?.x ?? 0
contentWidth: row.implicitWidth
contentHeight: row.implicitHeight
onContentXChanged: {
if (!moving || !currentItem)
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: {
if (!currentItem)
return;
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 ?? 0);
}
RowLayout {
id: row
Repeater {
id: repeater
model: ScriptModel {
values: root.dashboardTabs
}
delegate: Loader {
id: paneLoader
required property int index
required property var modelData
Layout.alignment: Qt.AlignTop
sourceComponent: modelData.component
Component.onCompleted: active = Qt.binding(() => {
if (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);
})
}
}
}
Component {
id: dashComponent
Dash {
visibilities: root.visibilities
state: root.state
facePicker: root.facePicker
}
}
Component {
id: mediaComponent
MediaWrapper {
visibilities: root.visibilities
}
}
Component {
id: performanceComponent
Performance {}
}
Component {
id: weatherComponent
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
}
}
}
================================================
FILE: modules/dashboard/Dash.qml
================================================
import qs.components
import qs.components.filedialog
import qs.services
import qs.config
import "dash"
import QtQuick.Layouts
GridLayout {
id: root
required property DrawerVisibilities visibilities
required property DashboardState 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
}
}
================================================
FILE: modules/dashboard/LyricMenu.qml
================================================
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 real contentHeight
function searchCandidates(title, artist) {
LyricsService.currentRequestId++;
LyricsService.fetchNetEaseCandidates(title, artist, LyricsService.currentRequestId);
}
implicitHeight: contentHeight
radius: Appearance.rounding.large
color: Colours.tPalette.m3surfaceContainer
Loader {
asynchronous: true
anchors.fill: parent
active: root.height > 0
sourceComponent: ColumnLayout {
anchors.fill: parent
anchors.margins: Appearance.padding.large
spacing: Appearance.spacing.normal
// Header: icon, backend name, refresh, toggle
RowLayout {
Layout.fillWidth: true
spacing: Appearance.padding.small
MaterialIcon {
text: "lyrics"
fill: 1
color: Colours.palette.m3primary
font.pointSize: Appearance.spacing.large
}
StyledText {
Layout.fillWidth: true
text: LyricsService.backend
font.pointSize: Appearance.font.size.normal
color: Colours.palette.m3secondary
elide: Text.ElideRight
}
IconButton {
icon: "refresh"
type: IconButton.Text
onClicked: LyricsService.loadLyrics()
}
StyledSwitch {
checked: LyricsService.lyricsVisible
onToggled: LyricsService.toggleVisibility()
}
}
StyledText {
Layout.fillWidth: true
text: "Fetched Candidates:"
color: Colours.palette.m3outline
font.pointSize: Appearance.font.size.small
elide: Text.ElideRight
}
// Candidates list
ListView {
id: candidatesView
Layout.fillWidth: true
Layout.fillHeight: true
visible: LyricsService.candidatesModel.count > 0
model: LyricsService.candidatesModel
clip: true
spacing: Appearance.spacing.small
opacity: visible ? 1 : 0
// Behavior on opacity {
// NumberAnimation { duration: Appearance.anim.durations.normal }
// }
delegate: Item {
id: delegateRoot
required property real id
required property string title
required property string artist
property bool hovered: false
property bool pressed: false
width: ListView.view.width * 0.98
height: 70
anchors.horizontalCenter: parent?.horizontalCenter
scale: hovered ? 1.02 : 1.0
Behavior on scale {
NumberAnimation {
duration: Appearance.anim.durations.small
easing.type: Easing.OutCubic
}
}
Rectangle {
id: background
anchors.fill: parent
radius: Appearance.rounding.small
color: delegateRoot.pressed ? Qt.rgba(Colours.palette.m3primary.r, Colours.palette.m3primary.g, Colours.palette.m3primary.b, 0.25) : delegateRoot.hovered ? Qt.rgba(Colours.palette.m3primary.r, Colours.palette.m3primary.g, Colours.palette.m3primary.b, 0.06) : Qt.rgba(Colours.palette.m3primary.r, Colours.palette.m3primary.g, Colours.palette.m3primary.b, 0.03)
border.width: delegateRoot.hovered ? 1 : 0
border.color: Colours.palette.m3primary
Behavior on color {
ColorAnimation {
duration: Appearance.anim.durations.small
}
}
Behavior on border.width {
NumberAnimation {
duration: Appearance.anim.durations.small
}
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onEntered: delegateRoot.hovered = true
onExited: delegateRoot.hovered = false
onPressed: delegateRoot.pressed = true
onReleased: delegateRoot.pressed = false
onClicked: LyricsService.selectCandidate(delegateRoot.id)
}
Row {
anchors.fill: parent
anchors.margins: Appearance.padding.normal
spacing: Appearance.spacing.small
// Active indicator bar
Rectangle {
width: 4
height: parent.height * 0.6
radius: 2
anchors.verticalCenter: parent.verticalCenter
color: LyricsService.currentSongId === delegateRoot.id ? Colours.palette.m3primary : "transparent"
Behavior on color {
ColorAnimation {
duration: Appearance.anim.durations.small
}
}
}
Column {
anchors.verticalCenter: parent.verticalCenter
width: parent.width - 30
spacing: 4
Text {
text: delegateRoot.title
font.pointSize: Appearance.font.size.normal
font.bold: true
color: delegateRoot.hovered ? Colours.palette.m3primary : Colours.palette.m3onSurface
width: parent.width
elide: Text.ElideRight
Behavior on color {
ColorAnimation {
duration: Appearance.anim.durations.small
}
}
}
Text {
text: delegateRoot.artist
font.pointSize: Appearance.font.size.small
color: Colours.palette.m3onSurfaceVariant
elide: Text.ElideRight
}
}
}
}
}
Item {
Layout.fillHeight: true
visible: LyricsService.candidatesModel.count == 0
}
// Manual search
ColumnLayout {
Layout.fillWidth: true
spacing: Appearance.padding.small
StyledText {
Layout.fillWidth: true
text: "Manual Search"
font.pointSize: Appearance.font.size.small
color: Colours.palette.m3onSurfaceVariant
elide: Text.ElideRight
}
RowLayout {
Layout.fillWidth: true
spacing: Appearance.padding.small
StyledInputField {
id: searchTitle
Layout.fillWidth: true
horizontalAlignment: TextInput.AlignLeft
Binding {
target: searchTitle
property: "text"
value: (Players.active?.trackTitle ?? qsTr("title")) || qsTr("title")
}
}
StyledInputField {
id: searchArtist
Layout.fillWidth: true
horizontalAlignment: TextInput.AlignLeft
Binding {
target: searchArtist
property: "text"
value: (Players.active?.trackArtist ?? qsTr("artist")) || qsTr("artist")
}
}
IconButton {
icon: "search"
onClicked: root.searchCandidates(searchTitle.text, searchArtist.text)
}
}
}
// Offset controls
RowLayout {
Layout.fillWidth: true
spacing: Appearance.padding.small
MaterialIcon {
text: "contrast_square"
font.pointSize: Appearance.font.size.large
color: Colours.palette.m3secondary
}
StyledText {
text: "Offset"
color: Colours.palette.m3outline
font.pointSize: Appearance.font.size.normal
}
Item {
Layout.fillWidth: true
}
IconButton {
icon: "remove"
type: IconButton.Text
onClicked: {
LyricsService.offset = parseFloat((LyricsService.offset - 0.1).toFixed(1));
LyricsService.savePrefs();
}
}
TextInput {
id: offsetInput
horizontalAlignment: TextInput.AlignHCenter
color: Colours.palette.m3secondary
font.pointSize: Appearance.font.size.normal
selectByMouse: true
text: (LyricsService.offset >= 0 ? "+" : "") + LyricsService.offset.toFixed(1) + "s"
onEditingFinished: {
let cleaned = offsetInput.text.replace(/[+s]/g, "").trim();
let val = parseFloat(cleaned);
if (!isNaN(val)) {
LyricsService.offset = parseFloat(val.toFixed(1));
LyricsService.savePrefs();
} else {
offsetInput.text = (LyricsService.offset >= 0 ? "+" : "") + LyricsService.offset.toFixed(1) + "s";
}
}
Binding {
target: offsetInput
property: "text"
value: (LyricsService.offset >= 0 ? "+" : "") + LyricsService.offset.toFixed(1) + "s"
when: !offsetInput.activeFocus
}
Connections {
function onCurrentRequestIdChanged() {
offsetInput.focus = false;
}
target: LyricsService
}
}
IconButton {
icon: "add"
type: IconButton.Text
onClicked: {
LyricsService.offset = parseFloat((LyricsService.offset + 0.1).toFixed(1));
LyricsService.savePrefs();
}
}
}
}
}
}
================================================
FILE: modules/dashboard/LyricsView.qml
================================================
import qs.components
import qs.components.containers
import qs.services
import qs.config
import Quickshell
import QtQuick
import QtQuick.Effects
StyledListView {
id: root
readonly property bool lyricsActuallyVisible: LyricsService.lyricsVisible && LyricsService.model.count != 0
clip: true
model: LyricsService.model
currentIndex: LyricsService.currentIndex
visible: lyricsActuallyVisible || hideTimer.running
preferredHighlightBegin: height / 2 - 30
preferredHighlightEnd: height / 2 + 30
highlightRangeMode: ListView.ApplyRange
highlightFollowsCurrentItem: true
highlightMoveDuration: LyricsService.isManualSeeking ? 0 : Appearance.anim.durations.normal
layer.enabled: true
layer.effect: ShaderEffect {
required property Item source
property real fadeMargin: 0.5
fragmentShader: Quickshell.shellPath("assets/shaders/fade.frag.qsb")
}
onLyricsActuallyVisibleChanged: {
if (!lyricsActuallyVisible)
hideTimer.restart();
}
onModelChanged: {
if (model && model.count > 0) {
Qt.callLater(() => positionViewAtIndex(currentIndex, ListView.Center));
}
}
delegate: Item {
id: delegateRoot
required property string lyricLine
required property real time
required property int index
readonly property bool hasContent: lyricLine && lyricLine.trim().length > 0
property bool isCurrent: ListView.isCurrentItem
width: ListView.view.width
height: hasContent ? (lyricText.contentHeight + Appearance.spacing.large) : 0
MultiEffect {
id: effect
anchors.fill: lyricText
source: lyricText
scale: lyricText.scale
enabled: delegateRoot.isCurrent
visible: delegateRoot.isCurrent
blurEnabled: true
blur: 0.4
shadowEnabled: true
shadowColor: Colours.palette.m3primary
shadowOpacity: 0.5
shadowBlur: 0.6
shadowHorizontalOffset: 0
shadowVerticalOffset: 0
autoPaddingEnabled: true
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: LyricsService.jumpTo(delegateRoot.index, delegateRoot.time)
}
Text {
id: lyricText
text: delegateRoot.lyricLine ? delegateRoot.lyricLine.replace(/\u00A0/g, " ") : ""
width: parent.width * 0.85
anchors.centerIn: parent
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
font.pointSize: Appearance.font.size.normal
color: delegateRoot.isCurrent ? Colours.palette.m3primary : Colours.palette.m3onSurfaceVariant
font.bold: delegateRoot.isCurrent
scale: delegateRoot.isCurrent ? 1.15 : 1.0
Behavior on color {
CAnim {
duration: Appearance.anim.durations.small
}
}
Behavior on scale {
Anim {
duration: Appearance.anim.durations.small
}
}
}
}
Timer {
id: hideTimer
interval: 300 // long enough to bridge the track switch gap
running: false
repeat: false
}
}
================================================
FILE: modules/dashboard/Media.qml
================================================
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 DrawerVisibilities visibilities
readonly property bool needsKeyboard: lyricMenuOpen
readonly property real nonAnimHeight: Math.max(cover.implicitHeight + Config.dashboard.sizes.mediaVisualiserSize * 2, lyricMenuOpen ? lyricMenu.implicitHeight : details.implicitHeight, bongocat.implicitHeight) + Appearance.padding.large * 2
readonly property real detailsHeightWithoutLyrics: details.implicitHeight - lyricsViewInDetails.implicitHeight
property bool lyricMenuOpen: false
property bool lyricsShowing: LyricsService.lyricsVisible && LyricsService.model.count != 0
property bool lyricsShowingDebounced: false
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}`;
}
onLyricsShowingChanged: {
if (lyricsShowing) {
lyricsHideDelay.stop();
lyricsShowingDebounced = true;
} else {
lyricsHideDelay.restart();
}
}
implicitWidth: cover.implicitWidth + Config.dashboard.sizes.mediaVisualiserSize * 2 + details.implicitWidth + details.anchors.leftMargin + bongocat.implicitWidth + bongocat.anchors.leftMargin * 2 + Appearance.padding.large * 2
implicitHeight: nonAnimHeight
Behavior on implicitHeight {
Anim {}
}
Behavior on playerProgress {
Anim {
duration: Appearance.anim.durations.large
}
}
Timer {
running: Players.active?.isPlaying ?? false
interval: Config.dashboard.mediaUpdateInterval
triggeredOnStart: true
repeat: true
onTriggered: {
if (!Players.active)
return;
LyricsService.updatePosition();
Players.active?.positionChanged();
}
}
Timer {
id: lyricsHideDelay
interval: 300
repeat: false
}
Connections {
function onTriggered() {
root.lyricsShowingDebounced = false;
}
target: lyricsHideDelay
}
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
MouseArea {
anchors.fill: parent
onClicked: {
LyricsService.toggleVisibility();
}
}
}
}
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
}
LyricsView {
id: lyricsViewInDetails
Layout.fillWidth: true
Layout.preferredHeight: 200
}
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: Players.active?.shuffle ? "shuffle_on" : "shuffle"
font.pointSize: Math.round(Appearance.font.size.large)
disabled: !Players.active?.shuffleSupported
onClicked: Players.active.shuffle = !Players.active?.shuffle
}
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()
}
PlayerControl {
type: IconButton.Text
icon: "lyrics"
font.pointSize: Math.round(Appearance.font.size.large)
onClicked: root.lyricMenuOpen = !root.lyricMenuOpen
}
}
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 {
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));
});
}
anchors.fill: parent
acceptedButtons: Qt.NoButton
}
}
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
}
}
}
ColumnLayout {
id: leftSection
anchors.verticalCenter: parent.verticalCenter
anchors.verticalCenterOffset: playerChanger.parent == leftSection ? -playerChanger.height : 0
anchors.left: details.right
anchors.leftMargin: Appearance.spacing.normal
visible: lyricMenu.height === 0 || opacity > 0
opacity: lyricMenu.height === 0 ? 1 : 0
Behavior on opacity {
NumberAnimation {
duration: Appearance.anim.durations.normal
easing.type: Easing.OutCubic
}
}
Item {
id: bongocat
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
}
}
}
LyricMenu {
id: lyricMenu
anchors.top: parent.top
anchors.left: details.right
anchors.right: parent.right
anchors.leftMargin: Appearance.spacing.normal
contentHeight: !root.lyricsShowingDebounced ? root.detailsHeightWithoutLyrics + Appearance.padding.large * 5 : root.detailsHeightWithoutLyrics + lyricsViewInDetails.implicitHeight
visible: root.lyricMenuOpen || height > 0
height: root.lyricMenuOpen ? implicitHeight : 0
clip: true
Behavior on height {
NumberAnimation {
duration: Appearance.anim.durations.normal
easing.type: Easing.OutCubic
}
}
}
RowLayout {
id: playerChanger
parent: !root.lyricsShowingDebounced ? details : leftSection
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()
}
}
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
}
}
}
}
================================================
FILE: modules/dashboard/MediaWrapper.qml
================================================
import QtQuick
Item {
property alias visibilities: media.visibilities
readonly property alias needsKeyboard: media.needsKeyboard
implicitWidth: media.implicitWidth
implicitHeight: media.nonAnimHeight
Media {
id: media
}
}
================================================
FILE: modules/dashboard/Performance.qml
================================================
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell.Services.UPower
import Caelestia.Internal
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
ArcGauge {
anchors.centerIn: parent
width: Math.min(parent.width, parent.height)
height: width
percentage: gaugeCard.animatedPercentage
accentColor: gaugeCard.accentColor
trackColor: Colours.layer(Colours.palette.m3surfaceContainerHigh, 2)
startAngle: gaugeCard.arcStartAngle
sweepAngle: gaugeCard.arcSweep
}
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
ArcGauge {
anchors.centerIn: parent
width: Math.min(parent.width, parent.height)
height: width
percentage: storageGaugeCard.animatedPercentage
accentColor: storageGaugeCard.accentColor
trackColor: Colours.layer(Colours.palette.m3surfaceContainerHigh, 2)
startAngle: storageGaugeCard.arcStartAngle
sweepAngle: storageGaugeCard.arcSweep
}
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
SparklineItem {
id: sparkline
property real targetMax: 1024
property real smoothMax: targetMax
anchors.fill: parent
line1: NetworkUsage.uploadBuffer
line1Color: Colours.palette.m3secondary
line1FillAlpha: 0.15
line2: NetworkUsage.downloadBuffer
line2Color: Colours.palette.m3tertiary
line2FillAlpha: 0.2
maxValue: smoothMax
historyLength: NetworkUsage.historyLength
Connections {
function onValuesChanged(): void {
sparkline.targetMax = Math.max(NetworkUsage.downloadBuffer.maximum, NetworkUsage.uploadBuffer.maximum, 1024);
slideAnim.restart();
}
target: NetworkUsage.downloadBuffer
}
NumberAnimation {
id: slideAnim
target: sparkline
property: "slideProgress"
from: 0
to: 1
duration: Config.dashboard.resourceUpdateInterval
}
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.downloadBuffer.count < 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
}
}
}
}
}
================================================
FILE: modules/dashboard/Tabs.qml
================================================
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 DashboardState state
required property var tabs
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
Repeater {
model: ScriptModel {
values: root.tabs
}
delegate: Tab {
required property var modelData
iconName: modelData.iconName
text: modelData.text
}
}
}
Item {
id: indicator
anchors.top: bar.bottom
anchors.topMargin: 5
implicitWidth: {
const tab = bar.currentItem;
if (tab)
return tab.implicitWidth;
const width = (root.nonAnimWidth - bar.spacing * (bar.count - 1)) / bar.count;
return width;
}
implicitHeight: 3
x: {
const tab = bar.currentItem;
const width = (root.nonAnimWidth - bar.spacing * (bar.count - 1)) / bar.count;
const tabWidth = tab?.implicitWidth ?? width;
return width * bar.currentIndex + (width - tabWidth) / 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
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);
}
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();
}
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
}
}
}
}
================================================
FILE: modules/dashboard/Weather.qml
================================================
import qs.components
import qs.services
import qs.config
import QtQuick
import QtQuick.Layouts
Item {
id: root
readonly property var today: Weather.forecast && Weather.forecast.length > 0 ? Weather.forecast[0] : null
implicitWidth: layout.implicitWidth > 800 ? layout.implicitWidth : 840
implicitHeight: layout.implicitHeight
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
}
}
}
}
================================================
FILE: modules/dashboard/Wrapper.qml
================================================
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 DrawerVisibilities visibilities
readonly property bool needsKeyboard: (content.item as Content)?.needsKeyboard ?? false
readonly property DashboardState dashState: DashboardState {
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 as Content)?.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
}
}
}
================================================
FILE: modules/dashboard/dash/Calendar.qml
================================================
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()
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);
}
anchors.left: parent.left
anchors.right: parent.right
implicitHeight: inner.implicitHeight + inner.anchors.margins * 2
acceptedButtons: Qt.MiddleButton
onClicked: root.state.currentDate = new Date()
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
function onClicked(): void {
root.state.currentDate = new Date(root.currYear, root.currMonth - 1, 1);
}
radius: Appearance.rounding.full
}
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 {
function onClicked(): void {
root.state.currentDate = new Date();
}
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();
}
}
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
function onClicked(): void {
root.state.currentDate = new Date(root.currYear, root.currMonth + 1, 1);
}
radius: Appearance.rounding.full
}
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
}
}
}
}
}
}
================================================
FILE: modules/dashboard/dash/DateTime.qml
================================================
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 {
asynchronous: true
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
}
}
}
}
================================================
FILE: modules/dashboard/dash/Media.qml
================================================
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 {
function onClicked(): void {
Players.active?.previous();
}
icon: "skip_previous"
canUse: Players.active?.canGoPrevious ?? false
}
Control {
function onClicked(): void {
Players.active?.togglePlaying();
}
icon: Players.active?.isPlaying ? "pause" : "play_arrow"
canUse: Players.active?.canTogglePlaying ?? false
}
Control {
function onClicked(): void {
Players.active?.next();
}
icon: "skip_next"
canUse: Players.active?.canGoNext ?? false
}
}
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 {
function onClicked(): void {
control.onClicked();
}
disabled: !control.canUse
radius: Appearance.rounding.full
}
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
}
}
}
================================================
FILE: modules/dashboard/dash/Resources.qml
================================================
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
}
}
}
}
================================================
FILE: modules/dashboard/dash/User.qml
================================================
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 QtQuick
Row {
id: root
required property DrawerVisibilities visibilities
required property DashboardState 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
visible: pfp.status !== Image.Ready
}
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 {
function onClicked(): void {
root.visibilities.launcher = false;
root.facePicker.open();
}
color: Colours.palette.m3onPrimary
}
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
}
}
}
================================================
FILE: modules/dashboard/dash/Weather.qml
================================================
import qs.components
import qs.services
import qs.config
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)
}
}
}
================================================
FILE: modules/drawers/Backgrounds.qml
================================================
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
}
}
================================================
FILE: modules/drawers/Border.qml
================================================
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
}
}
}
================================================
FILE: modules/drawers/Drawers.qml
================================================
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.Controls
import QtQuick.Effects
Variants {
model: Screens.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 || panels.dashboard.needsKeyboard ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None
mask: Region {
x: bar.clampedWidth + win.dragMaskPadding
y: Config.border.clampedThickness + win.dragMaskPadding
width: win.width - bar.clampedWidth - Config.border.clampedThickness - win.dragMaskPadding * 2
height: win.height - Config.border.clampedThickness * 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 as StackView)?.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
}
}
DrawerVisibilities {
id: visibilities
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)
}
}
}
}
}
================================================
FILE: modules/drawers/Exclusions.qml
================================================
pragma ComponentBehavior: Bound
import qs.components.containers
import qs.config
import qs.modules.bar as Bar
import Quickshell
import QtQuick
Scope {
id: root
required property ShellScreen screen
required property Bar.BarWrapper 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
}
}
================================================
FILE: modules/drawers/Interactions.qml
================================================
import qs.components
import qs.components.controls
import qs.config
import qs.modules.bar as Bar
import qs.modules.bar.popouts as BarPopouts
import Quickshell
import QtQuick
import QtQuick.Controls
CustomMouseArea {
id: root
required property ShellScreen screen
required property BarPopouts.Wrapper popouts
required property DrawerVisibilities visibilities
required property Panels panels
required property Bar.BarWrapper 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 > Math.min(width - Config.border.minThickness, bar.implicitWidth + panel.x) && withinPanelHeight(panel, x, y);
}
function inTopPanel(panel: Item, x: real, y: real): bool {
return y < Math.max(Config.border.minThickness, Config.border.thickness + panel.height) + panel.y && withinPanelWidth(panel, x, y);
}
function inBottomPanel(panel: Item, x: real, y: real, isCorner = false): bool {
return y > height - Math.max(Config.border.minThickness, Config.border.thickness + panel.height) - (isCorner ? Config.border.rounding : 0) && 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 as StackView)?.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.clampedWidth)
bar.isHovered = true;
// Show/hide bar on drag
if (pressed && dragStart.x < bar.clampedWidth) {
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 > Math.min(width - Config.border.minThickness, 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, true);
// 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 as StackView)?.depth ?? 0) <= 1) && !inLeftPanel(panels.popouts, x, y)) {
popouts.hasCurrent = false;
bar.closeTray();
}
}
// Monitor individual visibility changes
Connections {
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;
}
}
target: root.visibilities
}
}
================================================
FILE: modules/drawers/Panels.qml
================================================
import qs.components
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 as Bar
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 DrawerVisibilities visibilities
required property Bar.BarWrapper 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
sidebarPanel: sidebar
osdPanel: osd
sessionPanel: session
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
}
}
================================================
FILE: modules/launcher/AppList.qml
================================================
pragma ComponentBehavior: Bound
import qs.modules.launcher.items
import qs.modules.launcher.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 DrawerVisibilities 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
}
}
}
================================================
FILE: modules/launcher/Background.qml
================================================
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 {}
}
}
================================================
FILE: modules/launcher/Content.qml
================================================
pragma ComponentBehavior: Bound
import qs.modules.launcher.services
import qs.components
import qs.components.controls
import qs.services
import qs.config
import QtQuick
Item {
id: root
required property DrawerVisibilities 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 {
function onLauncherChanged(): void {
if (!root.visibilities.launcher)
search.text = "";
}
function onSessionChanged(): void {
if (!root.visibilities.session)
search.forceActiveFocus();
}
target: root.visibilities
}
}
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
}
}
}
}
}
================================================
FILE: modules/launcher/ContentList.qml
================================================
pragma ComponentBehavior: Bound
import qs.components
import qs.components.controls
import qs.services
import qs.config
import qs.utils
import QtQuick
Item {
id: root
required property var content
required property DrawerVisibilities 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 var currentList: showWallpapers ? wallpaperList.item : appList.item // Can be either ListView or PathView, so can't type properly
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
asynchronous: true
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
}
}
}
================================================
FILE: modules/launcher/WallpaperList.qml
================================================
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 as QsWindow)?.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 as WallpaperItem).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
}
}
}
================================================
FILE: modules/launcher/Wrapper.qml
================================================
pragma ComponentBehavior: Bound
import qs.components
import qs.config
import Quickshell
import QtQuick
Item {
id: root
required property ShellScreen screen
required property DrawerVisibilities 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 {
function onEnabledChanged(): void {
timer.start();
}
function onMaxShownChanged(): void {
timer.start();
}
target: Config.launcher
}
Connections {
function onValuesChanged(): void {
if (DesktopEntries.applications.values.length < Config.launcher.maxShown)
timer.start();
}
target: DesktopEntries.applications
}
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
}
}
}
================================================
FILE: modules/launcher/items/ActionItem.qml
================================================
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 {
function onClicked(): void {
root.modelData?.onClicked(root.list);
}
radius: Appearance.rounding.normal
}
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
}
}
}
}
================================================
FILE: modules/launcher/items/AppItem.qml
================================================
import qs.modules.launcher.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 DrawerVisibilities visibilities
implicitHeight: Config.launcher.sizes.itemHeight
anchors.left: parent?.left
anchors.right: parent?.right
StateLayer {
function onClicked(): void {
Apps.launch(root.modelData);
root.visibilities.launcher = false;
}
radius: Appearance.rounding.normal
}
Item {
anchors.fill: parent
anchors.leftMargin: Appearance.padding.larger
anchors.rightMargin: Appearance.padding.larger
anchors.margins: Appearance.padding.smaller
IconImage {
id: icon
asynchronous: true
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
asynchronous: true
anchors.verticalCenter: parent.verticalCenter
anchors.right: parent.right
active: root.modelData && Strings.testRegexList(Config.launcher.favouriteApps, root.modelData.id)
sourceComponent: MaterialIcon {
text: "favorite"
fill: 1
color: Colours.palette.m3primary
}
}
}
}
================================================
FILE: modules/launcher/items/CalcItem.qml
================================================
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.rawResult]);
root.list.visibilities.launcher = false;
}
onMathChanged: {
if (math.length > 0)
Qalculator.evalAsync(math);
}
implicitHeight: Config.launcher.sizes.itemHeight
anchors.left: parent?.left
anchors.right: parent?.right
StateLayer {
function onClicked(): void {
root.onClicked();
}
radius: Appearance.rounding.normal
}
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.result || qsTr("Calculating...")) : 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
function onClicked(): void {
Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.terminal, "fish", "-C", `exec qalc -i '${root.math}'`]);
root.list.visibilities.launcher = false;
}
color: Colours.palette.m3onTertiary
}
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
}
}
}
}
}
================================================
FILE: modules/launcher/items/SchemeItem.qml
================================================
import qs.modules.launcher.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 {
function onClicked(): void {
root.modelData?.onClicked(root.list);
}
radius: Appearance.rounding.normal
}
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
asynchronous: true
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
}
}
}
}
================================================
FILE: modules/launcher/items/VariantItem.qml
================================================
import qs.modules.launcher.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 {
function onClicked(): void {
root.modelData?.onClicked(root.list);
}
radius: Appearance.rounding.normal
}
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
asynchronous: true
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
}
}
}
}
================================================
FILE: modules/launcher/items/WallpaperItem.qml
================================================
import qs.components
import qs.components.effects
import qs.components.images
import qs.services
import qs.config
import Caelestia.Models
import QtQuick
Item {
id: root
required property FileSystemEntry modelData
required property DrawerVisibilities visibilities
scale: 0.5
opacity: 0
z: PathView.z ?? 0 // qmllint disable missing-property
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 {
function onClicked(): void {
Wallpapers.setWallpaper(root.modelData.path);
root.visibilities.launcher = false;
}
radius: Appearance.rounding.normal
}
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 {}
}
}
================================================
FILE: modules/launcher/services/Actions.qml
================================================
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);
}
}
}
}
================================================
FILE: modules/launcher/services/Apps.qml
================================================
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))
}
}
================================================
FILE: modules/launcher/services/M3Variants.qml
================================================
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]);
}
}
}
================================================
FILE: modules/launcher/services/Schemes.qml
================================================
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) => String(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]);
}
}
}
================================================
FILE: modules/lock/Center.qml
================================================
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 {
asynchronous: true
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)
visible: pfp.status !== Image.Ready
}
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 {
function onClicked(): void {
parent.forceActiveFocus();
}
hoverEnabled: false
cursorShape: Qt.IBeamCursor
}
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 {
function onClicked(): void {
root.lock.pam.passwd.start();
}
color: root.lock.pam.buffer ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface
}
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 {
function onFlashMsg(): void {
exitAnim.stop();
if (message.scale < 1)
appearAnim.restart();
else
flashAnim.restart();
}
target: root.lock.pam
}
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
}
}
================================================
FILE: modules/lock/Content.qml
================================================
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
}
}
}
}
================================================
FILE: modules/lock/Fetch.qml
================================================
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
}
}
Component {
id: distroIcon
ColouredIcon {
source: SysInfo.osLogo
implicitSize: height
colour: Colours.palette.m3primary
layer.enabled: Config.lock.recolourLogo
}
}
component WrappedLoader: Loader {
asynchronous: true
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
}
}
================================================
FILE: modules/lock/InputField.qml
================================================
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 {
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;
}
target: root.pam
}
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 {}
}
}
}
================================================
FILE: modules/lock/Lock.qml
================================================
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 {
function lock(): void {
lock.locked = true;
}
function unlock(): void {
lock.unlock();
}
function isLocked(): bool {
return lock.locked;
}
target: "lock"
}
}
================================================
FILE: modules/lock/LockSurface.qml
================================================
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 {
function onUnlock(): void {
unlockAnim.start();
}
target: root.lock
}
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
}
}
}
================================================
FILE: modules/lock/Media.qml
================================================
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 {
function onClicked(): void {
if (Players.active?.canGoPrevious)
Players.active.previous();
}
icon: "skip_previous"
}
PlayerControl {
function onClicked(): void {
if (Players.active?.canTogglePlaying)
Players.active.togglePlaying();
}
animate: true
icon: active ? "pause" : "play_arrow"
colour: "Primary"
level: active ? 2 : 1
active: Players.active?.isPlaying ?? false
}
PlayerControl {
function onClicked(): void {
if (Players.active?.canGoNext)
Players.active.next();
}
icon: "skip_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
function onClicked(): void {
control.onClicked();
}
color: control.active ? Colours.palette[`m3on${control.colour}`] : Colours.palette[`m3on${control.colour}Container`]
}
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
}
}
}
}
================================================
FILE: modules/lock/NotifDock.qml
================================================
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 {
asynchronous: true
anchors.centerIn: parent
active: opacity > 0
opacity: Notifs.list.length > 0 && !Config.lock.hideNotifs ? 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: Config.lock.hideNotifs ? qsTr("Unlock for Notifications") : 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
visible: !Config.lock.hideNotifs
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
}
}
}
}
}
================================================
FILE: modules/lock/NotifGroup.qml
================================================
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 var props: {
let img = "";
let icon = "";
let hasCritical = false;
let hasNormal = false;
for (const n of notifs) {
if (!img && n.image.length > 0)
img = n.image;
if (!icon && n.appIcon.length > 0)
icon = n.appIcon;
if (n.urgency === NotificationUrgency.Critical)
hasCritical = true;
else if (n.urgency === NotificationUrgency.Normal)
hasNormal = true;
}
return {
img,
icon,
urgency: hasCritical ? "critical" : hasNormal ? "normal" : "low"
};
}
readonly property string image: props.img
readonly property string appIcon: props.icon
readonly property string urgency: props.urgency
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
sourceSize.width: Config.notifs.sizes.image
sourceSize.height: Config.notifs.sizes.image
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 {
asynchronous: true
anchors.centerIn: parent
sourceComponent: root.image ? imageComp : root.appIcon ? appIconComp : materialIconComp
}
}
Loader {
asynchronous: true
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 {
function onClicked(): void {
root.expanded = !root.expanded;
}
color: root.urgency === "critical" ? Colours.palette.m3onError : Colours.palette.m3onSurface
}
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 {
asynchronous: true
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 NotifData 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
}
}
}
================================================
FILE: modules/lock/Pam.qml
================================================
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 {
function onSecureChanged(): void {
if (root.lock.secure) {
availProc.running = true;
root.buffer = "";
root.state = "";
root.fprintState = "";
root.lockMessage = "";
}
}
function onUnlock(): void {
fprint.abort();
}
target: root.lock
}
Connections {
function onEnableFprintChanged(): void {
fprint.checkAvail();
}
target: Config.lock
}
}
================================================
FILE: modules/lock/Resources.qml
================================================
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
}
}
}
}
================================================
FILE: modules/lock/WeatherInfo.qml
================================================
pragma ComponentBehavior: Bound
import qs.components
import qs.services
import qs.config
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 {
asynchronous: true
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 {
asynchronous: true
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
asynchronous: true
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()
}
}
================================================
FILE: modules/notifications/Background.qml
================================================
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 {}
}
}
================================================
FILE: modules/notifications/Content.qml
================================================
import qs.components
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 DrawerVisibilities visibilities
required property Item osdPanel
required property Item sessionPanel
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) as NotifWrapper)?.nonAnimHeight ?? 0;
if (visibilities.osd) {
const h = osdPanel.y - Config.border.rounding * 2 - padding * 2;
if (height > h)
height = h;
}
if (visibilities.session) {
const h = sessionPanel.y - Config.border.rounding * 2 - padding * 2;
if (height > h)
height = h;
}
return Math.min(((QsWindow.window as QsWindow)?.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 as QsWindow)?.screen.height ?? 0
delegate: NotifWrapper {}
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) as NotifWrapper)?.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) as NotifWrapper)?.nonAnimHeight ?? 0) + Appearance.spacing.smaller;
if (height - Appearance.spacing.smaller >= scrollY)
return count - i - 1;
}
return 0;
}
}
}
}
Behavior on implicitHeight {
Anim {}
}
component NotifWrapper: Item {
id: wrapper
required property NotifData 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
}
}
}
component Anim: NumberAnimation {
duration: Appearance.anim.durations.expressiveDefaultSpatial
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
}
}
================================================
FILE: modules/notifications/Notification.qml
================================================
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
import QtQuick.Shapes
StyledRect {
id: root
required property NotifData modelData
readonly property bool hasImage: modelData.image.length > 0
readonly property bool hasAppIcon: modelData.appIcon.length > 0
readonly property int bodyTextFormat: /[<*_`#\[\]]/.test(modelData.body) ? Text.MarkdownText : Text.PlainText
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
asynchronous: true
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
sourceSize.width: Config.notifs.sizes.image
sourceSize.height: Config.notifs.sizes.image
cache: false
asynchronous: true
}
}
}
Loader {
id: appIcon
asynchronous: true
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
asynchronous: true
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 {
asynchronous: true
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
}
}
}
}
Shape {
id: progressIndicator
anchors.centerIn: appIcon
width: appIcon.implicitWidth + progressShape.strokeWidth * 2
height: appIcon.implicitHeight + progressShape.strokeWidth * 2
preferredRendererType: Shape.CurveRenderer
ShapePath {
id: progressShape
capStyle: ShapePath.RoundCap
fillColor: "transparent"
strokeWidth: 2
strokeColor: Colours.palette.m3primary
PathAngleArc {
id: progressArc
radiusX: progressIndicator.width / 2 - Appearance.padding.small / 2
centerX: progressIndicator.width / 2
radiusY: progressIndicator.height / 2 - Appearance.padding.small / 2
centerY: progressIndicator.height / 2
startAngle: -90
sweepAngle: ((root.modelData.hints.value ?? 0) / 100) * 360
Behavior on sweepAngle {
Anim {
easing.bezierCurve: Appearance.anim.curves.emphasizedDecel
}
}
}
}
}
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 {
function onClicked() {
root.expanded = !root.expanded;
}
radius: Appearance.rounding.full
color: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface
}
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: root.bodyTextFormat
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: root.bodyTextFormat
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 {
function onClicked(): void {
action.modelData.invoke();
}
radius: Appearance.rounding.full
color: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3onSecondary : Colours.palette.m3onSurface
}
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;
}
}
}
}
================================================
FILE: modules/notifications/Wrapper.qml
================================================
import qs.components
import qs.config
import QtQuick
Item {
id: root
required property DrawerVisibilities visibilities
required property Item sidebarPanel
property alias osdPanel: content.osdPanel
property alias sessionPanel: content.sessionPanel
visible: height > 0
implicitWidth: Math.max(sidebarPanel.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
}
}
================================================
FILE: modules/osd/Background.qml
================================================
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 {}
}
}
================================================
FILE: modules/osd/Content.qml
================================================
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 DrawerVisibilities 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 {
function onWheel(event: WheelEvent) {
if (event.angleDelta.y > 0)
Audio.incrementVolume();
else if (event.angleDelta.y < 0)
Audio.decrementVolume();
}
implicitWidth: Config.osd.sizes.sliderWidth
implicitHeight: Config.osd.sizes.sliderHeight
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 {
function onWheel(event: WheelEvent) {
if (event.angleDelta.y > 0)
Audio.incrementSourceVolume();
else if (event.angleDelta.y < 0)
Audio.decrementSourceVolume();
}
implicitWidth: Config.osd.sizes.sliderWidth
implicitHeight: Config.osd.sizes.sliderHeight
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 {
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);
}
implicitWidth: Config.osd.sizes.sliderWidth
implicitHeight: Config.osd.sizes.sliderHeight
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
asynchronous: true
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 {}
}
}
}
================================================
FILE: modules/osd/Wrapper.qml
================================================
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 DrawerVisibilities 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 {
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;
}
target: Audio
}
Connections {
function onBrightnessChanged(): void {
root.show();
root.brightness = root.monitor?.brightness ?? 0;
}
target: root.monitor
}
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
}
}
}
================================================
FILE: modules/session/Background.qml
================================================
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 {}
}
}
================================================
FILE: modules/session/Content.qml
================================================
pragma ComponentBehavior: Bound
import qs.components
import qs.services
import qs.config
import qs.utils
import Quickshell
import QtQuick
Column {
id: root
required property DrawerVisibilities 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 {
function onLauncherChanged(): void {
if (!root.visibilities.launcher)
logout.forceActiveFocus();
}
target: root.visibilities
}
}
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 {
function onClicked(): void {
Quickshell.execDetached(button.command);
}
radius: parent.radius
color: button.activeFocus ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface
}
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
}
}
}
================================================
FILE: modules/session/Wrapper.qml
================================================
pragma ComponentBehavior: Bound
import qs.components
import qs.config
import QtQuick
Item {
id: root
required property DrawerVisibilities 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
}
}
}
================================================
FILE: modules/sidebar/Background.qml
================================================
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 {}
}
}
================================================
FILE: modules/sidebar/Content.qml
================================================
import qs.components
import qs.services
import qs.config
import QtQuick
import QtQuick.Layouts
Item {
id: root
required property Props props
required property DrawerVisibilities 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
}
}
}
================================================
FILE: modules/sidebar/Notif.qml
================================================
pragma ComponentBehavior: Bound
import qs.components
import qs.services
import qs.config
import Quickshell
import QtQuick
import QtQuick.Layouts
StyledRect {
id: root
required property NotifData modelData
required property Props props
required property bool expanded
required property DrawerVisibilities visibilities
readonly property StyledText body: (expandedContent.item as ExpandedBody)?.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: ExpandedBody {}
}
Behavior on implicitHeight {
Anim {
duration: Appearance.anim.durations.expressiveDefaultSpatial
easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
}
}
component ExpandedBody: ColumnLayout {
readonly property alias body: bodyText
spacing: Appearance.spacing.smaller
StyledText {
id: bodyText
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
}
}
component WrappedLoader: Loader {
required property bool shouldBeActive
asynchronous: true
opacity: shouldBeActive ? 1 : 0
active: opacity > 0
Behavior on opacity {
Anim {}
}
}
}
================================================
FILE: modules/sidebar/NotifActionList.qml
================================================
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 NotifData 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 {
asynchronous: true
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
}
}
}
}
}
}
}
================================================
FILE: modules/sidebar/NotifDock.qml
================================================
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 DrawerVisibilities 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 {
asynchronous: true
anchors.centerIn: parent
active: opacity > 0
opacity: root.notifCount > 0 ? 0 : 1
sourceComponent: ColumnLayout {
spacing: Appearance.spacing.large
Image {
asynchronous: true
source: Quickshell.shellPath("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) // qmllint disable missing-property
break;
}
if (next) {
next.closeAll(); // qmllint disable missing-property
} else {
stop();
}
}
}
Loader {
asynchronous: true
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
}
}
}
}
================================================
FILE: modules/sidebar/NotifDockList.qml
================================================
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 DrawerVisibilities 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()
}
delegate: NotifGroupDelegate {}
}
component NotifGroupDelegate: 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) as NotifGroupDelegate;
if (item && !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
}
}
}
}
================================================
FILE: modules/sidebar/NotifGroup.qml
================================================
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 DrawerVisibilities visibilities
readonly property list notifs: Notifs.list.filter(n => n.appName === modelData)
readonly property var groupProps: {
let count = 0;
let img = "";
let icon = "";
let hasCritical = false;
let hasNormal = false;
for (const n of notifs) {
if (!n.closed) {
count++;
if (!img && n.image.length > 0)
img = n.image;
if (!icon && n.appIcon.length > 0)
icon = n.appIcon;
if (n.urgency === NotificationUrgency.Critical)
hasCritical = true;
else if (n.urgency === NotificationUrgency.Normal)
hasNormal = true;
}
}
return {
count,
img,
icon,
urgency: hasCritical ? NotificationUrgency.Critical : hasNormal ? NotificationUrgency.Normal : NotificationUrgency.Low
};
}
readonly property int notifCount: groupProps.count
readonly property string image: groupProps.img
readonly property string appIcon: groupProps.icon
readonly property int urgency: groupProps.urgency
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
sourceSize.width: Config.notifs.sizes.image
sourceSize.height: Config.notifs.sizes.image
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 {
asynchronous: true
anchors.centerIn: parent
sourceComponent: root.image ? imageComp : root.appIcon ? appIconComp : materialIconComp
}
}
Loader {
asynchronous: true
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 {
function onClicked(): void {
root.toggleExpand(!root.expanded);
}
color: root.urgency === NotificationUrgency.Critical ? Colours.palette.m3onError : Colours.palette.m3onSurface
}
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)
}
}
}
}
================================================
FILE: modules/sidebar/NotifGroupList.qml
================================================
pragma ComponentBehavior: Bound
import qs.components
import qs.config
import qs.services
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 DrawerVisibilities visibilities
readonly property real nonAnimHeight: {
let h = -root.spacing;
for (let i = 0; i < repeater.count; i++) {
const item = repeater.itemAt(i) as NotifDelegate;
if (item && !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()
}
delegate: NotifDelegate {}
}
Behavior on implicitHeight {
Anim {
duration: Appearance.anim.durations.expressiveDefaultSpatial
easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
}
}
component NotifDelegate: MouseArea {
id: notif
required property int index
required property NotifData 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) as NotifDelegate;
if (item && !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
}
}
}
}
================================================
FILE: modules/sidebar/Props.qml
================================================
import Quickshell
PersistentProperties {
property list expandedNotifs: []
reloadableId: "sidebar"
}
================================================
FILE: modules/sidebar/Wrapper.qml
================================================
pragma ComponentBehavior: Bound
import qs.components
import qs.config
import QtQuick
Item {
id: root
required property DrawerVisibilities 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
}
}
}
================================================
FILE: modules/utilities/Background.qml
================================================
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 {}
}
}
================================================
FILE: modules/utilities/Content.qml
================================================
import "cards"
import qs.components
import qs.config
import qs.modules.bar.popouts as BarPopouts
import QtQuick
import QtQuick.Layouts
Item {
id: root
required property var props
required property DrawerVisibilities visibilities
required property BarPopouts.Wrapper 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
}
}
================================================
FILE: modules/utilities/RecordingDeleteModal.qml
================================================
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
asynchronous: true
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 {}
}
}
================================================
FILE: modules/utilities/Wrapper.qml
================================================
pragma ComponentBehavior: Bound
import qs.components
import qs.config
import Quickshell
import QtQuick
Item {
id: root
required property DrawerVisibilities 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
asynchronous: true
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
}
}
}
================================================
FILE: modules/utilities/cards/IdleInhibit.qml
================================================
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
asynchronous: true
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
}
}
}
================================================
FILE: modules/utilities/cards/Record.qml
================================================
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 DrawerVisibilities 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
asynchronous: true
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()
}
}
}
}
================================================
FILE: modules/utilities/cards/RecordingList.qml
================================================
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.Models
import Quickshell
import Quickshell.Widgets
import QtQuick
import QtQuick.Layouts
ColumnLayout {
id: root
required property var props
required property DrawerVisibilities 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 {
asynchronous: true
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
}
}
}
}
================================================
FILE: modules/utilities/cards/Toggles.qml
================================================
pragma ComponentBehavior: Bound
import qs.components
import qs.components.controls
import qs.services
import qs.config
import qs.modules.bar.popouts as BarPopouts
import Quickshell.Bluetooth
import QtQuick
import QtQuick.Layouts
StyledRect {
id: root
required property DrawerVisibilities visibilities
required property BarPopouts.Wrapper popouts
readonly property var quickToggles: {
const seenIds = new Set();
return Config.utilities.quickToggles.filter(item => {
if (!item.enabled)
return false;
if (seenIds.has(item.id)) {
return false;
}
if (item.id === "vpn") {
return Config.utilities.vpn.provider.some(p => typeof p === "object" ? (p.enabled === true) : false);
}
seenIds.add(item.id);
return true;
});
}
readonly property int splitIndex: Math.ceil(quickToggles.length / 2)
readonly property bool needExtraRow: quickToggles.length > 6
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
}
QuickToggleRow {
rowModel: root.needExtraRow ? root.quickToggles.slice(0, root.splitIndex) : root.quickToggles
}
QuickToggleRow {
visible: root.needExtraRow
rowModel: root.needExtraRow ? root.quickToggles.slice(root.splitIndex) : []
}
}
component QuickToggleRow: RowLayout {
property var rowModel: []
Layout.fillWidth: true
spacing: Appearance.spacing.small
Repeater {
model: parent.rowModel
delegate: DelegateChooser {
role: "id"
DelegateChoice {
roleValue: "wifi"
delegate: Toggle {
icon: "wifi"
checked: Nmcli.wifiEnabled
onClicked: Nmcli.toggleWifi()
}
}
DelegateChoice {
roleValue: "bluetooth"
delegate: Toggle {
icon: "bluetooth"
checked: Bluetooth.defaultAdapter?.enabled ?? false
onClicked: {
const adapter = Bluetooth.defaultAdapter;
if (adapter)
adapter.enabled = !adapter.enabled;
}
}
}
DelegateChoice {
roleValue: "mic"
delegate: Toggle {
icon: "mic"
checked: !Audio.sourceMuted
onClicked: {
const audio = Audio.source?.audio;
if (audio)
audio.muted = !audio.muted;
}
}
}
DelegateChoice {
roleValue: "settings"
delegate: Toggle {
icon: "settings"
inactiveOnColour: Colours.palette.m3onSurfaceVariant
toggle: false
onClicked: {
root.visibilities.utilities = false;
root.popouts.detach("network");
}
}
}
DelegateChoice {
roleValue: "gameMode"
delegate: Toggle {
icon: "gamepad"
checked: GameMode.enabled
onClicked: GameMode.enabled = !GameMode.enabled
}
}
DelegateChoice {
roleValue: "dnd"
delegate: Toggle {
icon: "notifications_off"
checked: Notifs.dnd
onClicked: Notifs.dnd = !Notifs.dnd
}
}
DelegateChoice {
roleValue: "vpn"
delegate: Toggle {
icon: "vpn_key"
checked: VPN.connected
enabled: !VPN.connecting
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
}
}
}
}
================================================
FILE: modules/utilities/toasts/ToastItem.qml
================================================
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 {}
}
}
================================================
FILE: modules/utilities/toasts/Toasts.qml
================================================
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
}
}
}
}
================================================
FILE: modules/windowinfo/Buttons.qml
================================================
pragma ComponentBehavior: Bound
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 {
function onClicked(): void {
root.moveToWsExpanded = !root.moveToWsExpanded;
}
color: Colours.palette.m3onPrimary
}
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
function onClicked(): void {
Hypr.dispatch(`movetoworkspace ${wsId},address:0x${root.client?.address}`);
}
color: isCurrent ? Colours.tPalette.m3surfaceContainerHighest : Colours.palette.m3tertiaryContainer
onColor: isCurrent ? Colours.palette.m3onSurface : Colours.palette.m3onTertiaryContainer
text: wsId
disabled: isCurrent
}
}
}
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 {
function onClicked(): void {
Hypr.dispatch(`togglefloating address:0x${root.client?.address}`);
}
color: Colours.palette.m3secondaryContainer
onColor: Colours.palette.m3onSecondaryContainer
text: root.client?.lastIpcObject.floating ? qsTr("Tile") : qsTr("Float")
}
Loader {
asynchronous: true
active: root.client?.lastIpcObject.floating
Layout.fillWidth: active
Layout.leftMargin: active ? 0 : -parent.spacing
Layout.rightMargin: active ? 0 : -parent.spacing
sourceComponent: Button {
function onClicked(): void {
Hypr.dispatch(`pin address:0x${root.client?.address}`);
}
color: Colours.palette.m3secondaryContainer
onColor: Colours.palette.m3onSecondaryContainer
text: root.client?.lastIpcObject.pinned ? qsTr("Unpin") : qsTr("Pin")
}
}
Button {
function onClicked(): void {
Hypr.dispatch(`killwindow address:0x${root.client?.address}`);
}
color: Colours.palette.m3errorContainer
onColor: Colours.palette.m3onErrorContainer
text: qsTr("Kill")
}
}
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
function onClicked(): void {
parent.onClicked();
}
color: parent.onColor
}
StyledText {
id: label
anchors.centerIn: parent
animate: true
color: parent.onColor
font.pointSize: Appearance.font.size.normal
}
}
}
================================================
FILE: modules/windowinfo/Details.qml
================================================
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
}
}
================================================
FILE: modules/windowinfo/Preview.qml
================================================
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 {
asynchronous: true
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]);
}
}
}
================================================
FILE: modules/windowinfo/WindowInfo.qml
================================================
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
}
}
}
}
}
================================================
FILE: nix/default.nix
================================================
{
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";
};
}
================================================
FILE: nix/hm-module.nix
================================================
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;
};
}
================================================
FILE: plugin/CMakeLists.txt
================================================
add_subdirectory(src/Caelestia)
================================================
FILE: plugin/src/Caelestia/CMakeLists.txt
================================================
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)
================================================
FILE: plugin/src/Caelestia/Internal/CMakeLists.txt
================================================
qml_module(caelestia-internal
URI Caelestia.Internal
SOURCES
arcgauge.hpp arcgauge.cpp
cachingimagemanager.hpp cachingimagemanager.cpp
circularbuffer.hpp circularbuffer.cpp
circularindicatormanager.hpp circularindicatormanager.cpp
hyprdevices.hpp hyprdevices.cpp
hyprextras.hpp hyprextras.cpp
logindmanager.hpp logindmanager.cpp
sparklineitem.hpp sparklineitem.cpp
LIBRARIES
Qt::Gui
Qt::Quick
Qt::Concurrent
Qt::Network
Qt::DBus
)
================================================
FILE: plugin/src/Caelestia/Internal/arcgauge.cpp
================================================
#include "arcgauge.hpp"
#include
#include
#include
namespace caelestia::internal {
ArcGauge::ArcGauge(QQuickItem* parent)
: QQuickPaintedItem(parent) {
setAntialiasing(true);
}
void ArcGauge::paint(QPainter* painter) {
const qreal w = width();
const qreal h = height();
const qreal side = qMin(w, h);
const qreal radius = (side - m_lineWidth - 2.0) / 2.0;
const qreal cx = w / 2.0;
const qreal cy = h / 2.0;
const QRectF arcRect(cx - radius, cy - radius, radius * 2.0, radius * 2.0);
// Convert from Canvas convention (CW radians from 3 o'clock) to QPainter (CCW 1/16th degrees)
const int startAngle16 = qRound(-(m_startAngle * 180.0 / M_PI) * 16.0);
const int sweepAngle16 = qRound(-(m_sweepAngle * 180.0 / M_PI) * 16.0);
painter->setRenderHint(QPainter::Antialiasing, true);
// Draw track arc
QPen trackPen(m_trackColor, m_lineWidth);
trackPen.setCapStyle(Qt::RoundCap);
painter->setPen(trackPen);
painter->setBrush(Qt::NoBrush);
painter->drawArc(arcRect, startAngle16, sweepAngle16);
// Draw value arc
if (m_percentage > 0.0) {
const int valueSweep16 = qRound(static_cast(sweepAngle16) * m_percentage);
QPen valuePen(m_accentColor, m_lineWidth);
valuePen.setCapStyle(Qt::RoundCap);
painter->setPen(valuePen);
painter->drawArc(arcRect, startAngle16, valueSweep16);
}
}
qreal ArcGauge::percentage() const {
return m_percentage;
}
void ArcGauge::setPercentage(qreal percentage) {
if (qFuzzyCompare(m_percentage, percentage))
return;
m_percentage = percentage;
emit percentageChanged();
update();
}
QColor ArcGauge::accentColor() const {
return m_accentColor;
}
void ArcGauge::setAccentColor(const QColor& color) {
if (m_accentColor == color)
return;
m_accentColor = color;
emit accentColorChanged();
update();
}
QColor ArcGauge::trackColor() const {
return m_trackColor;
}
void ArcGauge::setTrackColor(const QColor& color) {
if (m_trackColor == color)
return;
m_trackColor = color;
emit trackColorChanged();
update();
}
qreal ArcGauge::startAngle() const {
return m_startAngle;
}
void ArcGauge::setStartAngle(qreal angle) {
if (qFuzzyCompare(m_startAngle, angle))
return;
m_startAngle = angle;
emit startAngleChanged();
update();
}
qreal ArcGauge::sweepAngle() const {
return m_sweepAngle;
}
void ArcGauge::setSweepAngle(qreal angle) {
if (qFuzzyCompare(m_sweepAngle, angle))
return;
m_sweepAngle = angle;
emit sweepAngleChanged();
update();
}
qreal ArcGauge::lineWidth() const {
return m_lineWidth;
}
void ArcGauge::setLineWidth(qreal width) {
if (qFuzzyCompare(m_lineWidth, width))
return;
m_lineWidth = width;
emit lineWidthChanged();
update();
}
} // namespace caelestia::internal
================================================
FILE: plugin/src/Caelestia/Internal/arcgauge.hpp
================================================
#pragma once
#include
#include
#include
#include
namespace caelestia::internal {
class ArcGauge : public QQuickPaintedItem {
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(qreal percentage READ percentage WRITE setPercentage NOTIFY percentageChanged)
Q_PROPERTY(QColor accentColor READ accentColor WRITE setAccentColor NOTIFY accentColorChanged)
Q_PROPERTY(QColor trackColor READ trackColor WRITE setTrackColor NOTIFY trackColorChanged)
Q_PROPERTY(qreal startAngle READ startAngle WRITE setStartAngle NOTIFY startAngleChanged)
Q_PROPERTY(qreal sweepAngle READ sweepAngle WRITE setSweepAngle NOTIFY sweepAngleChanged)
Q_PROPERTY(qreal lineWidth READ lineWidth WRITE setLineWidth NOTIFY lineWidthChanged)
public:
explicit ArcGauge(QQuickItem* parent = nullptr);
void paint(QPainter* painter) override;
[[nodiscard]] qreal percentage() const;
void setPercentage(qreal percentage);
[[nodiscard]] QColor accentColor() const;
void setAccentColor(const QColor& color);
[[nodiscard]] QColor trackColor() const;
void setTrackColor(const QColor& color);
[[nodiscard]] qreal startAngle() const;
void setStartAngle(qreal angle);
[[nodiscard]] qreal sweepAngle() const;
void setSweepAngle(qreal angle);
[[nodiscard]] qreal lineWidth() const;
void setLineWidth(qreal width);
signals:
void percentageChanged();
void accentColorChanged();
void trackColorChanged();
void startAngleChanged();
void sweepAngleChanged();
void lineWidthChanged();
private:
qreal m_percentage = 0.0;
QColor m_accentColor;
QColor m_trackColor;
qreal m_startAngle = 0.75 * M_PI;
qreal m_sweepAngle = 1.5 * M_PI;
qreal m_lineWidth = 10.0;
};
} // namespace caelestia::internal
================================================
FILE: plugin/src/Caelestia/Internal/cachingimagemanager.cpp
================================================
#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
================================================
FILE: plugin/src/Caelestia/Internal/cachingimagemanager.hpp
================================================
#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
================================================
FILE: plugin/src/Caelestia/Internal/circularbuffer.cpp
================================================
#include "circularbuffer.hpp"
#include
namespace caelestia::internal {
CircularBuffer::CircularBuffer(QObject* parent)
: QObject(parent) {}
int CircularBuffer::capacity() const {
return m_capacity;
}
void CircularBuffer::setCapacity(int capacity) {
if (capacity < 0)
capacity = 0;
if (m_capacity == capacity)
return;
const auto old = values();
m_capacity = capacity;
m_data.resize(capacity);
m_data.fill(0.0);
m_head = 0;
m_count = 0;
// Re-push old values, keeping the most recent ones
const auto start = old.size() > capacity ? old.size() - capacity : 0;
for (auto i = start; i < old.size(); ++i) {
m_data[m_head] = old[i];
m_head = (m_head + 1) % m_capacity;
m_count++;
}
emit capacityChanged();
emit countChanged();
emit valuesChanged();
}
int CircularBuffer::count() const {
return m_count;
}
QList CircularBuffer::values() const {
QList result;
result.reserve(m_count);
for (int i = 0; i < m_count; ++i)
result.append(at(i));
return result;
}
qreal CircularBuffer::maximum() const {
if (m_count == 0)
return 0.0;
qreal maxVal = at(0);
for (int i = 1; i < m_count; ++i)
maxVal = std::max(maxVal, at(i));
return maxVal;
}
void CircularBuffer::push(qreal value) {
if (m_capacity <= 0)
return;
m_data[m_head] = value;
m_head = (m_head + 1) % m_capacity;
if (m_count < m_capacity) {
m_count++;
emit countChanged();
}
emit valuesChanged();
}
void CircularBuffer::clear() {
if (m_count == 0)
return;
m_head = 0;
m_count = 0;
emit countChanged();
emit valuesChanged();
}
qreal CircularBuffer::at(int index) const {
if (index < 0 || index >= m_count)
return 0.0;
const int actualIndex = (m_head - m_count + index + m_capacity) % m_capacity;
return m_data[actualIndex];
}
} // namespace caelestia::internal
================================================
FILE: plugin/src/Caelestia/Internal/circularbuffer.hpp
================================================
#pragma once
#include
#include
#include
namespace caelestia::internal {
class CircularBuffer : public QObject {
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(int capacity READ capacity WRITE setCapacity NOTIFY capacityChanged)
Q_PROPERTY(int count READ count NOTIFY countChanged)
Q_PROPERTY(QList values READ values NOTIFY valuesChanged)
Q_PROPERTY(qreal maximum READ maximum NOTIFY valuesChanged)
public:
explicit CircularBuffer(QObject* parent = nullptr);
[[nodiscard]] int capacity() const;
void setCapacity(int capacity);
[[nodiscard]] int count() const;
[[nodiscard]] QList values() const;
[[nodiscard]] qreal maximum() const;
Q_INVOKABLE void push(qreal value);
Q_INVOKABLE void clear();
Q_INVOKABLE [[nodiscard]] qreal at(int index) const;
signals:
void capacityChanged();
void countChanged();
void valuesChanged();
private:
QVector m_data;
int m_head = 0;
int m_count = 0;
int m_capacity = 0;
};
} // namespace caelestia::internal
================================================
FILE: plugin/src/Caelestia/Internal/circularindicatormanager.cpp
================================================
#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;
}
const auto oldRotation = m_rotation;
m_rotation = constantRotation + spinRotation;
if (!qFuzzyCompare(m_rotation + 1.0, oldRotation + 1.0))
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;
const auto oldStart = m_startFraction;
const auto oldEnd = m_endFraction;
// 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;
if (!qFuzzyCompare(m_startFraction + 1.0, oldStart + 1.0))
emit startFractionChanged();
if (!qFuzzyCompare(m_endFraction + 1.0, oldEnd + 1.0))
emit endFractionChanged();
}
} // namespace caelestia::internal
================================================
FILE: plugin/src/Caelestia/Internal/circularindicatormanager.hpp
================================================
#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
================================================
FILE: plugin/src/Caelestia/Internal/hyprdevices.cpp
================================================
#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
================================================
FILE: plugin/src/Caelestia/Internal/hyprdevices.hpp
================================================
#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
================================================
FILE: plugin/src/Caelestia/Internal/hyprextras.cpp
================================================
#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;
request.reserve(12 + options.size() * 40);
request += QLatin1String("[[BATCH]]");
for (auto it = options.constBegin(); it != options.constEnd(); ++it) {
request += QLatin1String("keyword ") + it.key() + QLatin1Char(' ') + it.value().toString() + QLatin1Char(';');
}
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
================================================
FILE: plugin/src/Caelestia/Internal/hyprextras.hpp
================================================
#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
================================================
FILE: plugin/src/Caelestia/Internal/logindmanager.cpp
================================================
#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
================================================
FILE: plugin/src/Caelestia/Internal/logindmanager.hpp
================================================
#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
================================================
FILE: plugin/src/Caelestia/Internal/sparklineitem.cpp
================================================
#include "sparklineitem.hpp"
#include
#include
#include
namespace caelestia::internal {
SparklineItem::SparklineItem(QQuickItem* parent)
: QQuickPaintedItem(parent) {
setAntialiasing(true);
}
void SparklineItem::paint(QPainter* painter) {
const bool has1 = m_line1 && m_line1->count() >= 2;
const bool has2 = m_line2 && m_line2->count() >= 2;
if (!has1 && !has2)
return;
painter->setRenderHint(QPainter::Antialiasing, true);
// Draw line1 first (behind), then line2 (in front)
if (has1)
drawLine(painter, m_line1, m_line1Color, m_line1FillAlpha);
if (has2)
drawLine(painter, m_line2, m_line2Color, m_line2FillAlpha);
}
void SparklineItem::drawLine(QPainter* painter, CircularBuffer* buffer, const QColor& color, qreal fillAlpha) {
if (m_historyLength < 2)
return;
const qreal w = width();
const qreal h = height();
const int len = buffer->count();
const qreal stepX = w / static_cast(m_historyLength - 1);
const qreal startX = w - (len - 1) * stepX - stepX * m_slideProgress + stepX;
// Build line path
QPainterPath linePath;
linePath.moveTo(startX, h - (buffer->at(0) / m_maxValue) * h);
for (int i = 1; i < len; ++i) {
const qreal x = startX + i * stepX;
const qreal y = h - (buffer->at(i) / m_maxValue) * h;
linePath.lineTo(x, y);
}
// Stroke the line
QPen pen(color, m_lineWidth);
pen.setCapStyle(Qt::RoundCap);
pen.setJoinStyle(Qt::RoundJoin);
painter->setPen(pen);
painter->setBrush(Qt::NoBrush);
painter->drawPath(linePath);
// Fill under the line
QPainterPath fillPath = linePath;
fillPath.lineTo(startX + (len - 1) * stepX, h);
fillPath.lineTo(startX, h);
fillPath.closeSubpath();
QColor fillColor = color;
fillColor.setAlphaF(static_cast(fillAlpha));
painter->setPen(Qt::NoPen);
painter->setBrush(fillColor);
painter->drawPath(fillPath);
}
void SparklineItem::connectBuffer(CircularBuffer* buffer) {
if (!buffer)
return;
connect(buffer, &CircularBuffer::valuesChanged, this, [this]() {
update();
});
connect(buffer, &QObject::destroyed, this, [this, buffer]() {
if (m_line1 == buffer) {
m_line1 = nullptr;
emit line1Changed();
}
if (m_line2 == buffer) {
m_line2 = nullptr;
emit line2Changed();
}
update();
});
}
CircularBuffer* SparklineItem::line1() const {
return m_line1;
}
void SparklineItem::setLine1(CircularBuffer* buffer) {
if (m_line1 == buffer)
return;
if (m_line1)
disconnect(m_line1, nullptr, this, nullptr);
m_line1 = buffer;
connectBuffer(buffer);
emit line1Changed();
update();
}
CircularBuffer* SparklineItem::line2() const {
return m_line2;
}
void SparklineItem::setLine2(CircularBuffer* buffer) {
if (m_line2 == buffer)
return;
if (m_line2)
disconnect(m_line2, nullptr, this, nullptr);
m_line2 = buffer;
connectBuffer(buffer);
emit line2Changed();
update();
}
QColor SparklineItem::line1Color() const {
return m_line1Color;
}
void SparklineItem::setLine1Color(const QColor& color) {
if (m_line1Color == color)
return;
m_line1Color = color;
emit line1ColorChanged();
update();
}
QColor SparklineItem::line2Color() const {
return m_line2Color;
}
void SparklineItem::setLine2Color(const QColor& color) {
if (m_line2Color == color)
return;
m_line2Color = color;
emit line2ColorChanged();
update();
}
qreal SparklineItem::line1FillAlpha() const {
return m_line1FillAlpha;
}
void SparklineItem::setLine1FillAlpha(qreal alpha) {
if (qFuzzyCompare(m_line1FillAlpha, alpha))
return;
m_line1FillAlpha = alpha;
emit line1FillAlphaChanged();
update();
}
qreal SparklineItem::line2FillAlpha() const {
return m_line2FillAlpha;
}
void SparklineItem::setLine2FillAlpha(qreal alpha) {
if (qFuzzyCompare(m_line2FillAlpha, alpha))
return;
m_line2FillAlpha = alpha;
emit line2FillAlphaChanged();
update();
}
qreal SparklineItem::maxValue() const {
return m_maxValue;
}
void SparklineItem::setMaxValue(qreal value) {
if (qFuzzyCompare(m_maxValue, value))
return;
m_maxValue = value;
emit maxValueChanged();
update();
}
qreal SparklineItem::slideProgress() const {
return m_slideProgress;
}
void SparklineItem::setSlideProgress(qreal progress) {
if (qFuzzyCompare(m_slideProgress, progress))
return;
m_slideProgress = progress;
emit slideProgressChanged();
update();
}
int SparklineItem::historyLength() const {
return m_historyLength;
}
void SparklineItem::setHistoryLength(int length) {
if (m_historyLength == length)
return;
m_historyLength = length;
emit historyLengthChanged();
update();
}
qreal SparklineItem::lineWidth() const {
return m_lineWidth;
}
void SparklineItem::setLineWidth(qreal width) {
if (qFuzzyCompare(m_lineWidth, width))
return;
m_lineWidth = width;
emit lineWidthChanged();
update();
}
} // namespace caelestia::internal
================================================
FILE: plugin/src/Caelestia/Internal/sparklineitem.hpp
================================================
#pragma once
#include
#include
#include
#include
#include "circularbuffer.hpp"
namespace caelestia::internal {
class SparklineItem : public QQuickPaintedItem {
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(CircularBuffer* line1 READ line1 WRITE setLine1 NOTIFY line1Changed)
Q_PROPERTY(CircularBuffer* line2 READ line2 WRITE setLine2 NOTIFY line2Changed)
Q_PROPERTY(QColor line1Color READ line1Color WRITE setLine1Color NOTIFY line1ColorChanged)
Q_PROPERTY(QColor line2Color READ line2Color WRITE setLine2Color NOTIFY line2ColorChanged)
Q_PROPERTY(qreal line1FillAlpha READ line1FillAlpha WRITE setLine1FillAlpha NOTIFY line1FillAlphaChanged)
Q_PROPERTY(qreal line2FillAlpha READ line2FillAlpha WRITE setLine2FillAlpha NOTIFY line2FillAlphaChanged)
Q_PROPERTY(qreal maxValue READ maxValue WRITE setMaxValue NOTIFY maxValueChanged)
Q_PROPERTY(qreal slideProgress READ slideProgress WRITE setSlideProgress NOTIFY slideProgressChanged)
Q_PROPERTY(int historyLength READ historyLength WRITE setHistoryLength NOTIFY historyLengthChanged)
Q_PROPERTY(qreal lineWidth READ lineWidth WRITE setLineWidth NOTIFY lineWidthChanged)
public:
explicit SparklineItem(QQuickItem* parent = nullptr);
void paint(QPainter* painter) override;
[[nodiscard]] CircularBuffer* line1() const;
void setLine1(CircularBuffer* buffer);
[[nodiscard]] CircularBuffer* line2() const;
void setLine2(CircularBuffer* buffer);
[[nodiscard]] QColor line1Color() const;
void setLine1Color(const QColor& color);
[[nodiscard]] QColor line2Color() const;
void setLine2Color(const QColor& color);
[[nodiscard]] qreal line1FillAlpha() const;
void setLine1FillAlpha(qreal alpha);
[[nodiscard]] qreal line2FillAlpha() const;
void setLine2FillAlpha(qreal alpha);
[[nodiscard]] qreal maxValue() const;
void setMaxValue(qreal value);
[[nodiscard]] qreal slideProgress() const;
void setSlideProgress(qreal progress);
[[nodiscard]] int historyLength() const;
void setHistoryLength(int length);
[[nodiscard]] qreal lineWidth() const;
void setLineWidth(qreal width);
signals:
void line1Changed();
void line2Changed();
void line1ColorChanged();
void line2ColorChanged();
void line1FillAlphaChanged();
void line2FillAlphaChanged();
void maxValueChanged();
void slideProgressChanged();
void historyLengthChanged();
void lineWidthChanged();
private:
void drawLine(QPainter* painter, CircularBuffer* buffer, const QColor& color, qreal fillAlpha);
void connectBuffer(CircularBuffer* buffer);
CircularBuffer* m_line1 = nullptr;
CircularBuffer* m_line2 = nullptr;
QColor m_line1Color;
QColor m_line2Color;
qreal m_line1FillAlpha = 0.15;
qreal m_line2FillAlpha = 0.2;
qreal m_maxValue = 1024.0;
qreal m_slideProgress = 0.0;
int m_historyLength = 30;
qreal m_lineWidth = 2.0;
};
} // namespace caelestia::internal
================================================
FILE: plugin/src/Caelestia/Models/CMakeLists.txt
================================================
qml_module(caelestia-models
URI Caelestia.Models
SOURCES
filesystemmodel.hpp filesystemmodel.cpp
LIBRARIES
Qt::Gui
Qt::Concurrent
)
================================================
FILE: plugin/src/Caelestia/Models/filesystemmodel.cpp
================================================
#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) {
static const QMimeDatabase s_db;
m_mimeType = s_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
================================================
FILE: plugin/src/Caelestia/Models/filesystemmodel.hpp
================================================
#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
================================================
FILE: plugin/src/Caelestia/Services/CMakeLists.txt
================================================
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
)
================================================
FILE: plugin/src/Caelestia/Services/audiocollector.cpp
================================================
#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
================================================
FILE: plugin/src/Caelestia/Services/audiocollector.hpp
================================================
#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
================================================
FILE: plugin/src/Caelestia/Services/audioprovider.cpp
================================================
#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
================================================
FILE: plugin/src/Caelestia/Services/audioprovider.hpp
================================================
#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 = nullptr;
};
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
================================================
FILE: plugin/src/Caelestia/Services/beattracker.cpp
================================================
#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);
}
if (m_out) {
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
================================================
FILE: plugin/src/Caelestia/Services/beattracker.hpp
================================================
#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
================================================
FILE: plugin/src/Caelestia/Services/cavaprovider.cpp
================================================
#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
================================================
FILE: plugin/src/Caelestia/Services/cavaprovider.hpp
================================================
#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
================================================
FILE: plugin/src/Caelestia/Services/service.cpp
================================================
#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
================================================
FILE: plugin/src/Caelestia/Services/service.hpp
================================================
#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
================================================
FILE: plugin/src/Caelestia/Services/serviceref.cpp
================================================
#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
================================================
FILE: plugin/src/Caelestia/Services/serviceref.hpp
================================================
#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
================================================
FILE: plugin/src/Caelestia/appdb.cpp
================================================
#include "appdb.hpp"
#include
#include