diff --git a/.envrc b/.envrc index c90b500c9..5a259560a 100644 --- a/.envrc +++ b/.envrc @@ -3,10 +3,11 @@ if has nix; then fi shopt -s globstar -watch_file assets/cpp/**/*.cpp -watch_file assets/cpp/**/*.hpp watch_file plugin/**/*.cpp watch_file plugin/**/*.hpp +watch_file plugin/**/*.qml +watch_file plugin/**/*.vert +watch_file plugin/**/*.frag watch_file **/CMakeLists.txt cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo -DCMAKE_CXX_COMPILER=clazy -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -DDISTRIBUTOR=direnv diff --git a/.github/ISSUE_TEMPLATE/crash.yml b/.github/ISSUE_TEMPLATE/crash.yml new file mode 100644 index 000000000..e37198c91 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/crash.yml @@ -0,0 +1,57 @@ +name: Crash report +description: Report a crash +labels: ["bug", "crash"] +type: "Bug" +title: "[CRASH] " +body: + - type: textarea + attributes: + label: What caused the crash + description: | + Any information likely to help debug the crash. What were you doing when the crash occurred, + what changes did you make, can you get it to happen again? + + - type: upload + attributes: + label: Report file + description: Attach `report.txt` here. + validations: + required: true + + - type: upload + attributes: + label: Log file + description: | + Attach `log.qslog.log` here. If it is too big to upload, compress it. + + You can preview the log if you'd like using `qs log -r '*=true'`. + validations: + required: true + + - type: textarea + attributes: + label: "Version info" + description: Run `caelestia -v` and paste the result below. + value: | +
Version info + + ``` + + ``` + +
+ validations: + required: true + + - type: checkboxes + attributes: + label: Reminder + options: + - label: I've successfully updated to the latest versions following the [updating guide](https://github.com/caelestia-dots/caelestia?tab=readme-ov-file#updating). + required: false + - label: I've successfully updated the system packages to the latest. + required: false + - label: I agree that it's usually impossible for others to help me without my logs. + required: true + - label: I've ticked the checkboxes without reading their contents + required: false diff --git a/.github/workflows/check-format.yml b/.github/workflows/check-format.yml new file mode 100644 index 000000000..ea5fe9ef0 --- /dev/null +++ b/.github/workflows/check-format.yml @@ -0,0 +1,38 @@ +name: Check formatting + +on: + push: + branches: + - main + pull_request: + +jobs: + check-qml: + runs-on: ubuntu-latest + container: + image: ghcr.io/${{ github.repository_owner }}/shell-arch-env:latest + + steps: + - uses: actions/checkout@v6 + + - name: Check QML format + shell: fish {0} + run: | + for file in (string match -v 'build/*' **.qml) + /usr/lib/qt6/bin/qmlformat $file | diff -u $file - || exit 1 + end + python3 scripts/qml-lint-conventions.py + + check-cpp: + runs-on: ubuntu-latest + container: + image: ghcr.io/${{ github.repository_owner }}/shell-arch-env:latest + + steps: + - uses: actions/checkout@v6 + + - name: Check C++ format + shell: fish {0} + run: | + find plugin extras -name '*.cpp' -o -name '*.hpp' \ + | xargs clang-format --dry-run --Werror diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 000000000..7962963ca --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,43 @@ +name: Lint code + +on: + push: + branches: + - main + pull_request: + +jobs: + lint: + runs-on: ubuntu-latest + + container: + image: ghcr.io/${{ github.repository_owner }}/shell-arch-env:latest + + steps: + - uses: actions/checkout@v6 + + - name: Build + run: | + # Set version and git rev for CI build so we don't call git + cmake -B build -G Ninja -DCMAKE_CXX_COMPILER=clazy -DCMAKE_CXX_FLAGS=-Werror -DVERSION= -DGIT_REVISION= + cmake --build build + + - name: Lint QML + shell: fish {0} + run: | + # Generate tooling + touch .qmlls.ini + QT_QPA_PLATFORM=offscreen QML2_IMPORT_PATH="$PWD/build/qml:$QML2_IMPORT_PATH" timeout 2 qs -p . + + # Construct linter args + set -l build_dir (grep -oP "(?<=buildDir=\")(.*)(?=\")" .qmlls.ini) + set -l import_paths (grep -oP "(?<=importPaths=\")(.*)(?=\")" .qmlls.ini | string split :) + set -l args -I $build_dir + for path in $import_paths + set -a args -I $path + end + set -l qml_files (string match -vr '(build|modules/controlcenter)/.*' **.qml) + + # Lint + set -l lint_out (/usr/lib/qt6/bin/qmllint --import disable $args $qml_files 2>&1 | tee /dev/stderr) + test -z "$lint_out" || exit 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ac4377231..29e704a96 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,7 +3,7 @@ name: Create release on: push: tags: - - 'v*' + - "v*" jobs: build-and-release: @@ -13,7 +13,7 @@ jobs: contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Create packages run: | @@ -35,3 +35,8 @@ jobs: caelestia-shell-${{ github.ref_name }}.tar.gz caelestia-shell-latest.tar.gz generate_release_notes: true + + - name: Update stable branch + run: | + git branch -f stable "$GITHUB_SHA" + git push origin stable --force diff --git a/.github/workflows/update-flake-inputs.yml b/.github/workflows/update-flake-inputs.yml index 1a8bd071f..7f24c7978 100644 --- a/.github/workflows/update-flake-inputs.yml +++ b/.github/workflows/update-flake-inputs.yml @@ -3,27 +3,33 @@ name: Update flake inputs on: workflow_dispatch: schedule: - - cron: '0 0 * * 0' + - cron: "0 0 * * 0" jobs: update-flake: runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/checkout@v4 + - name: Generate app token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ secrets.CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - uses: actions/checkout@v6 + with: + token: ${{ steps.app-token.outputs.token }} - name: Install Nix - uses: nixbuild/nix-quick-install-action@v31 + uses: nixbuild/nix-quick-install-action@v34 with: nix_conf: | keep-env-derivations = true keep-outputs = true - name: Restore and save Nix store - uses: nix-community/cache-nix-action@v6 + uses: nix-community/cache-nix-action@v7 with: # restore and save a cache using this key primary-key: nix-${{ hashFiles('**/*.nix', '**/flake.lock') }} @@ -78,8 +84,9 @@ jobs: - name: Commit and push changes if: steps.check.outputs.modified == 'true' - uses: EndBug/add-and-commit@v9 + uses: EndBug/add-and-commit@v10 with: + github_token: ${{ steps.app-token.outputs.token }} add: flake.lock default_author: github_actions message: "[CI] chore: update flake" diff --git a/.github/workflows/update-image.yml b/.github/workflows/update-image.yml new file mode 100644 index 000000000..74fe5435c --- /dev/null +++ b/.github/workflows/update-image.yml @@ -0,0 +1,46 @@ +name: Update Docker CI image + +on: + workflow_dispatch: + schedule: + - cron: "0 0 * * 0" + +permissions: + packages: write + +jobs: + build-image: + runs-on: ubuntu-latest + + steps: + - uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Write Dockerfile + run: | + cat > /tmp/Dockerfile <> /etc/sudoers && \ + sudo -u builder git clone https://aur.archlinux.org/yay-bin.git /home/builder/yay-bin && \ + cd /home/builder/yay-bin && \ + sudo -u builder makepkg -si --noconfirm && \ + sudo -u builder yay -S --noconfirm quickshell-git libcava && \ + sudo -u builder yay -Yc --noconfirm && \ + pacman -Rns --noconfirm yay-bin && \ + sed -i '/builder ALL=(ALL) NOPASSWD:ALL/d' /etc/sudoers && \ + userdel -r builder && \ + pacman -Scc --noconfirm + EOF + + - name: Build and push + uses: docker/build-push-action@v7 + with: + context: . + file: /tmp/Dockerfile + push: true + tags: ghcr.io/${{ github.repository_owner }}/shell-arch-env:latest diff --git a/.gitignore b/.gitignore index a114b1bcd..c30a6d926 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ /.qmlls.ini build/ .cache/ +logs \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index 7b95855b5..b23f7b485 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -60,8 +60,14 @@ if("plugin" IN_LIST ENABLE_MODULES) endif() if("shell" IN_LIST ENABLE_MODULES) - foreach(dir assets components config modules services utils) + foreach(dir assets components modules services utils) install(DIRECTORY ${dir} DESTINATION "${INSTALL_QSCONFDIR}") endforeach() - install(FILES shell.qml LICENSE DESTINATION "${INSTALL_QSCONFDIR}") + + file(READ shell.qml SHELL_QML) + string(REPLACE "settings.watchFiles: true" "settings.watchFiles: false" SHELL_QML "${SHELL_QML}") + file(WRITE "${CMAKE_BINARY_DIR}/qml/shell.qml" "${SHELL_QML}") + install(FILES "${CMAKE_BINARY_DIR}/qml/shell.qml" DESTINATION "${INSTALL_QSCONFDIR}") + + install(FILES LICENSE DESTINATION "${INSTALL_QSCONFDIR}") endif() diff --git a/README.md b/README.md index 6bbc8db9c..13b5ec8a2 100644 --- a/README.md +++ b/README.md @@ -205,20 +205,66 @@ 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. +default, you must create it manually. Options that you omit from the config file will use their default +values. + +### Per-monitor configuration + +You can configure options per-monitor in `~/.config/caelestia/monitors//shell.json`. Options +set in this file will **override** the respective options in the global config. Otherwise, the options will +use their values from the global config. + +For example, to disable the bar on DP-1: + +**`~/.config/caelestia/monitors/DP-1/shell.json`** + +```json +{ + "bar": { + "persistent": false + } +} +``` + +> [!NOTE] +> Not all options are respect per-monitor overrides. Most notably, the following options will only read +> from the global config, and ignore the respective option in per-monitor config files. +> +>
Ignored options +> +> - `appearance` (`anim`, `transparency`) +> - `general` (`logo`, `apps`, `idle`, `battery`) +> - `bar.workspaces` (`perMonitorWorkspaces`, `specialWorkspaceIcons`, `windowIcons`) +> - `bar.tray` (`iconSubs`, `hiddenIcons`) +> - `dashboard` (`mediaUpdateInterval`, `resourceUpdateInterval`) +> - `launcher` (`specialPrefix`, `actionPrefix`, `enableDangerousActions`, `vimKeybinds`, +> `favouriteApps`, `hiddenApps`, `actions`) +> - `launcher.useFuzzy` (`apps`, `actions`, `schemes`, `variants`, `wallpapers`) +> - `notifs` (`expire`, `fullscreen`, `defaultExpireTimeout`, `actionOnClick`) +> - `lock` (`enableFprint`, `maxFprintTries`) +> - `utilities` (`toasts`, `vpn`) +> - `services` (`weatherLocation`, `useFahrenheit`, `useFahrenheitPerformance`, `useTwelveHourClock`, +> `gpuType`, `visualiserBars`, `audioIncrement`, `brightnessIncrement`, `maxVolume`, `smartScheme`, +> `defaultPlayer`, `playerAliases`, `showLyrics`, `lyricsBackend`) +> - `paths` (`wallpaperDir`, `lyricsDir`) +> +>
### 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. +> The example configuration includes ALL configuration options in `shell.json`. You are +> **not** recommended to copy and paste this entire configuration into `shell.json`. +> This is meant to serve as a reference of all the available options, and you should +> only add the ones you want to change to `shell.json`.
Example ```json { + "enabled": true, "appearance": { + "deformScale": 1, "anim": { "durations": { "scale": 1 @@ -251,6 +297,10 @@ default, you must create it manually. } }, "general": { + "logo": "caelestia", + "showOverFullscreen": false, + "mediaGifSpeedAdjustment": 300, + "sessionGifSpeed": 0.7, "apps": { "terminal": ["foot"], "audio": ["pavucontrol"], @@ -328,7 +378,14 @@ default, you must create it manually. } }, "bar": { + "activeWindow": { + "compact": false, + "inverted": false, + "showOnHover": true + }, "clock": { + "background": false, + "showDate": false, "showIcon": true }, "dragThreshold": 20, @@ -413,6 +470,12 @@ default, you must create it manually. "name": "steam", "icon": "sports_esports" } + ], + "windowIcons": [ + { + "regex": "steam(_app_(default|[0-9]+))?", + "icon": "sports_esports" + } ] }, "excludedScreens": [""], @@ -422,13 +485,18 @@ default, you must create it manually. }, "border": { "rounding": 25, + "smoothing": 32, "thickness": 10 }, "dashboard": { "enabled": true, + "showOnHover": true, + "showDashboard": true, + "showMedia": true, + "showPerformance": true, + "showWeather": true, "dragThreshold": 50, - "mediaUpdateInterval": 500, - "showOnHover": true + "mediaUpdateInterval": 500 }, "launcher": { "actionPrefix": ">", @@ -560,10 +628,12 @@ default, you must create it manually. "wallpapers": false }, "showOnHover": false, + "favouriteApps": [], "hiddenApps": [] }, "lock": { - "recolourLogo": false + "recolourLogo": false, + "hideNotifs": false }, "notifs": { "actionOnClick": false, @@ -582,7 +652,10 @@ default, you must create it manually. "paths": { "mediaGif": "root:/assets/bongocat.gif", "sessionGif": "root:/assets/kurukuru.gif", - "wallpaperDir": "~/Pictures/Wallpapers" + "noNotifsPic": "root:/assets/dino.png", + "lockNoNotifsPic": "root:/assets/dino.png", + "wallpaperDir": "~/Pictures/Wallpapers", + "lyricsDir": "~/Music/lyrics" }, "services": { "audioIncrement": 0.1, @@ -593,6 +666,7 @@ default, you must create it manually. "playerAliases": [{ "from": "com.github.th_ch.youtube_music", "to": "YT Music" }], "weatherLocation": "", "useFahrenheit": false, + "useFahrenheitPerformance": false, "useTwelveHourClock": false, "smartScheme": true, "visualiserBars": 45 @@ -601,6 +675,12 @@ default, you must create it manually. "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"], @@ -639,13 +719,59 @@ default, you must create it manually. "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 + } + ] } } ```
+### Advanced configuration + +> [!WARNING] +> Do NOT change any of these options if you do not know what you are doing. These options control the +> tokens used internally within the shell, and can cause visual issues if changed. The existence of +> the options are also not guaranteed across versions, and may change or be removed without notice. + +A separate `~/.config/caelestia/shell-tokens.json` file allows editing the internal tokens without +touching the source code of the shell. These tokens affect, for example, individual rounding, +spacing, padding, font size, animation duration and easing curves tokens, and the sizes of certain +components. The appearance scale values in `shell.json` are multiplied against these base +token values to produce the final computed values. + +Per-monitor token overrides are also available at +`~/.config/caelestia/monitors//shell-tokens.json`. + ### Home Manager Module For NixOS users, a home manager module is also available. diff --git a/assets/logo.svg b/assets/logo.svg index 712d1e36e..6879c92b9 100644 --- a/assets/logo.svg +++ b/assets/logo.svg @@ -1,21 +1,19 @@ - - - - + inkscape:zoom="1.28" + inkscape:cx="43.359375" + inkscape:cy="183.98438" + inkscape:window-width="1824" + inkscape:window-height="1034" + inkscape:window-x="0" + inkscape:window-y="0" + inkscape:window-maximized="1" + inkscape:current-layer="Layer_2" /> + id="defs1"> + + - - - - - - - - - - + id="dark-4" + transform="matrix(0.08939103,0,0,0.08939103,-3.899948e-4,18.839439)"> + + + + + diff --git a/assets/shaders/fade.frag b/assets/shaders/fade.frag new file mode 100644 index 000000000..a6cdf70d0 --- /dev/null +++ b/assets/shaders/fade.frag @@ -0,0 +1,26 @@ +#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; +} diff --git a/assets/shaders/fade.frag.qsb b/assets/shaders/fade.frag.qsb new file mode 100644 index 000000000..888e4c106 Binary files /dev/null and b/assets/shaders/fade.frag.qsb differ diff --git a/components/AnchorAnim.qml b/components/AnchorAnim.qml new file mode 100644 index 000000000..dd62dc366 --- /dev/null +++ b/components/AnchorAnim.qml @@ -0,0 +1,51 @@ +import QtQuick +import Caelestia.Config + +AnchorAnimation { + enum Type { + StandardSmall = 0, + Standard, + StandardLarge, + StandardExtraLarge, + EmphasizedSmall, + Emphasized, + EmphasizedLarge, + EmphasizedExtraLarge, + FastSpatial, + DefaultSpatial, + SlowSpatial + } + + property int type: AnchorAnim.DefaultSpatial + + duration: { + if (type < AnchorAnim.StandardSmall || type > AnchorAnim.SlowSpatial) + return Tokens.anim.durations.expressiveDefaultSpatial; + + if (type == AnchorAnim.FastSpatial) + return Tokens.anim.durations.expressiveFastSpatial; + if (type == AnchorAnim.DefaultSpatial) + return Tokens.anim.durations.expressiveDefaultSpatial; + if (type == AnchorAnim.SlowSpatial) + return Tokens.anim.durations.expressiveSlowSpatial; + + const types = ["small", "normal", "large", "extraLarge"]; + const idx = type % 4; // 0-7 are the 4 standard types + return Tokens.anim.durations[types[idx]]; + } + easing: { + if (type == AnchorAnim.FastSpatial) + return Tokens.anim.expressiveFastSpatial; + if (type == AnchorAnim.DefaultSpatial) + return Tokens.anim.expressiveDefaultSpatial; + if (type == AnchorAnim.SlowSpatial) + return Tokens.anim.expressiveSlowSpatial; + + if (type >= AnchorAnim.StandardSmall && type <= AnchorAnim.StandardExtraLarge) + return Tokens.anim.standard; + if (type >= AnchorAnim.EmphasizedSmall && type <= AnchorAnim.EmphasizedExtraLarge) + return Tokens.anim.emphasized; + + return Tokens.anim.expressiveDefaultSpatial; + } +} diff --git a/components/Anim.qml b/components/Anim.qml index 6883a7984..8bc4468ac 100644 --- a/components/Anim.qml +++ b/components/Anim.qml @@ -1,8 +1,48 @@ -import qs.config import QtQuick +import Caelestia.Config NumberAnimation { - duration: Appearance.anim.durations.normal - easing.type: Easing.BezierSpline - easing.bezierCurve: Appearance.anim.curves.standard + enum Type { + StandardSmall = 0, + Standard, + StandardLarge, + StandardExtraLarge, + EmphasizedSmall, + Emphasized, + EmphasizedLarge, + EmphasizedExtraLarge, + FastSpatial, + DefaultSpatial, + SlowSpatial + } + + property int type: Anim.Standard + + duration: { + if (type < Anim.StandardSmall || type > Anim.SlowSpatial) + return Tokens.anim.durations.normal; + + if (type == Anim.FastSpatial) + return Tokens.anim.durations.expressiveFastSpatial; + if (type == Anim.DefaultSpatial) + return Tokens.anim.durations.expressiveDefaultSpatial; + if (type == Anim.SlowSpatial) + return Tokens.anim.durations.expressiveSlowSpatial; + + const types = ["small", "normal", "large", "extraLarge"]; + const idx = type % 4; // 0-7 are the 4 standard types + return Tokens.anim.durations[types[idx]]; + } + easing: { + if (type == Anim.FastSpatial) + return Tokens.anim.expressiveFastSpatial; + if (type == Anim.DefaultSpatial) + return Tokens.anim.expressiveDefaultSpatial; + if (type == Anim.SlowSpatial) + return Tokens.anim.expressiveSlowSpatial; + + if (type >= Anim.EmphasizedSmall && type <= Anim.EmphasizedExtraLarge) + return Tokens.anim.emphasized; + return Tokens.anim.standard; + } } diff --git a/components/CAnim.qml b/components/CAnim.qml index 49484b789..b86fcce1f 100644 --- a/components/CAnim.qml +++ b/components/CAnim.qml @@ -1,8 +1,7 @@ -import qs.config import QtQuick +import Caelestia.Config ColorAnimation { - duration: Appearance.anim.durations.normal - easing.type: Easing.BezierSpline - easing.bezierCurve: Appearance.anim.curves.standard + duration: Tokens.anim.durations.normal + easing: Tokens.anim.standard } diff --git a/components/ConnectionHeader.qml b/components/ConnectionHeader.qml index 12b427648..691fd3c50 100644 --- a/components/ConnectionHeader.qml +++ b/components/ConnectionHeader.qml @@ -1,8 +1,7 @@ -import qs.components -import qs.services -import qs.config import QtQuick import QtQuick.Layouts +import Caelestia.Config +import qs.components ColumnLayout { id: root @@ -10,14 +9,14 @@ ColumnLayout { required property string icon required property string title - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal Layout.alignment: Qt.AlignHCenter MaterialIcon { Layout.alignment: Qt.AlignHCenter animate: true text: root.icon - font.pointSize: Appearance.font.size.extraLarge * 3 + font.pointSize: Tokens.font.size.extraLarge * 3 font.bold: true } @@ -25,7 +24,7 @@ ColumnLayout { Layout.alignment: Qt.AlignHCenter animate: true text: root.title - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large font.bold: true } } diff --git a/components/ConnectionInfoSection.qml b/components/ConnectionInfoSection.qml index 927ef287d..d94c3a6e8 100644 --- a/components/ConnectionInfoSection.qml +++ b/components/ConnectionInfoSection.qml @@ -1,16 +1,15 @@ -import qs.components -import qs.components.effects -import qs.services -import qs.config import QtQuick import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.services ColumnLayout { id: root required property var deviceDetails - spacing: Appearance.spacing.small / 2 + spacing: Tokens.spacing.small / 2 StyledText { text: qsTr("IP Address") @@ -19,40 +18,40 @@ ColumnLayout { StyledText { text: root.deviceDetails?.ipAddress || qsTr("Not available") color: Colours.palette.m3outline - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small } StyledText { - Layout.topMargin: Appearance.spacing.normal + Layout.topMargin: Tokens.spacing.normal text: qsTr("Subnet Mask") } StyledText { text: root.deviceDetails?.subnet || qsTr("Not available") color: Colours.palette.m3outline - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small } StyledText { - Layout.topMargin: Appearance.spacing.normal + Layout.topMargin: Tokens.spacing.normal text: qsTr("Gateway") } StyledText { text: root.deviceDetails?.gateway || qsTr("Not available") color: Colours.palette.m3outline - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small } StyledText { - Layout.topMargin: Appearance.spacing.normal + Layout.topMargin: Tokens.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 + font.pointSize: Tokens.font.size.small wrapMode: Text.Wrap Layout.maximumWidth: parent.width } diff --git a/components/DashboardState.qml b/components/DashboardState.qml new file mode 100644 index 000000000..b4355cd7b --- /dev/null +++ b/components/DashboardState.qml @@ -0,0 +1,6 @@ +import Quickshell + +PersistentProperties { + property int currentTab + property date currentDate: new Date() +} diff --git a/components/DrawerVisibilities.qml b/components/DrawerVisibilities.qml new file mode 100644 index 000000000..3286e319f --- /dev/null +++ b/components/DrawerVisibilities.qml @@ -0,0 +1,11 @@ +import Quickshell + +PersistentProperties { + property bool bar + property bool osd + property bool session + property bool launcher + property bool dashboard + property bool utilities + property bool sidebar +} diff --git a/components/Logo.qml b/components/Logo.qml new file mode 100644 index 000000000..7cd41e17f --- /dev/null +++ b/components/Logo.qml @@ -0,0 +1,70 @@ +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" + } + } + } +} diff --git a/components/MaterialIcon.qml b/components/MaterialIcon.qml index a1d19d3c0..739c50bae 100644 --- a/components/MaterialIcon.qml +++ b/components/MaterialIcon.qml @@ -1,12 +1,12 @@ +import Caelestia.Config 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.family: Tokens.font.family.material + font.pointSize: Tokens.font.size.larger font.variableAxes: ({ FILL: fill.toFixed(1), GRAD: grade, diff --git a/components/PropertyRow.qml b/components/PropertyRow.qml index 640d5f743..4d3025095 100644 --- a/components/PropertyRow.qml +++ b/components/PropertyRow.qml @@ -1,8 +1,8 @@ -import qs.components -import qs.services -import qs.config import QtQuick import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.services ColumnLayout { id: root @@ -11,16 +11,16 @@ ColumnLayout { required property string value property bool showTopMargin: false - spacing: Appearance.spacing.small / 2 + spacing: Tokens.spacing.small / 2 StyledText { - Layout.topMargin: root.showTopMargin ? Appearance.spacing.normal : 0 + Layout.topMargin: root.showTopMargin ? Tokens.spacing.normal : 0 text: root.label } StyledText { text: root.value color: Colours.palette.m3outline - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small } } diff --git a/components/SectionContainer.qml b/components/SectionContainer.qml index 2b653a5d9..7aa3f13f1 100644 --- a/components/SectionContainer.qml +++ b/components/SectionContainer.qml @@ -1,21 +1,20 @@ -import qs.components -import qs.components.effects -import qs.services -import qs.config import QtQuick import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.services StyledRect { id: root default property alias content: contentColumn.data - property real contentSpacing: Appearance.spacing.larger + property real contentSpacing: Tokens.spacing.larger property bool alignTop: false Layout.fillWidth: true - implicitHeight: contentColumn.implicitHeight + Appearance.padding.large * 2 + implicitHeight: contentColumn.implicitHeight + Tokens.padding.large * 2 - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal color: Colours.transparency.enabled ? Colours.layer(Colours.palette.m3surfaceContainer, 2) : Colours.palette.m3surfaceContainerHigh ColumnLayout { @@ -25,7 +24,7 @@ StyledRect { anchors.right: parent.right anchors.top: root.alignTop ? parent.top : undefined anchors.verticalCenter: root.alignTop ? undefined : parent.verticalCenter - anchors.margins: Appearance.padding.large + anchors.margins: Tokens.padding.large spacing: root.contentSpacing } diff --git a/components/SectionHeader.qml b/components/SectionHeader.qml index 502e91895..09364143f 100644 --- a/components/SectionHeader.qml +++ b/components/SectionHeader.qml @@ -1,8 +1,8 @@ -import qs.components -import qs.services -import qs.config import QtQuick import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.services ColumnLayout { id: root @@ -13,9 +13,9 @@ ColumnLayout { spacing: 0 StyledText { - Layout.topMargin: Appearance.spacing.large + Layout.topMargin: Tokens.spacing.large text: root.title - font.pointSize: Appearance.font.size.larger + font.pointSize: Tokens.font.size.larger font.weight: 500 } diff --git a/components/StateLayer.qml b/components/StateLayer.qml index a20e26616..2ce8c5008 100644 --- a/components/StateLayer.qml +++ b/components/StateLayer.qml @@ -1,95 +1,186 @@ -import qs.services -import qs.config import QtQuick +import QtQuick.Shapes +import Caelestia.Config +import qs.services MouseArea { id: root property bool disabled property bool showHoverBackground: true - property color color: Colours.palette.m3onSurface - property real radius: parent?.radius ?? 0 - property alias rect: hoverLayer + readonly property alias rect: base + + property bool shapeMorph + property real stateOpacity: pressed ? 0.1 : containsMouse ? 0.08 : 0 + + property real pressX: width / 2 + property real pressY: height / 2 + property real circleRadius + + property alias color: base.color + property alias radius: base.radius + property alias topLeftRadius: base.topLeftRadius + property alias topRightRadius: base.topRightRadius + property alias bottomLeftRadius: base.bottomLeftRadius + property alias bottomRightRadius: base.bottomRightRadius + + readonly property real endRadius: { + const d1 = distSq(0, 0); + const d2 = distSq(width, 0); + const d3 = distSq(0, height); + const d4 = distSq(width, height); + return Math.sqrt(Math.max(d1, d2, d3, d4)) * (shapeMorph ? 1.16 : 1); + } + property real endRadiusAtPress - function onClicked(): void { + function distSq(x: real, y: real): real { + return (pressX - x) ** 2 + (pressY - y) ** 2; } - anchors.fill: parent + function clamp(r: real): real { + return Math.max(0, Math.min(r, width / 2, height / 2)); + } + function press(x: real, y: real): void { + pressX = x; + pressY = y; + fadeAnim.complete(); + circleRadius = 0; + circle.opacity = 0.1; + rippleAnim.restart(); + endRadiusAtPress = endRadius; + } + + anchors.fill: parent enabled: !disabled cursorShape: disabled ? undefined : Qt.PointingHandCursor hoverEnabled: true - onPressed: event => { - if (disabled) - return; + onPressed: e => press(e.x, e.y) - 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(); + onPressedChanged: { + if (!pressed && !rippleAnim.running && circle.opacity > 0) + fadeAnim.start(); } - onClicked: event => !disabled && onClicked(event) + onCircleRadiusChanged: { + if (!pressed && circleRadius > endRadiusAtPress * 0.99 && !fadeAnim.running) + fadeAnim.start(); + } - SequentialAnimation { + Anim { id: rippleAnim - property real x - property real y - property real radius + alwaysRunToEnd: true + target: root + property: "circleRadius" + to: root.endRadius + easing: Tokens.anim.expressiveSlowEffects + duration: Tokens.anim.durations.expressiveSlowEffects * 2 + } - 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 - } + Anim { + id: fadeAnim + + target: circle + property: "opacity" + to: 0 + easing: Tokens.anim.expressiveSlowEffects + duration: Tokens.anim.durations.expressiveSlowEffects } - StyledClippingRect { - id: hoverLayer + StyledRect { + id: base anchors.fill: parent + opacity: root.stateOpacity + color: Colours.palette.m3onSurface + // Pick up radius from parent if it has one (parent can be anything with a radius property) + radius: root.parent?.radius ?? 0 // qmllint disable missing-property + } - color: Qt.alpha(root.color, root.disabled ? 0 : root.pressed ? 0.12 : (root.showHoverBackground && root.containsMouse) ? 0.08 : 0) - radius: root.radius + Shape { + id: circle - StyledRect { - id: ripple + anchors.fill: parent + opacity: 0 + preferredRendererType: Shape.CurveRenderer + + ShapePath { + strokeWidth: 0 + strokeColor: "transparent" + fillColor: base.color + fillGradient: RadialGradient { + centerX: root.pressX + centerY: root.pressY + centerRadius: root.circleRadius + focalX: centerX + focalY: centerY + + GradientStop { + position: 0 + color: Qt.alpha(base.color, 1) + } + GradientStop { + position: 0.99 + color: Qt.alpha(base.color, 1) + } + GradientStop { + position: 1 + color: Qt.alpha(base.color, 0) + } + } - radius: Appearance.rounding.full - color: root.color - opacity: 0 + startX: root.clamp(base.topLeftRadius) + startY: 0 - transform: Translate { - x: -ripple.width / 2 - y: -ripple.height / 2 + PathLine { + x: root.width - root.clamp(base.topLeftRadius) + y: 0 + } + PathArc { + relativeX: root.clamp(base.topLeftRadius) + relativeY: root.clamp(base.topLeftRadius) + radiusX: root.clamp(base.topLeftRadius) + radiusY: root.clamp(base.topLeftRadius) + } + PathLine { + x: root.width + y: root.height - root.clamp(base.bottomRightRadius) } + PathArc { + relativeX: -root.clamp(base.bottomRightRadius) + relativeY: root.clamp(base.bottomRightRadius) + radiusX: root.clamp(base.bottomRightRadius) + radiusY: root.clamp(base.bottomRightRadius) + } + PathLine { + x: root.clamp(base.bottomLeftRadius) + y: root.height + } + PathArc { + relativeX: -root.clamp(base.bottomLeftRadius) + relativeY: -root.clamp(base.bottomLeftRadius) + radiusX: root.clamp(base.bottomLeftRadius) + radiusY: root.clamp(base.bottomLeftRadius) + } + PathLine { + x: 0 + y: root.clamp(base.topLeftRadius) + } + PathArc { + x: root.clamp(base.topLeftRadius) + y: 0 + radiusX: root.clamp(base.topLeftRadius) + radiusY: root.clamp(base.topLeftRadius) + } + } + } + + Behavior on stateOpacity { + Anim { + easing: Tokens.anim.expressiveDefaultEffects + duration: Tokens.anim.durations.expressiveDefaultEffects } } } diff --git a/components/StyledClippingRect.qml b/components/StyledClippingRect.qml index 8f2630c13..6484e0e6f 100644 --- a/components/StyledClippingRect.qml +++ b/components/StyledClippingRect.qml @@ -1,5 +1,5 @@ -import Quickshell.Widgets import QtQuick +import Quickshell.Widgets ClippingRectangle { id: root diff --git a/components/StyledText.qml b/components/StyledText.qml index ed961d26a..d3624c58c 100644 --- a/components/StyledText.qml +++ b/components/StyledText.qml @@ -1,8 +1,8 @@ pragma ComponentBehavior: Bound -import qs.services -import qs.config import QtQuick +import Caelestia.Config +import qs.services Text { id: root @@ -11,13 +11,13 @@ Text { property string animateProp: "scale" property real animateFrom: 0 property real animateTo: 1 - property int animateDuration: Appearance.anim.durations.normal + property int animateDuration: Tokens.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 + font.family: Tokens.font.family.sans + font.pointSize: Tokens.font.size.smaller Behavior on color { CAnim {} @@ -29,12 +29,12 @@ Text { SequentialAnimation { Anim { to: root.animateFrom - easing.bezierCurve: Appearance.anim.curves.standardAccel + easing: Tokens.anim.standardAccel } PropertyAction {} Anim { to: root.animateTo - easing.bezierCurve: Appearance.anim.curves.standardDecel + easing: Tokens.anim.standardDecel } } } @@ -43,6 +43,5 @@ Text { target: root property: root.animateProp duration: root.animateDuration / 2 - easing.type: Easing.BezierSpline } } diff --git a/components/containers/StyledFlickable.qml b/components/containers/StyledFlickable.qml index bc6ae0f62..b792e5833 100644 --- a/components/containers/StyledFlickable.qml +++ b/components/containers/StyledFlickable.qml @@ -1,5 +1,5 @@ -import ".." import QtQuick +import qs.components Flickable { id: root diff --git a/components/containers/StyledListView.qml b/components/containers/StyledListView.qml index 626d20635..8b5a4401e 100644 --- a/components/containers/StyledListView.qml +++ b/components/containers/StyledListView.qml @@ -1,5 +1,5 @@ -import ".." import QtQuick +import qs.components ListView { id: root diff --git a/components/containers/StyledWindow.qml b/components/containers/StyledWindow.qml index 8c6e39fc8..72bbed6f1 100644 --- a/components/containers/StyledWindow.qml +++ b/components/containers/StyledWindow.qml @@ -1,9 +1,15 @@ import Quickshell import Quickshell.Wayland +import Caelestia.Config +// qmllint disable uncreatable-type PanelWindow { + // qmllint enable uncreatable-type required property string name WlrLayershell.namespace: `caelestia-${name}` color: "transparent" + + contentItem.Config.screen: screen.name + contentItem.Tokens.screen: screen.name } diff --git a/components/controls/CircularIndicator.qml b/components/controls/CircularIndicator.qml index 957899e5c..aabda13a7 100644 --- a/components/controls/CircularIndicator.qml +++ b/components/controls/CircularIndicator.qml @@ -1,9 +1,9 @@ -import ".." -import qs.services -import qs.config -import Caelestia.Internal import QtQuick import QtQuick.Templates +import Caelestia.Config +import Caelestia.Internal +import qs.components +import qs.services BusyIndicator { id: root @@ -19,8 +19,8 @@ BusyIndicator { Completing } - property real implicitSize: Appearance.font.size.normal * 3 - property real strokeWidth: Appearance.padding.small * 0.8 + property real implicitSize: Tokens.font.size.normal * 3 + property real strokeWidth: Tokens.padding.small * 0.8 property color fgColour: Colours.palette.m3primary property color bgColour: Colours.palette.m3secondaryContainer @@ -64,7 +64,7 @@ BusyIndicator { transitions: Transition { Anim { properties: "opacity,internalStrokeWidth" - duration: manager.completeEndDuration * Appearance.anim.durations.scale + duration: manager.completeEndDuration * Tokens.anim.durations.scale } } @@ -90,7 +90,7 @@ BusyIndicator { property: "progress" from: 0 to: 1 - duration: manager.duration * Appearance.anim.durations.scale + duration: manager.duration * Tokens.anim.durations.scale } NumberAnimation { @@ -99,7 +99,7 @@ BusyIndicator { property: "completeEndProgress" from: 0 to: 1 - duration: manager.completeEndDuration * Appearance.anim.durations.scale + duration: manager.completeEndDuration * Tokens.anim.durations.scale onFinished: { if (root.animState === CircularIndicator.Completing) root.animState = CircularIndicator.Stopped; diff --git a/components/controls/CircularProgress.qml b/components/controls/CircularProgress.qml index a15cd900b..2372c86cd 100644 --- a/components/controls/CircularProgress.qml +++ b/components/controls/CircularProgress.qml @@ -1,17 +1,17 @@ -import ".." -import qs.services -import qs.config import QtQuick import QtQuick.Shapes +import Caelestia.Config +import qs.components +import qs.services Shape { id: root property real value property int startAngle: -90 - property int strokeWidth: Appearance.padding.smaller + property int strokeWidth: Tokens.padding.smaller property int padding: 0 - property int spacing: Appearance.spacing.small + property int spacing: Tokens.spacing.small property color fgColour: Colours.palette.m3primary property color bgColour: Colours.palette.m3secondaryContainer @@ -27,7 +27,7 @@ Shape { fillColor: "transparent" strokeColor: root.bgColour strokeWidth: root.strokeWidth - capStyle: Appearance.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap + capStyle: root.Tokens.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap PathAngleArc { startAngle: root.startAngle + 360 * root.vValue + root.gapAngle @@ -40,7 +40,7 @@ Shape { Behavior on strokeColor { CAnim { - duration: Appearance.anim.durations.large + duration: Tokens.anim.durations.large } } } @@ -49,7 +49,7 @@ Shape { fillColor: "transparent" strokeColor: root.fgColour strokeWidth: root.strokeWidth - capStyle: Appearance.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap + capStyle: root.Tokens.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap PathAngleArc { startAngle: root.startAngle @@ -62,7 +62,7 @@ Shape { Behavior on strokeColor { CAnim { - duration: Appearance.anim.durations.large + duration: Tokens.anim.durations.large } } } diff --git a/components/controls/CollapsibleSection.qml b/components/controls/CollapsibleSection.qml index e3d8eefd1..80ea8fe8d 100644 --- a/components/controls/CollapsibleSection.qml +++ b/components/controls/CollapsibleSection.qml @@ -1,10 +1,8 @@ -import ".." -import qs.components -import qs.components.effects -import qs.services -import qs.config import QtQuick import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.services ColumnLayout { id: root @@ -15,28 +13,32 @@ ColumnLayout { property bool showBackground: false property bool nested: false + default property alias content: contentColumn.data + signal toggleRequested - spacing: Appearance.spacing.small + spacing: Tokens.spacing.small Layout.fillWidth: true Item { id: sectionHeaderItem + Layout.fillWidth: true - Layout.preferredHeight: Math.max(titleRow.implicitHeight + Appearance.padding.normal * 2, 48) + Layout.preferredHeight: Math.max(titleRow.implicitHeight + Tokens.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 + anchors.leftMargin: Tokens.padding.normal + anchors.rightMargin: Tokens.padding.normal + spacing: Tokens.spacing.normal StyledText { text: root.title - font.pointSize: Appearance.font.size.larger + font.pointSize: Tokens.font.size.larger font.weight: 500 } @@ -48,11 +50,11 @@ ColumnLayout { text: "expand_more" rotation: root.expanded ? 180 : 0 color: Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.font.size.normal + Behavior on rotation { Anim { - duration: Appearance.anim.durations.small - easing.bezierCurve: Appearance.anim.curves.standard + type: Anim.StandardSmall } } } @@ -61,70 +63,66 @@ ColumnLayout { StateLayer { anchors.fill: parent color: Colours.palette.m3onSurface - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal showHoverBackground: false - function onClicked(): void { + onClicked: { root.toggleRequested(); root.expanded = !root.expanded; } } } - default property alias content: contentColumn.data - Item { id: contentWrapper + Layout.fillWidth: true - Layout.preferredHeight: root.expanded ? (contentColumn.implicitHeight + Appearance.spacing.small * 2) : 0 + Layout.preferredHeight: root.expanded ? (contentColumn.implicitHeight + Tokens.spacing.small * 2) : 0 clip: true Behavior on Layout.preferredHeight { - Anim { - easing.bezierCurve: Appearance.anim.curves.standard - } + Anim {} } StyledRect { id: backgroundRect + anchors.fill: parent - radius: Appearance.rounding.normal + radius: Tokens.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 - } + Anim {} } } 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 + y: Tokens.spacing.small + anchors.leftMargin: Tokens.padding.normal + anchors.rightMargin: Tokens.padding.normal + anchors.bottomMargin: Tokens.spacing.small + spacing: Tokens.spacing.small opacity: root.expanded ? 1.0 : 0.0 Behavior on opacity { - Anim { - easing.bezierCurve: Appearance.anim.curves.standard - } + Anim {} } StyledText { id: descriptionText + Layout.fillWidth: true - Layout.topMargin: root.description !== "" ? Appearance.spacing.smaller : 0 - Layout.bottomMargin: root.description !== "" ? Appearance.spacing.small : 0 + Layout.topMargin: root.description !== "" ? Tokens.spacing.smaller : 0 + Layout.bottomMargin: root.description !== "" ? Tokens.spacing.small : 0 visible: root.description !== "" text: root.description color: Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small wrapMode: Text.Wrap } } diff --git a/components/controls/CustomSpinBox.qml b/components/controls/CustomSpinBox.qml index 438dc0806..d7885c30b 100644 --- a/components/controls/CustomSpinBox.qml +++ b/components/controls/CustomSpinBox.qml @@ -1,10 +1,10 @@ pragma ComponentBehavior: Bound -import ".." -import qs.services -import qs.config import QtQuick import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.services RowLayout { id: root @@ -15,13 +15,13 @@ RowLayout { property real step: 1 property alias repeatRate: timer.interval - signal valueModified(value: real) - - spacing: Appearance.spacing.small - property bool isEditing: false property string displayText: root.value.toString() + signal valueModified(value: real) + + spacing: Tokens.spacing.small + onValueChanged: { if (!root.isEditing) { root.displayText = root.value.toString(); @@ -73,23 +73,23 @@ RowLayout { root.isEditing = false; } - padding: Appearance.padding.small - leftPadding: Appearance.padding.normal - rightPadding: Appearance.padding.normal + padding: Tokens.padding.small + leftPadding: Tokens.padding.normal + rightPadding: Tokens.padding.normal background: StyledRect { implicitWidth: 100 - radius: Appearance.rounding.small + radius: Tokens.rounding.small color: Colours.tPalette.m3surfaceContainerHigh } } StyledRect { - radius: Appearance.rounding.small + radius: Tokens.rounding.small color: Colours.palette.m3primary implicitWidth: implicitHeight - implicitHeight: upIcon.implicitHeight + Appearance.padding.small * 2 + implicitHeight: upIcon.implicitHeight + Tokens.padding.small * 2 StateLayer { id: upState @@ -99,7 +99,7 @@ RowLayout { onPressAndHold: timer.start() onReleased: timer.stop() - function onClicked(): void { + onClicked: { 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; @@ -120,21 +120,16 @@ RowLayout { } StyledRect { - radius: Appearance.rounding.small + radius: Tokens.rounding.small color: Colours.palette.m3primary implicitWidth: implicitHeight - implicitHeight: downIcon.implicitHeight + Appearance.padding.small * 2 + implicitHeight: downIcon.implicitHeight + Tokens.padding.small * 2 StateLayer { id: downState - color: Colours.palette.m3onPrimary - - onPressAndHold: timer.start() - onReleased: timer.stop() - - function onClicked(): void { + onClicked: { 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; @@ -143,6 +138,11 @@ RowLayout { root.displayText = newValue.toString(); root.valueModified(newValue); } + + color: Colours.palette.m3onPrimary + + onPressAndHold: timer.start() + onReleased: timer.stop() } MaterialIcon { @@ -162,9 +162,9 @@ RowLayout { triggeredOnStart: true onTriggered: { if (upState.pressed) - upState.onClicked(); + upState.clicked(); else if (downState.pressed) - downState.onClicked(); + downState.clicked(); } } } diff --git a/components/controls/FilledSlider.qml b/components/controls/FilledSlider.qml index 80dd44c5f..1d8c85a9c 100644 --- a/components/controls/FilledSlider.qml +++ b/components/controls/FilledSlider.qml @@ -1,9 +1,9 @@ -import ".." import "../effects" -import qs.services -import qs.config import QtQuick import QtQuick.Templates +import Caelestia.Config +import qs.components +import qs.services Slider { id: root @@ -16,7 +16,7 @@ Slider { background: StyledRect { color: Colours.layer(Colours.palette.m3surfaceContainer, 2) - radius: Appearance.rounding.full + radius: Tokens.rounding.full StyledRect { anchors.left: parent.left @@ -51,7 +51,7 @@ Slider { anchors.fill: parent color: Colours.palette.m3inverseSurface - radius: Appearance.rounding.full + radius: Tokens.rounding.full MouseArea { id: handleInteraction @@ -70,8 +70,8 @@ Slider { 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; + font.pointSize = moving ? Tokens.font.size.small : Tokens.font.size.larger; + font.family = moving ? Tokens.font.family.sans : Tokens.font.family.material; } text: root.icon @@ -96,8 +96,8 @@ Slider { target: icon property: "scale" to: 0 - duration: Appearance.anim.durations.normal / 2 - easing.bezierCurve: Appearance.anim.curves.standardAccel + duration: Tokens.anim.durations.normal / 2 + easing: Tokens.anim.standardAccel } ScriptAction { script: icon.update() @@ -106,8 +106,8 @@ Slider { target: icon property: "scale" to: 1 - duration: Appearance.anim.durations.normal / 2 - easing.bezierCurve: Appearance.anim.curves.standardDecel + duration: Tokens.anim.durations.normal / 2 + easing: Tokens.anim.standardDecel } } } @@ -140,7 +140,7 @@ Slider { Behavior on value { Anim { - duration: Appearance.anim.durations.large + type: Anim.StandardLarge } } } diff --git a/components/controls/IconButton.qml b/components/controls/IconButton.qml index ffb1d0663..58af806a6 100644 --- a/components/controls/IconButton.qml +++ b/components/controls/IconButton.qml @@ -1,7 +1,7 @@ -import ".." -import qs.services -import qs.config import QtQuick +import Caelestia.Config +import qs.components +import qs.services StyledRect { id: root @@ -15,7 +15,7 @@ StyledRect { 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 real padding: type === IconButton.Text ? Tokens.padding.small / 2 : Tokens.padding.smaller property alias font: label.font property int type: IconButton.Filled property bool disabled @@ -44,7 +44,7 @@ StyledRect { onCheckedChanged: internalChecked = checked - radius: internalChecked ? Appearance.rounding.small : implicitHeight / 2 * Math.min(1, Appearance.rounding.scale) + radius: internalChecked ? Tokens.rounding.small : implicitHeight / 2 * Math.min(1, Tokens.rounding.scale) color: type === IconButton.Text ? "transparent" : disabled ? disabledColour : internalChecked ? activeColour : inactiveColour implicitWidth: implicitHeight @@ -55,8 +55,7 @@ StyledRect { color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour disabled: root.disabled - - function onClicked(): void { + onClicked: { if (root.toggle) root.internalChecked = !root.internalChecked; root.clicked(); diff --git a/components/controls/IconTextButton.qml b/components/controls/IconTextButton.qml index b2bb96cc0..2a1dacec7 100644 --- a/components/controls/IconTextButton.qml +++ b/components/controls/IconTextButton.qml @@ -1,8 +1,8 @@ -import ".." -import qs.services -import qs.config import QtQuick import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.services StyledRect { id: root @@ -17,8 +17,8 @@ StyledRect { property alias text: label.text property bool checked property bool toggle - property real horizontalPadding: Appearance.padding.normal - property real verticalPadding: Appearance.padding.smaller + property real horizontalPadding: Tokens.padding.normal + property real verticalPadding: Tokens.padding.smaller property alias font: label.font property int type: IconTextButton.Filled @@ -36,7 +36,7 @@ StyledRect { onCheckedChanged: internalChecked = checked - radius: internalChecked ? Appearance.rounding.small : implicitHeight / 2 * Math.min(1, Appearance.rounding.scale) + radius: internalChecked ? Tokens.rounding.small : implicitHeight / 2 * Math.min(1, Tokens.rounding.scale) color: type === IconTextButton.Text ? "transparent" : internalChecked ? activeColour : inactiveColour implicitWidth: row.implicitWidth + horizontalPadding * 2 @@ -46,8 +46,7 @@ StyledRect { id: stateLayer color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour - - function onClicked(): void { + onClicked: { if (root.toggle) root.internalChecked = !root.internalChecked; root.clicked(); @@ -58,7 +57,7 @@ StyledRect { id: row anchors.centerIn: parent - spacing: Appearance.spacing.small + spacing: Tokens.spacing.small MaterialIcon { id: iconLabel diff --git a/components/controls/Menu.qml b/components/controls/Menu.qml index c763b54a8..482a4d409 100644 --- a/components/controls/Menu.qml +++ b/components/controls/Menu.qml @@ -1,95 +1,187 @@ pragma ComponentBehavior: Bound -import ".." -import "../effects" -import qs.services -import qs.config import QtQuick import QtQuick.Layouts +import Quickshell +import Caelestia.Config +import qs.components +import qs.components.effects +import qs.services +import qs.modules.drawers -Elevation { +MouseArea { id: root + enum Side { + Top, + Bottom, + Left, + Right + } + + required property Item attachTo + property int attachSideX: Menu.Right + property int attachSideY: Menu.Bottom + property int thisSideX: Menu.Right + property int thisSideY: Menu.Top + property real marginX + property real marginY + property list items property MenuItem active: items[0] ?? null property bool expanded signal itemSelected(item: MenuItem) - radius: Appearance.rounding.small / 2 - level: 2 + parent: { + const win = QsWindow.window; + const contentWin = win as ContentWindow; // If inside the drawer content window, put it inside the interaction wrapper so hover works + return contentWin ? contentWin.interactionWrapper : (win as QsWindow).contentItem; + } + anchors.fill: parent - implicitWidth: Math.max(200, column.implicitWidth) - implicitHeight: root.expanded ? column.implicitHeight : 0 - opacity: root.expanded ? 1 : 0 + enabled: expanded + onClicked: expanded = false - StyledClippingRect { - anchors.fill: parent - radius: parent.radius - color: Colours.palette.m3surfaceContainer + opacity: expanded ? 1 : 0 + layer.enabled: opacity < 1 - ColumnLayout { - id: column + Behavior on opacity { + Anim { + duration: Tokens.anim.durations.small + } + } - anchors.left: parent.left - anchors.right: parent.right - spacing: 0 + TransformWatcher { + id: watcher - Repeater { - model: root.items + a: root.parent + b: root.attachTo + } - StyledRect { - id: item + Elevation { + id: menu - required property int index - required property MenuItem modelData - readonly property bool active: modelData === root.active + x: { + watcher.transform; // mapToItem is not reactive so this forces updates + const item = root.attachTo; + let off = root.attachSideX === Menu.Left ? 0 : item.width; + if (root.thisSideX === Menu.Right) + off -= width; + return item.mapToItem(root.parent, off, 0).x + root.marginX; + } + y: { + watcher.transform; // mapToItem is not reactive so this forces updates + const item = root.attachTo; + let off = root.attachSideY === Menu.Top ? 0 : item.height; + if (root.thisSideY === Menu.Bottom) + off -= height; + return item.mapToItem(root.parent, 0, off).y + root.marginY; + } - Layout.fillWidth: true - implicitWidth: menuOptionRow.implicitWidth + Appearance.padding.normal * 2 - implicitHeight: menuOptionRow.implicitHeight + Appearance.padding.normal * 2 + radius: Tokens.rounding.normal + level: 2 - color: Qt.alpha(Colours.palette.m3secondaryContainer, active ? 1 : 0) + implicitWidth: Math.max(200, column.implicitWidth + column.anchors.margins * 2) + implicitHeight: column.implicitHeight + column.anchors.margins * 2 - StateLayer { - color: item.active ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface - disabled: !root.expanded + transform: Scale { + yScale: root.expanded ? 1 : 0.1 + origin.y: root.thisSideY === Menu.Bottom ? menu.height : 0 - function onClicked(): void { - root.itemSelected(item.modelData); - root.active = item.modelData; - root.expanded = false; - } - } + Behavior on yScale { + Anim { + type: Anim.DefaultSpatial + } + } + } + + StyledRect { + anchors.fill: parent + radius: parent.radius + color: Colours.palette.m3surfaceContainerLow + + ColumnLayout { + id: column + + anchors.fill: parent + anchors.margins: Tokens.padding.small + spacing: 0 - RowLayout { - id: menuOptionRow + Repeater { + id: repeater - anchors.fill: parent - anchors.margins: Appearance.padding.normal - spacing: Appearance.spacing.small + model: root.items - MaterialIcon { - Layout.alignment: Qt.AlignVCenter - text: item.modelData.icon - color: item.active ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurfaceVariant + StyledRect { + id: item + + required property int index + required property MenuItem modelData + readonly property bool active: modelData === root.active + + Layout.fillWidth: true + implicitWidth: menuOptionRow.implicitWidth + Tokens.padding.normal * 2 + implicitHeight: menuOptionRow.implicitHeight + Tokens.padding.normal * 2 + + radius: active ? 12 : Tokens.rounding.extraSmall // This should use a token, but tokens are currently extremely scuffed + topLeftRadius: index === 0 ? Tokens.rounding.small : radius + topRightRadius: index === 0 ? Tokens.rounding.small : radius + bottomLeftRadius: index === repeater.count - 1 ? Tokens.rounding.small : radius + bottomRightRadius: index === repeater.count - 1 ? Tokens.rounding.small : radius + + color: Qt.alpha(Colours.palette.m3tertiaryContainer, active ? 1 : 0) + + Behavior on radius { + Anim {} } - StyledText { - Layout.alignment: Qt.AlignVCenter - Layout.fillWidth: true - text: item.modelData.text - color: item.active ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface + StateLayer { + topLeftRadius: parent.topLeftRadius + topRightRadius: parent.topRightRadius + bottomLeftRadius: parent.bottomLeftRadius + bottomRightRadius: parent.bottomRightRadius + + color: item.active ? Colours.palette.m3onTertiaryContainer : Colours.palette.m3onSurface + disabled: !root.expanded + onClicked: { + root.itemSelected(item.modelData); + root.active = item.modelData; + item.modelData.clicked(); + root.expanded = false; + } } - Loader { - Layout.alignment: Qt.AlignVCenter - active: item.modelData.trailingIcon.length > 0 - visible: active + RowLayout { + id: menuOptionRow - sourceComponent: MaterialIcon { - text: item.modelData.trailingIcon - color: item.active ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface + anchors.fill: parent + anchors.margins: Tokens.padding.normal + spacing: Tokens.spacing.small + + MaterialIcon { + Layout.alignment: Qt.AlignVCenter + text: item.modelData.icon + color: item.active ? Colours.palette.m3onTertiaryContainer : Colours.palette.m3onSurfaceVariant + } + + StyledText { + Layout.alignment: Qt.AlignVCenter + Layout.fillWidth: true + text: item.modelData.text + color: item.active ? Colours.palette.m3onTertiaryContainer : 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.m3onTertiaryContainer : Colours.palette.m3onSurfaceVariant + } } } } @@ -97,17 +189,4 @@ Elevation { } } } - - Behavior on opacity { - Anim { - duration: Appearance.anim.durations.expressiveDefaultSpatial - } - } - - Behavior on implicitHeight { - Anim { - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial - } - } } diff --git a/components/controls/SpinBoxRow.qml b/components/controls/SpinBoxRow.qml index fe6a19822..dc52c19c1 100644 --- a/components/controls/SpinBoxRow.qml +++ b/components/controls/SpinBoxRow.qml @@ -1,10 +1,8 @@ -import ".." -import qs.components -import qs.components.effects -import qs.services -import qs.config import QtQuick import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.services StyledRect { id: root @@ -17,8 +15,8 @@ StyledRect { property var onValueModified: function (value) {} Layout.fillWidth: true - implicitHeight: row.implicitHeight + Appearance.padding.large * 2 - radius: Appearance.rounding.normal + implicitHeight: row.implicitHeight + Tokens.padding.large * 2 + radius: Tokens.rounding.normal color: Colours.layer(Colours.palette.m3surfaceContainer, 2) Behavior on implicitHeight { @@ -31,8 +29,8 @@ StyledRect { anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - anchors.margins: Appearance.padding.large - spacing: Appearance.spacing.normal + anchors.margins: Tokens.padding.large + spacing: Tokens.spacing.normal StyledText { Layout.fillWidth: true @@ -45,7 +43,7 @@ StyledRect { step: root.step value: root.value onValueModified: value => { - root.onValueModified(value); + root.onValueModified(value); // qmllint disable use-proper-function } } } diff --git a/components/controls/SplitButton.qml b/components/controls/SplitButton.qml index c91474eae..4acd98cf6 100644 --- a/components/controls/SplitButton.qml +++ b/components/controls/SplitButton.qml @@ -1,8 +1,8 @@ -import ".." -import qs.services -import qs.config import QtQuick import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.services Row { id: root @@ -12,8 +12,8 @@ Row { Tonal } - property real horizontalPadding: Appearance.padding.normal - property real verticalPadding: Appearance.padding.smaller + property real horizontalPadding: Tokens.padding.normal + property real verticalPadding: Tokens.padding.smaller property int type: SplitButton.Filled property bool disabled property bool menuOnTop @@ -33,12 +33,12 @@ Row { 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) + spacing: Math.floor(Tokens.spacing.small / 2) StyledRect { - radius: implicitHeight / 2 * Math.min(1, Appearance.rounding.scale) - topRightRadius: Appearance.rounding.small / 2 - bottomRightRadius: Appearance.rounding.small / 2 + radius: implicitHeight / 2 * Math.min(1, Tokens.rounding.scale) + topRightRadius: Tokens.rounding.small / 2 + bottomRightRadius: Tokens.rounding.small / 2 color: root.disabled ? root.disabledColour : root.colour implicitWidth: textRow.implicitWidth + root.horizontalPadding * 2 @@ -51,10 +51,7 @@ Row { rect.bottomRightRadius: parent.bottomRightRadius color: root.textColour disabled: root.disabled - - function onClicked(): void { - root.active?.clicked(); - } + onClicked: root.active?.clicked() } RowLayout { @@ -62,7 +59,7 @@ Row { anchors.centerIn: parent anchors.horizontalCenterOffset: Math.floor(root.verticalPadding / 4) - spacing: Appearance.spacing.small + spacing: Tokens.spacing.small MaterialIcon { id: iconLabel @@ -86,7 +83,7 @@ Row { Behavior on Layout.preferredWidth { Anim { - easing.bezierCurve: Appearance.anim.curves.emphasized + type: Anim.Emphasized } } } @@ -96,9 +93,9 @@ Row { StyledRect { id: expandBtn - property real rad: root.expanded ? implicitHeight / 2 * Math.min(1, Appearance.rounding.scale) : Appearance.rounding.small / 2 + property real rad: root.expanded ? implicitHeight / 2 * Math.min(1, Tokens.rounding.scale) : Tokens.rounding.small / 2 - radius: implicitHeight / 2 * Math.min(1, Appearance.rounding.scale) + radius: implicitHeight / 2 * Math.min(1, Tokens.rounding.scale) topLeftRadius: rad bottomLeftRadius: rad color: root.disabled ? root.disabledColour : root.colour @@ -113,10 +110,7 @@ Row { rect.bottomLeftRadius: parent.bottomLeftRadius color: root.textColour disabled: root.disabled - - function onClicked(): void { - root.expanded = !root.expanded; - } + onClicked: root.expanded = !root.expanded } MaterialIcon { @@ -141,24 +135,14 @@ Row { Behavior on rad { Anim {} } + } - Menu { - id: menu - - states: State { - when: root.menuOnTop - - AnchorChanges { - target: menu - anchors.top: undefined - anchors.bottom: expandBtn.top - } - } + Menu { + id: menu - anchors.top: parent.bottom - anchors.right: parent.right - anchors.topMargin: Appearance.spacing.small - anchors.bottomMargin: Appearance.spacing.small - } + attachTo: expandBtn + attachSideY: root.menuOnTop ? Menu.Top : Menu.Bottom + thisSideY: root.menuOnTop ? Menu.Bottom : Menu.Top + marginY: Tokens.spacing.small * (root.menuOnTop ? -1 : 1) } } diff --git a/components/controls/SplitButtonRow.qml b/components/controls/SplitButtonRow.qml index db9925ff6..bd1ecc622 100644 --- a/components/controls/SplitButtonRow.qml +++ b/components/controls/SplitButtonRow.qml @@ -1,19 +1,16 @@ pragma ComponentBehavior: Bound -import ".." -import qs.components -import qs.components.effects -import qs.services -import qs.config import QtQuick import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.services 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 @@ -23,8 +20,8 @@ StyledRect { signal selected(item: MenuItem) Layout.fillWidth: true - implicitHeight: row.implicitHeight + Appearance.padding.large * 2 - radius: Appearance.rounding.normal + implicitHeight: row.implicitHeight + Tokens.padding.large * 2 + radius: Tokens.rounding.normal color: Colours.layer(Colours.palette.m3surfaceContainer, 2) clip: false @@ -33,9 +30,10 @@ StyledRect { RowLayout { id: row + anchors.fill: parent - anchors.margins: Appearance.padding.large - spacing: Appearance.spacing.normal + anchors.margins: Tokens.padding.large + spacing: Tokens.spacing.normal StyledText { Layout.fillWidth: true @@ -45,6 +43,7 @@ StyledRect { SplitButton { id: splitButton + enabled: root.enabled type: SplitButton.Filled diff --git a/components/controls/StyledInputField.qml b/components/controls/StyledInputField.qml index 0d199c738..ff7772cac 100644 --- a/components/controls/StyledInputField.qml +++ b/components/controls/StyledInputField.qml @@ -1,10 +1,9 @@ pragma ComponentBehavior: Bound -import ".." +import QtQuick +import Caelestia.Config import qs.components import qs.services -import qs.config -import QtQuick Item { id: root @@ -13,23 +12,23 @@ Item { 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 + implicitWidth: 70 + implicitHeight: inputField.implicitHeight + Tokens.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 + radius: Tokens.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 @@ -43,6 +42,7 @@ Item { MouseArea { id: inputHover + anchors.fill: parent hoverEnabled: true cursorShape: Qt.IBeamCursor @@ -52,20 +52,14 @@ Item { StyledTextField { id: inputField + anchors.centerIn: parent - width: parent.width - Appearance.padding.normal + width: parent.width - Tokens.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); @@ -74,6 +68,13 @@ Item { onEditingFinished: { root.editingFinished(); } + + Binding { + target: inputField + property: "text" + value: root.text + when: !inputField.activeFocus + } } } } diff --git a/components/controls/StyledRadioButton.qml b/components/controls/StyledRadioButton.qml index b72fc77f3..24c7d6e8b 100644 --- a/components/controls/StyledRadioButton.qml +++ b/components/controls/StyledRadioButton.qml @@ -1,13 +1,13 @@ -import qs.components -import qs.services -import qs.config import QtQuick import QtQuick.Templates +import Caelestia.Config +import qs.components +import qs.services RadioButton { id: root - font.pointSize: Appearance.font.size.smaller + font.pointSize: Tokens.font.size.smaller implicitWidth: implicitIndicatorWidth + implicitContentWidth + contentItem.anchors.leftMargin implicitHeight: Math.max(implicitIndicatorHeight, implicitContentHeight) @@ -17,20 +17,17 @@ RadioButton { implicitWidth: 20 implicitHeight: 20 - radius: Appearance.rounding.full + radius: Tokens.rounding.full color: "transparent" border.color: root.checked ? Colours.palette.m3primary : Colours.palette.m3onSurfaceVariant border.width: 2 anchors.verticalCenter: parent.verticalCenter StateLayer { - anchors.margins: -Appearance.padding.smaller + anchors.margins: -Tokens.padding.smaller color: root.checked ? Colours.palette.m3onSurface : Colours.palette.m3primary z: -1 - - function onClicked(): void { - root.click(); - } + onClicked: root.click() } StyledRect { @@ -38,7 +35,7 @@ RadioButton { implicitWidth: 8 implicitHeight: 8 - radius: Appearance.rounding.full + radius: Tokens.rounding.full color: Qt.alpha(Colours.palette.m3primary, root.checked ? 1 : 0) } @@ -52,6 +49,6 @@ RadioButton { font.pointSize: root.font.pointSize anchors.verticalCenter: parent.verticalCenter anchors.left: outerCircle.right - anchors.leftMargin: Appearance.spacing.smaller + anchors.leftMargin: Tokens.spacing.smaller } } diff --git a/components/controls/StyledScrollBar.qml b/components/controls/StyledScrollBar.qml index de8b679cd..8cfe3f8da 100644 --- a/components/controls/StyledScrollBar.qml +++ b/components/controls/StyledScrollBar.qml @@ -1,8 +1,8 @@ -import ".." -import qs.services -import qs.config import QtQuick import QtQuick.Templates +import Caelestia.Config +import qs.components +import qs.services ScrollBar { id: root @@ -11,6 +11,8 @@ ScrollBar { property bool shouldBeActive property real nonAnimPosition property bool animating + property bool _updatingFromFlickable: false + property bool _updatingFromUser: false onHoveredChanged: { if (hovered) @@ -19,9 +21,6 @@ ScrollBar { shouldBeActive = flickable.moving; } - property bool _updatingFromFlickable: false - property bool _updatingFromUser: false - // Sync nonAnimPosition with Qt's automatic position binding onPositionChanged: { if (_updatingFromUser) { @@ -37,24 +36,6 @@ ScrollBar { } } - // Sync nonAnimPosition with flickable when not animating - Connections { - target: flickable - function onContentYChanged() { - if (!animating && !fullMouse.pressed) { - _updatingFromFlickable = true; - const contentHeight = flickable.contentHeight; - const height = flickable.height; - if (contentHeight > height) { - nonAnimPosition = Math.max(0, Math.min(1, flickable.contentY / (contentHeight - height))); - } else { - nonAnimPosition = 0; - } - _updatingFromFlickable = false; - } - } - } - Component.onCompleted: { if (flickable) { const contentHeight = flickable.contentHeight; @@ -64,7 +45,7 @@ ScrollBar { } } } - implicitWidth: Appearance.padding.small + implicitWidth: Tokens.padding.small contentItem: StyledRect { anchors.left: parent.left @@ -80,7 +61,7 @@ ScrollBar { return 0.6; return 0; } - radius: Appearance.rounding.full + radius: Tokens.rounding.full color: Colours.palette.m3secondary MouseArea { @@ -97,15 +78,34 @@ ScrollBar { } } + // 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 { @@ -118,13 +118,14 @@ ScrollBar { CustomMouseArea { id: fullMouse - anchors.fill: parent - preventStealing: true - - onPressed: event => { + function onWheel(event: WheelEvent): void { root.animating = true; root._updatingFromUser = true; - const newPos = Math.max(0, Math.min(1 - root.size, event.y / root.height - root.size / 2)); + 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] @@ -140,7 +141,11 @@ ScrollBar { } } - onPositionChanged: event => { + 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; @@ -158,14 +163,9 @@ ScrollBar { } } - function onWheel(event: WheelEvent): void { - root.animating = true; + onPositionChanged: event => { 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); + 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] diff --git a/components/controls/StyledSlider.qml b/components/controls/StyledSlider.qml index 0ef229df2..f169691b4 100644 --- a/components/controls/StyledSlider.qml +++ b/components/controls/StyledSlider.qml @@ -1,8 +1,8 @@ -import qs.components -import qs.services -import qs.config import QtQuick import QtQuick.Templates +import Caelestia.Config +import qs.components +import qs.services Slider { id: root @@ -18,7 +18,7 @@ Slider { implicitWidth: root.handle.x - root.implicitHeight / 6 color: Colours.palette.m3primary - radius: Appearance.rounding.full + radius: Tokens.rounding.full topRightRadius: root.implicitHeight / 15 bottomRightRadius: root.implicitHeight / 15 } @@ -33,7 +33,7 @@ Slider { implicitWidth: parent.width - root.handle.x - root.handle.implicitWidth - root.implicitHeight / 6 color: Colours.palette.m3surfaceContainerHighest - radius: Appearance.rounding.full + radius: Tokens.rounding.full topLeftRadius: root.implicitHeight / 15 bottomLeftRadius: root.implicitHeight / 15 } @@ -46,7 +46,7 @@ Slider { implicitHeight: root.implicitHeight color: Colours.palette.m3primary - radius: Appearance.rounding.full + radius: Tokens.rounding.full MouseArea { anchors.fill: parent diff --git a/components/controls/StyledSwitch.qml b/components/controls/StyledSwitch.qml index ce93cd505..2e31adbff 100644 --- a/components/controls/StyledSwitch.qml +++ b/components/controls/StyledSwitch.qml @@ -1,9 +1,9 @@ -import ".." -import qs.services -import qs.config import QtQuick -import QtQuick.Templates import QtQuick.Shapes +import QtQuick.Templates +import Caelestia.Config +import qs.components +import qs.services Switch { id: root @@ -14,21 +14,21 @@ Switch { implicitHeight: implicitIndicatorHeight indicator: StyledRect { - radius: Appearance.rounding.full + radius: Tokens.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 + implicitHeight: Tokens.font.size.normal + Tokens.padding.smaller * 2 StyledRect { readonly property real nonAnimWidth: root.pressed ? implicitHeight * 1.3 : implicitHeight - radius: Appearance.rounding.full + radius: Tokens.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 + x: root.checked ? parent.implicitWidth - nonAnimWidth - Tokens.padding.small / 2 : Tokens.padding.small / 2 implicitWidth: nonAnimWidth - implicitHeight: parent.implicitHeight - Appearance.padding.small + implicitHeight: parent.implicitHeight - Tokens.padding.small anchors.verticalCenter: parent.verticalCenter StyledRect { @@ -83,15 +83,15 @@ Switch { anchors.centerIn: parent width: height - height: parent.implicitHeight - Appearance.padding.small * 2 + height: parent.implicitHeight - Tokens.padding.small * 2 preferredRendererType: Shape.CurveRenderer asynchronous: true ShapePath { - strokeWidth: Appearance.font.size.larger * 0.15 + strokeWidth: root.Tokens.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 + capStyle: root.Tokens.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap startX: icon.start1.x startY: icon.start1.y @@ -145,8 +145,7 @@ Switch { } component PropAnim: PropertyAnimation { - duration: Appearance.anim.durations.normal - easing.type: Easing.BezierSpline - easing.bezierCurve: Appearance.anim.curves.standard + duration: Tokens.anim.durations.normal + easing: Tokens.anim.standard } } diff --git a/components/controls/StyledTextField.qml b/components/controls/StyledTextField.qml index 60bcff259..4ab2f824c 100644 --- a/components/controls/StyledTextField.qml +++ b/components/controls/StyledTextField.qml @@ -1,18 +1,18 @@ pragma ComponentBehavior: Bound -import ".." -import qs.services -import qs.config import QtQuick import QtQuick.Controls +import Caelestia.Config +import qs.components +import qs.services TextField { id: root color: Colours.palette.m3onSurface placeholderTextColor: Colours.palette.m3outline - font.family: Appearance.font.family.sans - font.pointSize: Appearance.font.size.smaller + font.family: Tokens.font.family.sans + font.pointSize: Tokens.font.size.smaller renderType: echoMode === TextField.Password ? TextField.QtRendering : TextField.NativeRendering cursorVisible: !readOnly @@ -25,11 +25,9 @@ TextField { implicitWidth: 2 color: Colours.palette.m3primary - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal Connections { - target: root - function onCursorPositionChanged(): void { if (root.activeFocus && root.cursorVisible) { cursor.opacity = 1; @@ -37,6 +35,8 @@ TextField { enableBlink.restart(); } } + + target: root } Timer { @@ -61,7 +61,7 @@ TextField { Behavior on opacity { Anim { - duration: Appearance.anim.durations.small + type: Anim.StandardSmall } } } diff --git a/components/controls/SwitchRow.qml b/components/controls/SwitchRow.qml index 6dda3f0cc..b909739fe 100644 --- a/components/controls/SwitchRow.qml +++ b/components/controls/SwitchRow.qml @@ -1,22 +1,20 @@ -import ".." -import qs.components -import qs.components.effects -import qs.services -import qs.config import QtQuick import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.services StyledRect { id: root required property string label required property bool checked - property bool enabled: true - property var onToggled: function (checked) {} + + signal toggled(checked: bool) Layout.fillWidth: true - implicitHeight: row.implicitHeight + Appearance.padding.large * 2 - radius: Appearance.rounding.normal + implicitHeight: row.implicitHeight + Tokens.padding.large * 2 + radius: Tokens.rounding.normal color: Colours.layer(Colours.palette.m3surfaceContainer, 2) Behavior on implicitHeight { @@ -29,8 +27,8 @@ StyledRect { anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - anchors.margins: Appearance.padding.large - spacing: Appearance.spacing.normal + anchors.margins: Tokens.padding.large + spacing: Tokens.spacing.normal StyledText { Layout.fillWidth: true @@ -40,9 +38,7 @@ StyledRect { StyledSwitch { checked: root.checked enabled: root.enabled - onToggled: { - root.onToggled(checked); - } + onToggled: root.toggled(checked) } } } diff --git a/components/controls/TextButton.qml b/components/controls/TextButton.qml index ecf7eb133..94d76ff86 100644 --- a/components/controls/TextButton.qml +++ b/components/controls/TextButton.qml @@ -1,7 +1,7 @@ -import ".." -import qs.services -import qs.config import QtQuick +import Caelestia.Config +import qs.components +import qs.services StyledRect { id: root @@ -15,8 +15,8 @@ StyledRect { property alias text: label.text property bool checked property bool toggle - property real horizontalPadding: Appearance.padding.normal - property real verticalPadding: Appearance.padding.smaller + property real horizontalPadding: Tokens.padding.normal + property real verticalPadding: Tokens.padding.smaller property alias font: label.font property int type: TextButton.Filled @@ -47,7 +47,7 @@ StyledRect { onCheckedChanged: internalChecked = checked - radius: internalChecked ? Appearance.rounding.small : implicitHeight / 2 * Math.min(1, Appearance.rounding.scale) + radius: internalChecked ? Tokens.rounding.small : implicitHeight / 2 * Math.min(1, Tokens.rounding.scale) color: type === TextButton.Text ? "transparent" : internalChecked ? activeColour : inactiveColour implicitWidth: label.implicitWidth + horizontalPadding * 2 @@ -57,8 +57,7 @@ StyledRect { id: stateLayer color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour - - function onClicked(): void { + onClicked: { if (root.toggle) root.internalChecked = !root.internalChecked; root.clicked(); diff --git a/components/controls/ToggleButton.qml b/components/controls/ToggleButton.qml index 98c7564f2..232426c17 100644 --- a/components/controls/ToggleButton.qml +++ b/components/controls/ToggleButton.qml @@ -1,11 +1,11 @@ -import ".." +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Caelestia.Config import qs.components import qs.components.controls -import qs.components.effects import qs.services -import qs.config -import QtQuick -import QtQuick.Layouts StyledRect { id: root @@ -14,50 +14,45 @@ StyledRect { 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 real iconSize: Tokens.font.size.large + property real horizontalPadding: Tokens.padding.large + property real verticalPadding: Tokens.padding.normal property string tooltip: "" - property bool hovered: false + signal clicked - Component.onCompleted: { - hovered = toggleStateLayer.containsMouse; - } + Component.onCompleted: hovered = toggleStateLayer.containsMouse + + Layout.preferredWidth: implicitWidth + (toggleStateLayer.pressed ? Tokens.padding.normal * 2 : toggled ? Tokens.padding.small * 2 : 0) + implicitWidth: toggleBtnInner.implicitWidth + horizontalPadding * 2 + implicitHeight: toggleBtnIcon.implicitHeight + verticalPadding * 2 + radius: toggled || toggleStateLayer.pressed ? Tokens.rounding.small : Math.min(width, height) / 2 * Math.min(1, Tokens.rounding.scale) + color: toggled ? Colours.palette[`m3${accent.toLowerCase()}`] : Colours.palette[`m3${accent.toLowerCase()}Container`] Connections { - target: toggleStateLayer function onContainsMouseChanged() { const newHovered = toggleStateLayer.containsMouse; - if (hovered !== newHovered) { - hovered = newHovered; + if (root.hovered !== newHovered) { + root.hovered = newHovered; } } - } - - Layout.preferredWidth: implicitWidth + (toggleStateLayer.pressed ? Appearance.padding.normal * 2 : toggled ? Appearance.padding.small * 2 : 0) - implicitWidth: toggleBtnInner.implicitWidth + horizontalPadding * 2 - implicitHeight: toggleBtnIcon.implicitHeight + verticalPadding * 2 - radius: toggled || toggleStateLayer.pressed ? Appearance.rounding.small : Math.min(width, height) / 2 * Math.min(1, Appearance.rounding.scale) - color: toggled ? Colours.palette[`m3${accent.toLowerCase()}`] : Colours.palette[`m3${accent.toLowerCase()}Container`] + target: toggleStateLayer + } StateLayer { id: toggleStateLayer color: root.toggled ? Colours.palette[`m3on${root.accent}`] : Colours.palette[`m3on${root.accent}Container`] - - function onClicked(): void { - root.clicked(); - } + onClicked: root.clicked() } RowLayout { id: toggleBtnInner anchors.centerIn: parent - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal MaterialIcon { id: toggleBtnIcon @@ -74,6 +69,7 @@ StyledRect { } Loader { + asynchronous: true active: !!root.label visible: active @@ -86,21 +82,21 @@ StyledRect { Behavior on radius { Anim { - duration: Appearance.anim.durations.expressiveFastSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + type: Anim.FastSpatial } } Behavior on Layout.preferredWidth { Anim { - duration: Appearance.anim.durations.expressiveFastSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + type: Anim.FastSpatial } } // Tooltip - positioned absolutely, doesn't affect layout Loader { id: tooltipLoader + + asynchronous: true active: root.tooltip !== "" z: 10000 width: 0 diff --git a/components/controls/ToggleRow.qml b/components/controls/ToggleRow.qml index 269d3d6a5..beb6a15cb 100644 --- a/components/controls/ToggleRow.qml +++ b/components/controls/ToggleRow.qml @@ -1,9 +1,8 @@ -import qs.components -import qs.components.controls -import qs.services -import qs.config import QtQuick import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.components.controls RowLayout { id: root @@ -13,7 +12,7 @@ RowLayout { property alias toggle: toggle Layout.fillWidth: true - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal StyledText { Layout.fillWidth: true diff --git a/components/controls/Tooltip.qml b/components/controls/Tooltip.qml index b129a37b9..6c62f43d5 100644 --- a/components/controls/Tooltip.qml +++ b/components/controls/Tooltip.qml @@ -1,10 +1,9 @@ -import ".." -import qs.components.effects -import qs.services -import qs.config import QtQuick import QtQuick.Controls -import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.components.effects +import qs.services Popup { id: root @@ -24,55 +23,6 @@ Popup { onTriggered: root.tooltipVisible = false } - // Popup properties - doesn't affect layout - parent: { - let p = target; - // Walk up to find the root Item (usually has anchors.fill: parent) - while (p && p.parent) { - const parentItem = p.parent; - // Check if this looks like a root pane Item - if (parentItem && parentItem.anchors && parentItem.anchors.fill !== undefined) { - return parentItem; - } - p = parentItem; - } - // Fallback - return target.parent?.parent?.parent ?? target.parent?.parent ?? target.parent ?? target; - } - - visible: tooltipVisible - modal: false - closePolicy: Popup.NoAutoClose - padding: 0 - margins: 0 - background: Item {} - - // Update position when target moves or tooltip becomes visible - onTooltipVisibleChanged: { - if (tooltipVisible) { - Qt.callLater(updatePosition); - } - } - Connections { - target: root.target - function onXChanged() { - if (root.tooltipVisible) - root.updatePosition(); - } - function onYChanged() { - if (root.tooltipVisible) - root.updatePosition(); - } - function onWidthChanged() { - if (root.tooltipVisible) - root.updatePosition(); - } - function onHeightChanged() { - if (root.tooltipVisible) - root.updatePosition(); - } - } - function updatePosition() { if (!target || !parent) return; @@ -94,10 +44,10 @@ Popup { let newX = targetCenterX - tooltipWidth / 2; // Position tooltip above target - let newY = targetPos.y - tooltipHeight - Appearance.spacing.small; + let newY = targetPos.y - tooltipHeight - Tokens.spacing.small; // Keep within bounds - const padding = Appearance.padding.normal; + const padding = Tokens.padding.normal; if (newX < padding) { newX = padding; } else if (newX + tooltipWidth > (parent.width - padding)) { @@ -110,13 +60,47 @@ Popup { }); } + // 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 + type: Anim.FastSpatial } } @@ -125,37 +109,18 @@ Popup { property: "opacity" from: 1 to: 0 - duration: Appearance.anim.durations.expressiveFastSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial - } - } - - // Monitor hover state - Connections { - target: root.target - function onHoveredChanged() { - if (target.hovered) { - showTimer.start(); - if (timeout > 0) { - hideTimer.stop(); - hideTimer.start(); - } - } else { - showTimer.stop(); - hideTimer.stop(); - tooltipVisible = false; - } + type: Anim.FastSpatial } } contentItem: StyledRect { id: tooltipRect - implicitWidth: tooltipText.implicitWidth + Appearance.padding.normal * 2 - implicitHeight: tooltipText.implicitHeight + Appearance.padding.smaller * 2 + implicitWidth: tooltipText.implicitWidth + Tokens.padding.normal * 2 + implicitHeight: tooltipText.implicitHeight + Tokens.padding.smaller * 2 color: Colours.palette.m3surfaceContainerHighest - radius: Appearance.rounding.small + radius: Tokens.rounding.small antialiasing: true // Add elevation for depth @@ -173,13 +138,47 @@ Popup { text: root.text color: Colours.palette.m3onSurface - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small } } - Component.onCompleted: { - if (tooltipVisible) { - updatePosition(); + 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 } } diff --git a/components/effects/ColouredIcon.qml b/components/effects/ColouredIcon.qml index 5ef4d4cc0..570246bf0 100644 --- a/components/effects/ColouredIcon.qml +++ b/components/effects/ColouredIcon.qml @@ -1,8 +1,8 @@ pragma ComponentBehavior: Bound -import Caelestia -import Quickshell.Widgets import QtQuick +import Quickshell.Widgets +import Caelestia IconImage { id: root diff --git a/components/effects/Colouriser.qml b/components/effects/Colouriser.qml index 2948155d6..b8abebb14 100644 --- a/components/effects/Colouriser.qml +++ b/components/effects/Colouriser.qml @@ -1,6 +1,6 @@ -import ".." import QtQuick import QtQuick.Effects +import qs.components MultiEffect { property color sourceColor: "black" diff --git a/components/effects/Elevation.qml b/components/effects/Elevation.qml index fb29f16e8..9e0962dad 100644 --- a/components/effects/Elevation.qml +++ b/components/effects/Elevation.qml @@ -1,7 +1,7 @@ -import ".." -import qs.services import QtQuick import QtQuick.Effects +import qs.components +import qs.services RectangularShadow { property int level diff --git a/components/effects/InnerBorder.qml b/components/effects/InnerBorder.qml index d4a751f84..1b502e299 100644 --- a/components/effects/InnerBorder.qml +++ b/components/effects/InnerBorder.qml @@ -1,10 +1,10 @@ pragma ComponentBehavior: Bound -import ".." -import qs.services -import qs.config import QtQuick import QtQuick.Effects +import Caelestia.Config +import qs.components +import qs.services StyledRect { property alias innerRadius: maskInner.radius @@ -37,8 +37,8 @@ StyledRect { id: maskInner anchors.fill: parent - anchors.margins: Appearance.padding.normal - radius: Appearance.rounding.small + anchors.margins: Tokens.padding.normal + radius: Tokens.rounding.normal } } } diff --git a/components/effects/OpacityMask.qml b/components/effects/OpacityMask.qml index 22e424960..8e625034e 100644 --- a/components/effects/OpacityMask.qml +++ b/components/effects/OpacityMask.qml @@ -1,9 +1,9 @@ -import Quickshell import QtQuick +import Quickshell ShaderEffect { required property Item source required property Item maskSource - fragmentShader: Qt.resolvedUrl(`${Quickshell.shellDir}/assets/shaders/opacitymask.frag.qsb`) + fragmentShader: Quickshell.shellPath("assets/shaders/opacitymask.frag.qsb") } diff --git a/components/filedialog/CurrentItem.qml b/components/filedialog/CurrentItem.qml index bb87133c7..83cb4a922 100644 --- a/components/filedialog/CurrentItem.qml +++ b/components/filedialog/CurrentItem.qml @@ -1,16 +1,16 @@ -import ".." -import qs.services -import qs.config import QtQuick import QtQuick.Shapes +import Caelestia.Config +import qs.components +import qs.services 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 + implicitWidth: content.implicitWidth + Tokens.padding.larger + content.anchors.rightMargin + implicitHeight: currentItem ? content.implicitHeight + Tokens.padding.normal + content.anchors.bottomMargin : 0 Shape { preferredRendererType: Shape.CurveRenderer @@ -18,7 +18,7 @@ Item { ShapePath { id: path - readonly property real rounding: Appearance.rounding.small + readonly property real rounding: Tokens.rounding.small readonly property bool flatten: root.implicitHeight < rounding * 2 readonly property real roundingY: flatten ? root.implicitHeight / 2 : rounding @@ -76,16 +76,16 @@ Item { anchors.right: parent.right anchors.bottom: parent.bottom - anchors.rightMargin: Appearance.padding.larger - Appearance.padding.small - anchors.bottomMargin: Appearance.padding.normal - Appearance.padding.small + anchors.rightMargin: Tokens.padding.larger - Tokens.padding.small + anchors.bottomMargin: Tokens.padding.normal - Tokens.padding.small Connections { - target: root - function onCurrentItemChanged(): void { if (root.currentItem) content.text = qsTr(`"%1" selected`).arg(root.currentItem.modelData.name); } + + target: root } } } diff --git a/components/filedialog/DialogButtons.qml b/components/filedialog/DialogButtons.qml index bde9ac277..b46b4f721 100644 --- a/components/filedialog/DialogButtons.qml +++ b/components/filedialog/DialogButtons.qml @@ -1,7 +1,7 @@ -import ".." -import qs.services -import qs.config import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.services StyledRect { id: root @@ -9,7 +9,7 @@ StyledRect { required property var dialog required property FolderContents folder - implicitHeight: inner.implicitHeight + Appearance.padding.normal * 2 + implicitHeight: inner.implicitHeight + Tokens.padding.normal * 2 color: Colours.tPalette.m3surfaceContainer @@ -17,9 +17,9 @@ StyledRect { id: inner anchors.fill: parent - anchors.margins: Appearance.padding.normal + anchors.margins: Tokens.padding.normal - spacing: Appearance.spacing.small + spacing: Tokens.spacing.small StyledText { text: qsTr("Filter:") @@ -28,14 +28,14 @@ StyledRect { StyledRect { Layout.fillWidth: true Layout.fillHeight: true - Layout.rightMargin: Appearance.spacing.normal + Layout.rightMargin: Tokens.spacing.normal color: Colours.tPalette.m3surfaceContainerHigh - radius: Appearance.rounding.small + radius: Tokens.rounding.small StyledText { anchors.fill: parent - anchors.margins: Appearance.padding.normal + anchors.margins: Tokens.padding.normal text: `${root.dialog.filterLabel} (${root.dialog.filters.map(f => `*.${f}`).join(", ")})` } @@ -43,24 +43,21 @@ StyledRect { StyledRect { color: Colours.tPalette.m3surfaceContainerHigh - radius: Appearance.rounding.small + radius: Tokens.rounding.small - implicitWidth: cancelText.implicitWidth + Appearance.padding.normal * 2 - implicitHeight: cancelText.implicitHeight + Appearance.padding.normal * 2 + implicitWidth: cancelText.implicitWidth + Tokens.padding.normal * 2 + implicitHeight: cancelText.implicitHeight + Tokens.padding.normal * 2 StateLayer { disabled: !root.dialog.selectionValid - - function onClicked(): void { - root.dialog.accepted(root.folder.currentItem.modelData.path); - } + onClicked: root.dialog.accepted(root.folder.currentItem.modelData.path) } StyledText { id: selectText anchors.centerIn: parent - anchors.margins: Appearance.padding.normal + anchors.margins: Tokens.padding.normal text: qsTr("Select") color: root.dialog.selectionValid ? Colours.palette.m3onSurface : Colours.palette.m3outline @@ -69,13 +66,13 @@ StyledRect { StyledRect { color: Colours.tPalette.m3surfaceContainerHigh - radius: Appearance.rounding.small + radius: Tokens.rounding.small - implicitWidth: cancelText.implicitWidth + Appearance.padding.normal * 2 - implicitHeight: cancelText.implicitHeight + Appearance.padding.normal * 2 + implicitWidth: cancelText.implicitWidth + Tokens.padding.normal * 2 + implicitHeight: cancelText.implicitHeight + Tokens.padding.normal * 2 StateLayer { - function onClicked(): void { + onClicked: { root.dialog.rejected(); } } @@ -84,7 +81,7 @@ StyledRect { id: cancelText anchors.centerIn: parent - anchors.margins: Appearance.padding.normal + anchors.margins: Tokens.padding.normal text: qsTr("Cancel") } diff --git a/components/filedialog/FileDialog.qml b/components/filedialog/FileDialog.qml index f3187a55b..90dd2bc20 100644 --- a/components/filedialog/FileDialog.qml +++ b/components/filedialog/FileDialog.qml @@ -1,10 +1,10 @@ pragma ComponentBehavior: Bound -import qs.components -import qs.services -import Quickshell import QtQuick import QtQuick.Layouts +import Quickshell +import qs.components +import qs.services LazyLoader { id: loader diff --git a/components/filedialog/FolderContents.qml b/components/filedialog/FolderContents.qml index e16c7a15c..d8869b83f 100644 --- a/components/filedialog/FolderContents.qml +++ b/components/filedialog/FolderContents.qml @@ -1,22 +1,23 @@ pragma ComponentBehavior: Bound -import ".." -import "../controls" -import "../images" -import qs.services -import qs.config -import qs.utils -import Caelestia.Models -import Quickshell import QtQuick -import QtQuick.Layouts import QtQuick.Effects +import QtQuick.Layouts +import Quickshell +import Caelestia.Config +import Caelestia.Models +import qs.components +import qs.components.controls +import qs.components.filedialog +import qs.components.images +import qs.services +import qs.utils Item { id: root required property var dialog - property alias currentItem: view.currentItem + readonly property FileEntry currentItem: view.currentItem as FileEntry StyledRect { anchors.fill: parent @@ -41,12 +42,13 @@ Item { Rectangle { anchors.fill: parent - anchors.margins: Appearance.padding.small - radius: Appearance.rounding.small + anchors.margins: Tokens.padding.small + radius: Tokens.rounding.small } } Loader { + asynchronous: true anchors.centerIn: parent opacity: view.count === 0 ? 1 : 0 @@ -57,14 +59,14 @@ Item { Layout.alignment: Qt.AlignHCenter text: "scan_delete" color: Colours.palette.m3outline - font.pointSize: Appearance.font.size.extraLarge * 2 + font.pointSize: Tokens.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.pointSize: Tokens.font.size.large font.weight: 500 } } @@ -78,10 +80,10 @@ Item { id: view anchors.fill: parent - anchors.margins: Appearance.padding.small + Appearance.padding.normal + anchors.margins: Tokens.padding.small + Tokens.padding.normal - cellWidth: Sizes.itemWidth + Appearance.spacing.small - cellHeight: Sizes.itemWidth + Appearance.spacing.small * 2 + Appearance.padding.normal * 2 + 1 + cellWidth: Sizes.itemWidth + Tokens.spacing.small + cellHeight: Sizes.itemWidth + Tokens.spacing.small * 2 + Tokens.padding.normal * 2 + 1 clip: true focus: true @@ -90,11 +92,11 @@ Item { Keys.onReturnPressed: { if (root.dialog.selectionValid) - root.dialog.accepted(currentItem.modelData.path); + root.dialog.accepted((currentItem as FileEntry).modelData.path); } Keys.onEnterPressed: { if (root.dialog.selectionValid) - root.dialog.accepted(currentItem.modelData.path); + root.dialog.accepted((currentItem as FileEntry).modelData.path); } StyledScrollBar.vertical: StyledScrollBar { @@ -104,92 +106,21 @@ Item { model: FileSystemModel { path: { if (root.dialog.cwd[0] === "Home") - return `${Paths.home}/${root.dialog.cwd.slice(1).join("/")}`; + return Paths.home + `/${root.dialog.cwd.slice(1).join("/")}`; else return root.dialog.cwd.join("/"); } onPathChanged: view.currentIndex = -1 } - delegate: StyledRect { - id: item - - required property int index - required property FileSystemEntry modelData - - readonly property real nonAnimHeight: icon.implicitHeight + name.anchors.topMargin + name.implicitHeight + Appearance.padding.normal * 2 - - implicitWidth: Sizes.itemWidth - implicitHeight: nonAnimHeight - - radius: Appearance.rounding.normal - color: Qt.alpha(Colours.tPalette.m3surfaceContainerHighest, GridView.isCurrentItem ? Colours.tPalette.m3surfaceContainerHighest.a : 0) - z: GridView.isCurrentItem || implicitHeight !== nonAnimHeight ? 1 : 0 - clip: true - - StateLayer { - onDoubleClicked: { - if (item.modelData.isDir) - root.dialog.cwd.push(item.modelData.name); - else if (root.dialog.selectionValid) - root.dialog.accepted(item.modelData.path); - } - - function onClicked(): void { - view.currentIndex = item.index; - } - } - - CachingIconImage { - id: icon - - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: parent.top - anchors.topMargin: Appearance.padding.normal - - implicitSize: Sizes.itemWidth - Appearance.padding.normal * 2 - - Component.onCompleted: { - const file = item.modelData; - if (file.isImage) - source = Qt.resolvedUrl(file.path); - else if (!file.isDir) - source = Quickshell.iconPath(file.mimeType.replace("/", "-"), "application-x-zerosize"); - else if (root.dialog.cwd.length === 1 && ["Desktop", "Documents", "Downloads", "Music", "Pictures", "Public", "Templates", "Videos"].includes(file.name)) - source = Quickshell.iconPath(`folder-${file.name.toLowerCase()}`); - else - source = Quickshell.iconPath("inode-directory"); - } - } - - StyledText { - id: name - - anchors.left: parent.left - anchors.right: parent.right - anchors.top: icon.bottom - anchors.topMargin: Appearance.spacing.small - anchors.margins: Appearance.padding.normal - - horizontalAlignment: Text.AlignHCenter - elide: item.GridView.isCurrentItem ? Text.ElideNone : Text.ElideRight - wrapMode: item.GridView.isCurrentItem ? Text.WrapAtWordBoundaryOrAnywhere : Text.NoWrap - - Component.onCompleted: text = item.modelData.name - } - - Behavior on implicitHeight { - Anim {} - } - } + delegate: FileEntry {} add: Transition { Anim { properties: "opacity,scale" from: 0 to: 1 - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + type: Anim.DefaultSpatial } } @@ -208,12 +139,11 @@ Item { Anim { properties: "opacity,scale" to: 1 - easing.bezierCurve: Appearance.anim.curves.standardDecel + easing: Tokens.anim.standardDecel } Anim { properties: "x,y" - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + type: Anim.DefaultSpatial } } } @@ -221,8 +151,77 @@ Item { CurrentItem { anchors.right: parent.right anchors.bottom: parent.bottom - anchors.margins: Appearance.padding.small + anchors.margins: Tokens.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 + Tokens.padding.normal * 2 + + implicitWidth: Sizes.itemWidth + implicitHeight: nonAnimHeight + + radius: Tokens.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 { + onClicked: 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: Tokens.padding.normal + + implicitSize: Sizes.itemWidth - Tokens.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: Tokens.spacing.small + anchors.margins: Tokens.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 {} + } + } } diff --git a/components/filedialog/HeaderBar.qml b/components/filedialog/HeaderBar.qml index c9a3feb53..c53d8f765 100644 --- a/components/filedialog/HeaderBar.qml +++ b/components/filedialog/HeaderBar.qml @@ -1,18 +1,18 @@ pragma ComponentBehavior: Bound -import ".." -import qs.services -import qs.config import QtQuick import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.services StyledRect { id: root required property var dialog - implicitWidth: inner.implicitWidth + Appearance.padding.normal * 2 - implicitHeight: inner.implicitHeight + Appearance.padding.normal * 2 + implicitWidth: inner.implicitWidth + Tokens.padding.normal * 2 + implicitHeight: inner.implicitHeight + Tokens.padding.normal * 2 color: Colours.tPalette.m3surfaceContainer @@ -20,20 +20,17 @@ StyledRect { id: inner anchors.fill: parent - anchors.margins: Appearance.padding.normal - spacing: Appearance.spacing.small + anchors.margins: Tokens.padding.normal + spacing: Tokens.spacing.small Item { implicitWidth: implicitHeight - implicitHeight: upIcon.implicitHeight + Appearance.padding.small * 2 + implicitHeight: upIcon.implicitHeight + Tokens.padding.small * 2 StateLayer { - radius: Appearance.rounding.small + radius: Tokens.rounding.small disabled: root.dialog.cwd.length === 1 - - function onClicked(): void { - root.dialog.cwd.pop(); - } + onClicked: root.dialog.cwd.pop() } MaterialIcon { @@ -49,7 +46,7 @@ StyledRect { StyledRect { Layout.fillWidth: true - radius: Appearance.rounding.small + radius: Tokens.rounding.small color: Colours.tPalette.m3surfaceContainerHigh implicitHeight: pathComponents.implicitHeight + pathComponents.anchors.margins * 2 @@ -58,10 +55,10 @@ StyledRect { id: pathComponents anchors.fill: parent - anchors.margins: Appearance.padding.small / 2 + anchors.margins: Tokens.padding.small / 2 anchors.leftMargin: 0 - spacing: Appearance.spacing.small + spacing: Tokens.spacing.small Repeater { model: root.dialog.cwd @@ -75,7 +72,8 @@ StyledRect { spacing: 0 Loader { - Layout.rightMargin: Appearance.spacing.small + asynchronous: true + Layout.rightMargin: Tokens.spacing.small active: folder.index > 0 sourceComponent: StyledText { text: "/" @@ -85,27 +83,30 @@ StyledRect { } Item { - implicitWidth: homeIcon.implicitWidth + (homeIcon.active ? Appearance.padding.small : 0) + folderName.implicitWidth + Appearance.padding.normal * 2 - implicitHeight: folderName.implicitHeight + Appearance.padding.small * 2 + implicitWidth: homeIcon.implicitWidth + (homeIcon.active ? Tokens.padding.small : 0) + folderName.implicitWidth + Tokens.padding.normal * 2 + implicitHeight: folderName.implicitHeight + Tokens.padding.small * 2 Loader { + asynchronous: true anchors.fill: parent active: folder.index < root.dialog.cwd.length - 1 sourceComponent: StateLayer { - radius: Appearance.rounding.small - - function onClicked(): void { + onClicked: { root.dialog.cwd = root.dialog.cwd.slice(0, folder.index + 1); } + + radius: Tokens.rounding.small } } Loader { id: homeIcon + asynchronous: true + anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter - anchors.leftMargin: Appearance.padding.normal + anchors.leftMargin: Tokens.padding.normal active: folder.index === 0 && folder.modelData === "Home" sourceComponent: MaterialIcon { @@ -120,7 +121,7 @@ StyledRect { anchors.left: homeIcon.right anchors.verticalCenter: parent.verticalCenter - anchors.leftMargin: homeIcon.active ? Appearance.padding.small : 0 + anchors.leftMargin: homeIcon.active ? Tokens.padding.small : 0 text: folder.modelData color: folder.index < root.dialog.cwd.length - 1 ? Colours.palette.m3onSurfaceVariant : Colours.palette.m3onSurface diff --git a/components/filedialog/Sidebar.qml b/components/filedialog/Sidebar.qml index b55d7b379..e9c0918bf 100644 --- a/components/filedialog/Sidebar.qml +++ b/components/filedialog/Sidebar.qml @@ -1,10 +1,11 @@ pragma ComponentBehavior: Bound -import ".." -import qs.services -import qs.config import QtQuick import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.components.filedialog +import qs.services StyledRect { id: root @@ -12,7 +13,7 @@ StyledRect { required property var dialog implicitWidth: Sizes.sidebarWidth - implicitHeight: inner.implicitHeight + Appearance.padding.normal * 2 + implicitHeight: inner.implicitHeight + Tokens.padding.normal * 2 color: Colours.tPalette.m3surfaceContainer @@ -22,16 +23,16 @@ StyledRect { anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top - anchors.margins: Appearance.padding.normal - spacing: Appearance.spacing.small / 2 + anchors.margins: Tokens.padding.normal + spacing: Tokens.spacing.small / 2 StyledText { Layout.alignment: Qt.AlignHCenter - Layout.topMargin: Appearance.padding.small / 2 - Layout.bottomMargin: Appearance.spacing.normal + Layout.topMargin: Tokens.padding.small / 2 + Layout.bottomMargin: Tokens.spacing.normal text: qsTr("Files") color: Colours.palette.m3onSurface - font.pointSize: Appearance.font.size.larger + font.pointSize: Tokens.font.size.larger font.bold: true } @@ -45,15 +46,14 @@ StyledRect { readonly property bool selected: modelData === root.dialog.cwd[root.dialog.cwd.length - 1] Layout.fillWidth: true - implicitHeight: placeInner.implicitHeight + Appearance.padding.normal * 2 + implicitHeight: placeInner.implicitHeight + Tokens.padding.normal * 2 - radius: Appearance.rounding.full + radius: Tokens.rounding.full color: Qt.alpha(Colours.palette.m3secondaryContainer, selected ? 1 : 0) StateLayer { color: place.selected ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface - - function onClicked(): void { + onClicked: { if (place.modelData === "Home") root.dialog.cwd = ["Home"]; else @@ -65,11 +65,11 @@ StyledRect { id: placeInner anchors.fill: parent - anchors.margins: Appearance.padding.normal - anchors.leftMargin: Appearance.padding.large - anchors.rightMargin: Appearance.padding.large + anchors.margins: Tokens.padding.normal + anchors.leftMargin: Tokens.padding.large + anchors.rightMargin: Tokens.padding.large - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal MaterialIcon { text: { @@ -91,7 +91,7 @@ StyledRect { return "folder"; } color: place.selected ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large fill: place.selected ? 1 : 0 Behavior on fill { @@ -103,7 +103,7 @@ StyledRect { Layout.fillWidth: true text: place.modelData color: place.selected ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.font.size.normal elide: Text.ElideRight } } diff --git a/components/images/CachingIconImage.qml b/components/images/CachingIconImage.qml index 1acc6a181..001e95de4 100644 --- a/components/images/CachingIconImage.qml +++ b/components/images/CachingIconImage.qml @@ -1,13 +1,14 @@ pragma ComponentBehavior: Bound -import qs.utils -import Quickshell.Widgets import QtQuick +import Quickshell.Widgets +import qs.utils Item { id: root - readonly property int status: loader.item?.status ?? Image.Null + // 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 @@ -18,6 +19,7 @@ Item { Loader { id: loader + asynchronous: true anchors.fill: parent sourceComponent: root.source ? root.source.toString().startsWith("image://icon/") ? iconImage : cachingImage : null } diff --git a/components/images/CachingImage.qml b/components/images/CachingImage.qml index e8f957a7d..1e932dbad 100644 --- a/components/images/CachingImage.qml +++ b/components/images/CachingImage.qml @@ -1,28 +1,17 @@ -import qs.utils -import Caelestia.Internal -import Quickshell import QtQuick +import Quickshell +import Caelestia.Images Image { id: root - property alias path: manager.path + property string path asynchronous: true fillMode: Image.PreserveAspectCrop - - Connections { - target: QsWindow.window - - function onDevicePixelRatioChanged(): void { - manager.updateSource(); - } - } - - CachingImageManager { - id: manager - - item: root - cacheDir: Qt.resolvedUrl(Paths.imagecache) + source: IUtils.urlForPath(path, fillMode) + sourceSize: { + const dpr = (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1; + return Qt.size(width * dpr, height * dpr); } } diff --git a/components/misc/CustomShortcut.qml b/components/misc/CustomShortcut.qml index aa35ed8f5..9bbcf1f76 100644 --- a/components/misc/CustomShortcut.qml +++ b/components/misc/CustomShortcut.qml @@ -1,5 +1,7 @@ import Quickshell.Hyprland +// qmllint disable unresolved-type GlobalShortcut { + // qmllint enable unresolved-type appid: "caelestia" } diff --git a/components/widgets/ExtraIndicator.qml b/components/widgets/ExtraIndicator.qml index db73ea08f..d426eed88 100644 --- a/components/widgets/ExtraIndicator.qml +++ b/components/widgets/ExtraIndicator.qml @@ -1,20 +1,20 @@ -import ".." import "../effects" -import qs.services -import qs.config import QtQuick +import Caelestia.Config +import qs.components +import qs.services StyledRect { required property int extra anchors.right: parent.right - anchors.margins: Appearance.padding.normal + anchors.margins: Tokens.padding.normal color: Colours.palette.m3tertiary - radius: Appearance.rounding.small + radius: Tokens.rounding.small - implicitWidth: count.implicitWidth + Appearance.padding.normal * 2 - implicitHeight: count.implicitHeight + Appearance.padding.small * 2 + implicitWidth: count.implicitWidth + Tokens.padding.normal * 2 + implicitHeight: count.implicitHeight + Tokens.padding.small * 2 opacity: extra > 0 ? 1 : 0 scale: extra > 0 ? 1 : 0.5 @@ -38,14 +38,13 @@ StyledRect { Behavior on opacity { Anim { - duration: Appearance.anim.durations.expressiveFastSpatial + duration: Tokens.anim.durations.expressiveFastSpatial } } Behavior on scale { Anim { - duration: Appearance.anim.durations.expressiveFastSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + type: Anim.FastSpatial } } } diff --git a/config/Appearance.qml b/config/Appearance.qml deleted file mode 100644 index 241c21a78..000000000 --- a/config/Appearance.qml +++ /dev/null @@ -1,14 +0,0 @@ -pragma Singleton - -import Quickshell - -Singleton { - // Literally just here to shorten accessing stuff :woe: - // Also kinda so I can keep accessing it with `Appearance.xxx` instead of `Config.appearance.xxx` - readonly property AppearanceConfig.Rounding rounding: Config.appearance.rounding - readonly property AppearanceConfig.Spacing spacing: Config.appearance.spacing - readonly property AppearanceConfig.Padding padding: Config.appearance.padding - readonly property AppearanceConfig.FontStuff font: Config.appearance.font - readonly property AppearanceConfig.Anim anim: Config.appearance.anim - readonly property AppearanceConfig.Transparency transparency: Config.appearance.transparency -} diff --git a/config/AppearanceConfig.qml b/config/AppearanceConfig.qml deleted file mode 100644 index b25945b15..000000000 --- a/config/AppearanceConfig.qml +++ /dev/null @@ -1,92 +0,0 @@ -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 AnimCurves curves: AnimCurves {} - property AnimDurations durations: AnimDurations {} - } - - component Transparency: JsonObject { - property bool enabled: false - property real base: 0.85 - property real layers: 0.4 - } -} diff --git a/config/BackgroundConfig.qml b/config/BackgroundConfig.qml deleted file mode 100644 index b8a8ad92e..000000000 --- a/config/BackgroundConfig.qml +++ /dev/null @@ -1,36 +0,0 @@ -import Quickshell.Io - -JsonObject { - property bool enabled: true - property DesktopClock desktopClock: DesktopClock {} - property Visualiser visualiser: Visualiser {} - - component DesktopClock: JsonObject { - property bool enabled: false - property real scale: 1.0 - property string position: "bottom-right" - property bool invertColors: false - property DesktopClockBackground background: DesktopClockBackground {} - property DesktopClockShadow shadow: DesktopClockShadow {} - } - - component DesktopClockBackground: JsonObject { - property bool enabled: false - property real opacity: 0.7 - property bool blur: true - } - - component DesktopClockShadow: JsonObject { - property bool enabled: true - property real opacity: 0.7 - property real blur: 0.4 - } - - component Visualiser: JsonObject { - property bool enabled: false - property bool autoHide: true - property bool blur: false - property real rounding: 1 - property real spacing: 1 - } -} diff --git a/config/BarConfig.qml b/config/BarConfig.qml deleted file mode 100644 index cf33fd21d..000000000 --- a/config/BarConfig.qml +++ /dev/null @@ -1,117 +0,0 @@ -import Quickshell.Io - -JsonObject { - property bool persistent: true - property bool showOnHover: true - property int dragThreshold: 20 - property ScrollActions scrollActions: ScrollActions {} - property Popouts popouts: Popouts {} - property Workspaces workspaces: Workspaces {} - property ActiveWindow activeWindow: ActiveWindow {} - property Tray tray: Tray {} - property Status status: Status {} - property Clock clock: Clock {} - property Sizes sizes: Sizes {} - property list excludedScreens: [] - - property list entries: [ - { - id: "logo", - enabled: true - }, - { - id: "workspaces", - enabled: true - }, - { - id: "spacer", - enabled: true - }, - { - id: "activeWindow", - enabled: true - }, - { - id: "spacer", - enabled: true - }, - { - id: "tray", - enabled: true - }, - { - id: "clock", - enabled: true - }, - { - id: "statusIcons", - enabled: true - }, - { - id: "power", - enabled: true - } - ] - - component ScrollActions: JsonObject { - property bool workspaces: true - property bool volume: true - property bool brightness: true - } - - component Popouts: JsonObject { - property bool activeWindow: true - property bool tray: true - property bool statusIcons: true - } - - component Workspaces: JsonObject { - property int shown: 5 - property bool activeIndicator: true - property bool occupiedBg: false - property bool showWindows: true - property bool showWindowsOnSpecialWorkspaces: showWindows - property bool activeTrail: false - property bool perMonitorWorkspaces: true - property string label: " " // if empty, will show workspace name's first letter - property string occupiedLabel: "󰮯" - property string activeLabel: "󰮯" - property string capitalisation: "preserve" // upper, lower, or preserve - relevant only if label is empty - property list specialWorkspaceIcons: [] - } - - component ActiveWindow: JsonObject { - property bool inverted: false - } - - component Tray: JsonObject { - property bool background: false - property bool recolour: false - property bool compact: false - property list iconSubs: [] - } - - component Status: JsonObject { - property bool showAudio: false - property bool showMicrophone: false - property bool showKbLayout: false - property bool showNetwork: true - property bool showWifi: true - property bool showBluetooth: true - property bool showBattery: true - property bool showLockStatus: true - } - - component Clock: JsonObject { - property bool showIcon: true - } - - component Sizes: JsonObject { - property int innerWidth: 40 - property int windowPreviewSize: 400 - property int trayMenuWidth: 300 - property int batteryWidth: 250 - property int networkWidth: 320 - property int kbLayoutWidth: 320 - } -} diff --git a/config/BorderConfig.qml b/config/BorderConfig.qml deleted file mode 100644 index b15811fdd..000000000 --- a/config/BorderConfig.qml +++ /dev/null @@ -1,6 +0,0 @@ -import Quickshell.Io - -JsonObject { - property int thickness: Appearance.padding.normal - property int rounding: Appearance.rounding.large -} diff --git a/config/Config.qml b/config/Config.qml deleted file mode 100644 index b32ebda76..000000000 --- a/config/Config.qml +++ /dev/null @@ -1,502 +0,0 @@ -pragma Singleton - -import qs.utils -import Caelestia -import Quickshell -import Quickshell.Io -import QtQuick - -Singleton { - id: root - - property alias appearance: adapter.appearance - property alias general: adapter.general - property alias background: adapter.background - property alias bar: adapter.bar - property alias border: adapter.border - property alias dashboard: adapter.dashboard - property alias controlCenter: adapter.controlCenter - property alias launcher: adapter.launcher - property alias notifs: adapter.notifs - property alias osd: adapter.osd - property alias session: adapter.session - property alias winfo: adapter.winfo - property alias lock: adapter.lock - property alias utilities: adapter.utilities - property alias sidebar: adapter.sidebar - property alias services: adapter.services - property alias paths: adapter.paths - - // Public save function - call this to persist config changes - function save(): void { - saveTimer.restart(); - recentlySaved = true; - recentSaveCooldown.restart(); - } - - property bool recentlySaved: false - - ElapsedTimer { - id: timer - } - - Timer { - id: saveTimer - - interval: 500 - onTriggered: { - timer.restart(); - try { - // Parse current config to preserve structure and comments if possible - let config = {}; - try { - config = JSON.parse(fileView.text()); - } catch (e) { - // If parsing fails, start with empty object - config = {}; - } - - // Update config with current values - config = serializeConfig(); - - // Save to file with pretty printing - fileView.setText(JSON.stringify(config, null, 2)); - } catch (e) { - Toaster.toast(qsTr("Failed to serialize config"), e.message, "settings_alert", Toast.Error); - } - } - } - - Timer { - id: recentSaveCooldown - - interval: 2000 - onTriggered: { - recentlySaved = false; - } - } - - // Helper function to serialize the config object - function serializeConfig(): var { - return { - appearance: serializeAppearance(), - general: serializeGeneral(), - background: serializeBackground(), - bar: serializeBar(), - border: serializeBorder(), - dashboard: serializeDashboard(), - controlCenter: serializeControlCenter(), - launcher: serializeLauncher(), - notifs: serializeNotifs(), - osd: serializeOsd(), - session: serializeSession(), - winfo: serializeWinfo(), - lock: serializeLock(), - utilities: serializeUtilities(), - sidebar: serializeSidebar(), - services: serializeServices(), - paths: serializePaths() - }; - } - - function serializeAppearance(): var { - return { - rounding: { - scale: appearance.rounding.scale - }, - spacing: { - scale: appearance.spacing.scale - }, - padding: { - scale: appearance.padding.scale - }, - font: { - family: { - sans: appearance.font.family.sans, - mono: appearance.font.family.mono, - material: appearance.font.family.material, - clock: appearance.font.family.clock - }, - size: { - scale: appearance.font.size.scale - } - }, - anim: { - durations: { - scale: appearance.anim.durations.scale - } - }, - transparency: { - enabled: appearance.transparency.enabled, - base: appearance.transparency.base, - layers: appearance.transparency.layers - } - }; - } - - function serializeGeneral(): var { - return { - logo: general.logo, - apps: { - terminal: general.apps.terminal, - audio: general.apps.audio, - playback: general.apps.playback, - explorer: general.apps.explorer - }, - idle: { - lockBeforeSleep: general.idle.lockBeforeSleep, - inhibitWhenAudio: general.idle.inhibitWhenAudio, - timeouts: general.idle.timeouts - }, - battery: { - warnLevels: general.battery.warnLevels, - criticalLevel: general.battery.criticalLevel - } - }; - } - - function serializeBackground(): var { - return { - enabled: background.enabled, - desktopClock: { - enabled: background.desktopClock.enabled, - scale: background.desktopClock.scale, - position: background.desktopClock.position, - invertColors: background.desktopClock.invertColors, - background: { - enabled: background.desktopClock.background.enabled, - opacity: background.desktopClock.background.opacity, - blur: background.desktopClock.background.blur - }, - shadow: { - enabled: background.desktopClock.shadow.enabled, - opacity: background.desktopClock.shadow.opacity, - blur: background.desktopClock.shadow.blur - } - }, - visualiser: { - enabled: background.visualiser.enabled, - autoHide: background.visualiser.autoHide, - blur: background.visualiser.blur, - rounding: background.visualiser.rounding, - spacing: background.visualiser.spacing - } - }; - } - - function serializeBar(): var { - return { - persistent: bar.persistent, - showOnHover: bar.showOnHover, - dragThreshold: bar.dragThreshold, - scrollActions: { - workspaces: bar.scrollActions.workspaces, - volume: bar.scrollActions.volume, - brightness: bar.scrollActions.brightness - }, - popouts: { - activeWindow: bar.popouts.activeWindow, - tray: bar.popouts.tray, - statusIcons: bar.popouts.statusIcons - }, - workspaces: { - shown: bar.workspaces.shown, - activeIndicator: bar.workspaces.activeIndicator, - occupiedBg: bar.workspaces.occupiedBg, - showWindows: bar.workspaces.showWindows, - showWindowsOnSpecialWorkspaces: bar.workspaces.showWindowsOnSpecialWorkspaces, - activeTrail: bar.workspaces.activeTrail, - perMonitorWorkspaces: bar.workspaces.perMonitorWorkspaces, - label: bar.workspaces.label, - occupiedLabel: bar.workspaces.occupiedLabel, - activeLabel: bar.workspaces.activeLabel, - capitalisation: bar.workspaces.capitalisation, - specialWorkspaceIcons: bar.workspaces.specialWorkspaceIcons - }, - tray: { - background: bar.tray.background, - recolour: bar.tray.recolour, - compact: bar.tray.compact, - iconSubs: bar.tray.iconSubs - }, - status: { - showAudio: bar.status.showAudio, - showMicrophone: bar.status.showMicrophone, - showKbLayout: bar.status.showKbLayout, - showNetwork: bar.status.showNetwork, - showWifi: bar.status.showWifi, - showBluetooth: bar.status.showBluetooth, - showBattery: bar.status.showBattery, - showLockStatus: bar.status.showLockStatus - }, - clock: { - showIcon: bar.clock.showIcon - }, - sizes: { - innerWidth: bar.sizes.innerWidth, - windowPreviewSize: bar.sizes.windowPreviewSize, - trayMenuWidth: bar.sizes.trayMenuWidth, - batteryWidth: bar.sizes.batteryWidth, - networkWidth: bar.sizes.networkWidth - }, - entries: bar.entries - }; - } - - function serializeBorder(): var { - return { - thickness: border.thickness, - rounding: border.rounding - }; - } - - function serializeDashboard(): var { - return { - enabled: dashboard.enabled, - showOnHover: dashboard.showOnHover, - mediaUpdateInterval: dashboard.mediaUpdateInterval, - dragThreshold: dashboard.dragThreshold, - sizes: { - tabIndicatorHeight: dashboard.sizes.tabIndicatorHeight, - tabIndicatorSpacing: dashboard.sizes.tabIndicatorSpacing, - infoWidth: dashboard.sizes.infoWidth, - infoIconSize: dashboard.sizes.infoIconSize, - dateTimeWidth: dashboard.sizes.dateTimeWidth, - mediaWidth: dashboard.sizes.mediaWidth, - mediaProgressSweep: dashboard.sizes.mediaProgressSweep, - mediaProgressThickness: dashboard.sizes.mediaProgressThickness, - resourceProgessThickness: dashboard.sizes.resourceProgessThickness, - weatherWidth: dashboard.sizes.weatherWidth, - mediaCoverArtSize: dashboard.sizes.mediaCoverArtSize, - mediaVisualiserSize: dashboard.sizes.mediaVisualiserSize, - resourceSize: dashboard.sizes.resourceSize - } - }; - } - - function serializeControlCenter(): var { - return { - sizes: { - heightMult: controlCenter.sizes.heightMult, - ratio: controlCenter.sizes.ratio - } - }; - } - - function serializeLauncher(): var { - return { - enabled: launcher.enabled, - showOnHover: launcher.showOnHover, - maxShown: launcher.maxShown, - maxWallpapers: launcher.maxWallpapers, - specialPrefix: launcher.specialPrefix, - actionPrefix: launcher.actionPrefix, - enableDangerousActions: launcher.enableDangerousActions, - dragThreshold: launcher.dragThreshold, - vimKeybinds: launcher.vimKeybinds, - hiddenApps: launcher.hiddenApps, - useFuzzy: { - apps: launcher.useFuzzy.apps, - actions: launcher.useFuzzy.actions, - schemes: launcher.useFuzzy.schemes, - variants: launcher.useFuzzy.variants, - wallpapers: launcher.useFuzzy.wallpapers - }, - sizes: { - itemWidth: launcher.sizes.itemWidth, - itemHeight: launcher.sizes.itemHeight, - wallpaperWidth: launcher.sizes.wallpaperWidth, - wallpaperHeight: launcher.sizes.wallpaperHeight - }, - actions: launcher.actions - }; - } - - function serializeNotifs(): var { - return { - expire: notifs.expire, - defaultExpireTimeout: notifs.defaultExpireTimeout, - clearThreshold: notifs.clearThreshold, - expandThreshold: notifs.expandThreshold, - actionOnClick: notifs.actionOnClick, - groupPreviewNum: notifs.groupPreviewNum, - sizes: { - width: notifs.sizes.width, - image: notifs.sizes.image, - badge: notifs.sizes.badge - } - }; - } - - function serializeOsd(): var { - return { - enabled: osd.enabled, - hideDelay: osd.hideDelay, - enableBrightness: osd.enableBrightness, - enableMicrophone: osd.enableMicrophone, - sizes: { - sliderWidth: osd.sizes.sliderWidth, - sliderHeight: osd.sizes.sliderHeight - } - }; - } - - function serializeSession(): var { - return { - enabled: session.enabled, - dragThreshold: session.dragThreshold, - vimKeybinds: session.vimKeybinds, - commands: { - logout: session.commands.logout, - shutdown: session.commands.shutdown, - hibernate: session.commands.hibernate, - reboot: session.commands.reboot - }, - sizes: { - button: session.sizes.button - } - }; - } - - function serializeWinfo(): var { - return { - sizes: { - heightMult: winfo.sizes.heightMult, - detailsWidth: winfo.sizes.detailsWidth - } - }; - } - - function serializeLock(): var { - return { - recolourLogo: lock.recolourLogo, - enableFprint: lock.enableFprint, - maxFprintTries: lock.maxFprintTries, - sizes: { - heightMult: lock.sizes.heightMult, - ratio: lock.sizes.ratio, - centerWidth: lock.sizes.centerWidth - } - }; - } - - function serializeUtilities(): var { - return { - enabled: utilities.enabled, - maxToasts: utilities.maxToasts, - sizes: { - width: utilities.sizes.width, - toastWidth: utilities.sizes.toastWidth - }, - toasts: { - configLoaded: utilities.toasts.configLoaded, - chargingChanged: utilities.toasts.chargingChanged, - gameModeChanged: utilities.toasts.gameModeChanged, - dndChanged: utilities.toasts.dndChanged, - audioOutputChanged: utilities.toasts.audioOutputChanged, - audioInputChanged: utilities.toasts.audioInputChanged, - capsLockChanged: utilities.toasts.capsLockChanged, - numLockChanged: utilities.toasts.numLockChanged, - kbLayoutChanged: utilities.toasts.kbLayoutChanged, - vpnChanged: utilities.toasts.vpnChanged, - nowPlaying: utilities.toasts.nowPlaying - }, - vpn: { - enabled: utilities.vpn.enabled, - provider: utilities.vpn.provider - } - }; - } - - function serializeSidebar(): var { - return { - enabled: sidebar.enabled, - dragThreshold: sidebar.dragThreshold, - sizes: { - width: sidebar.sizes.width - } - }; - } - - function serializeServices(): var { - return { - weatherLocation: services.weatherLocation, - useFahrenheit: services.useFahrenheit, - useTwelveHourClock: services.useTwelveHourClock, - gpuType: services.gpuType, - visualiserBars: services.visualiserBars, - audioIncrement: services.audioIncrement, - brightnessIncrement: services.brightnessIncrement, - maxVolume: services.maxVolume, - smartScheme: services.smartScheme, - defaultPlayer: services.defaultPlayer, - playerAliases: services.playerAliases - }; - } - - function serializePaths(): var { - return { - wallpaperDir: paths.wallpaperDir, - sessionGif: paths.sessionGif, - mediaGif: paths.mediaGif - }; - } - - FileView { - id: fileView - - path: `${Paths.config}/shell.json` - watchChanges: true - onFileChanged: { - // Prevent reload loop - don't reload if we just saved - if (!recentlySaved) { - timer.restart(); - reload(); - } else { - // Self-initiated save - reload without toast - reload(); - } - } - onLoaded: { - try { - JSON.parse(text()); - const elapsed = timer.elapsedMs(); - // Only show toast for external changes (not our own saves) and when elapsed time is meaningful - if (adapter.utilities.toasts.configLoaded && !recentlySaved && elapsed > 0) { - Toaster.toast(qsTr("Config loaded"), qsTr("Config loaded in %1ms").arg(elapsed), "rule_settings"); - } else if (adapter.utilities.toasts.configLoaded && recentlySaved && elapsed > 0) { - Toaster.toast(qsTr("Config saved"), qsTr("Config reloaded in %1ms").arg(elapsed), "rule_settings"); - } - } catch (e) { - Toaster.toast(qsTr("Failed to load config"), e.message, "settings_alert", Toast.Error); - } - } - onLoadFailed: err => { - if (err !== FileViewError.FileNotFound) - Toaster.toast(qsTr("Failed to read config file"), FileViewError.toString(err), "settings_alert", Toast.Warning); - } - onSaveFailed: err => Toaster.toast(qsTr("Failed to save config"), FileViewError.toString(err), "settings_alert", Toast.Error) - - JsonAdapter { - id: adapter - - property AppearanceConfig appearance: AppearanceConfig {} - property GeneralConfig general: GeneralConfig {} - property BackgroundConfig background: BackgroundConfig {} - property BarConfig bar: BarConfig {} - property BorderConfig border: BorderConfig {} - property DashboardConfig dashboard: DashboardConfig {} - property ControlCenterConfig controlCenter: ControlCenterConfig {} - property LauncherConfig launcher: LauncherConfig {} - property NotifsConfig notifs: NotifsConfig {} - property OsdConfig osd: OsdConfig {} - property SessionConfig session: SessionConfig {} - property WInfoConfig winfo: WInfoConfig {} - property LockConfig lock: LockConfig {} - property UtilitiesConfig utilities: UtilitiesConfig {} - property SidebarConfig sidebar: SidebarConfig {} - property ServiceConfig services: ServiceConfig {} - property UserPaths paths: UserPaths {} - } - } -} diff --git a/config/ControlCenterConfig.qml b/config/ControlCenterConfig.qml deleted file mode 100644 index a5889491d..000000000 --- a/config/ControlCenterConfig.qml +++ /dev/null @@ -1,10 +0,0 @@ -import Quickshell.Io - -JsonObject { - property Sizes sizes: Sizes {} - - component Sizes: JsonObject { - property real heightMult: 0.7 - property real ratio: 16 / 9 - } -} diff --git a/config/DashboardConfig.qml b/config/DashboardConfig.qml deleted file mode 100644 index 030292b14..000000000 --- a/config/DashboardConfig.qml +++ /dev/null @@ -1,25 +0,0 @@ -import Quickshell.Io - -JsonObject { - property bool enabled: true - property bool showOnHover: true - property int mediaUpdateInterval: 500 - property int dragThreshold: 50 - property Sizes sizes: Sizes {} - - component Sizes: JsonObject { - readonly property int tabIndicatorHeight: 3 - readonly property int tabIndicatorSpacing: 5 - readonly property int infoWidth: 200 - readonly property int infoIconSize: 25 - readonly property int dateTimeWidth: 110 - readonly property int mediaWidth: 200 - readonly property int mediaProgressSweep: 180 - readonly property int mediaProgressThickness: 8 - readonly property int resourceProgessThickness: 10 - readonly property int weatherWidth: 250 - readonly property int mediaCoverArtSize: 150 - readonly property int mediaVisualiserSize: 80 - readonly property int resourceSize: 200 - } -} diff --git a/config/GeneralConfig.qml b/config/GeneralConfig.qml deleted file mode 100644 index 52ef0de3e..000000000 --- a/config/GeneralConfig.qml +++ /dev/null @@ -1,60 +0,0 @@ -import Quickshell.Io - -JsonObject { - property string logo: "" - property Apps apps: Apps {} - property Idle idle: Idle {} - property Battery battery: Battery {} - - component Apps: JsonObject { - property list terminal: ["foot"] - property list audio: ["pavucontrol"] - property list playback: ["mpv"] - property list explorer: ["thunar"] - } - - component Idle: JsonObject { - property bool lockBeforeSleep: true - property bool inhibitWhenAudio: true - property list timeouts: [ - { - timeout: 180, - idleAction: "lock" - }, - { - timeout: 300, - idleAction: "dpms off", - returnAction: "dpms on" - }, - { - timeout: 600, - idleAction: ["systemctl", "suspend-then-hibernate"] - } - ] - } - - component Battery: JsonObject { - property list warnLevels: [ - { - level: 20, - title: qsTr("Low battery"), - message: qsTr("You might want to plug in a charger"), - icon: "battery_android_frame_2" - }, - { - level: 10, - title: qsTr("Did you see the previous message?"), - message: qsTr("You should probably plug in a charger now"), - icon: "battery_android_frame_1" - }, - { - level: 5, - title: qsTr("Critical battery level"), - message: qsTr("PLUG THE CHARGER RIGHT NOW!!"), - icon: "battery_android_alert", - critical: true - }, - ] - property int criticalLevel: 3 - } -} diff --git a/config/LauncherConfig.qml b/config/LauncherConfig.qml deleted file mode 100644 index 7f9c78812..000000000 --- a/config/LauncherConfig.qml +++ /dev/null @@ -1,146 +0,0 @@ -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 hiddenApps: [] - property UseFuzzy useFuzzy: UseFuzzy {} - property Sizes sizes: Sizes {} - - component UseFuzzy: JsonObject { - property bool apps: false - property bool actions: false - property bool schemes: false - property bool variants: false - property bool wallpapers: false - } - - component Sizes: JsonObject { - property int itemWidth: 600 - property int itemHeight: 57 - property int wallpaperWidth: 280 - property int wallpaperHeight: 200 - } - - property list actions: [ - { - name: "Calculator", - icon: "calculate", - description: "Do simple math equations (powered by Qalc)", - command: ["autocomplete", "calc"], - enabled: true, - dangerous: false - }, - { - name: "Scheme", - icon: "palette", - description: "Change the current colour scheme", - command: ["autocomplete", "scheme"], - enabled: true, - dangerous: false - }, - { - name: "Wallpaper", - icon: "image", - description: "Change the current wallpaper", - command: ["autocomplete", "wallpaper"], - enabled: true, - dangerous: false - }, - { - name: "Variant", - icon: "colors", - description: "Change the current scheme variant", - command: ["autocomplete", "variant"], - enabled: true, - dangerous: false - }, - { - name: "Transparency", - icon: "opacity", - description: "Change shell transparency", - command: ["autocomplete", "transparency"], - enabled: false, - dangerous: false - }, - { - name: "Random", - icon: "casino", - description: "Switch to a random wallpaper", - command: ["caelestia", "wallpaper", "-r"], - enabled: true, - dangerous: false - }, - { - name: "Light", - icon: "light_mode", - description: "Change the scheme to light mode", - command: ["setMode", "light"], - enabled: true, - dangerous: false - }, - { - name: "Dark", - icon: "dark_mode", - description: "Change the scheme to dark mode", - command: ["setMode", "dark"], - enabled: true, - dangerous: false - }, - { - name: "Shutdown", - icon: "power_settings_new", - description: "Shutdown the system", - command: ["systemctl", "poweroff"], - enabled: true, - dangerous: true - }, - { - name: "Reboot", - icon: "cached", - description: "Reboot the system", - command: ["systemctl", "reboot"], - enabled: true, - dangerous: true - }, - { - name: "Logout", - icon: "exit_to_app", - description: "Log out of the current session", - command: ["loginctl", "terminate-user", ""], - enabled: true, - dangerous: true - }, - { - name: "Lock", - icon: "lock", - description: "Lock the current session", - command: ["loginctl", "lock-session"], - enabled: true, - dangerous: false - }, - { - name: "Sleep", - icon: "bedtime", - description: "Suspend then hibernate", - command: ["systemctl", "suspend-then-hibernate"], - enabled: true, - dangerous: false - }, - { - name: "Settings", - icon: "settings", - description: "Configure the shell", - command: ["caelestia", "shell", "controlCenter", "open"], - enabled: true, - dangerous: false - } - ] -} diff --git a/config/LockConfig.qml b/config/LockConfig.qml deleted file mode 100644 index 2af4e2cd5..000000000 --- a/config/LockConfig.qml +++ /dev/null @@ -1,14 +0,0 @@ -import Quickshell.Io - -JsonObject { - property bool recolourLogo: false - property bool enableFprint: true - property int maxFprintTries: 3 - property Sizes sizes: Sizes {} - - component Sizes: JsonObject { - property real heightMult: 0.7 - property real ratio: 16 / 9 - property int centerWidth: 600 - } -} diff --git a/config/NotifsConfig.qml b/config/NotifsConfig.qml deleted file mode 100644 index fa2db494e..000000000 --- a/config/NotifsConfig.qml +++ /dev/null @@ -1,18 +0,0 @@ -import Quickshell.Io - -JsonObject { - property bool expire: true - property int defaultExpireTimeout: 5000 - property real clearThreshold: 0.3 - property int expandThreshold: 20 - property bool actionOnClick: false - property int groupPreviewNum: 3 - property bool openExpanded: false // Show the notifichation in expanded state when opening - property Sizes sizes: Sizes {} - - component Sizes: JsonObject { - property int width: 400 - property int image: 41 - property int badge: 20 - } -} diff --git a/config/OsdConfig.qml b/config/OsdConfig.qml deleted file mode 100644 index 543fc41e5..000000000 --- a/config/OsdConfig.qml +++ /dev/null @@ -1,14 +0,0 @@ -import Quickshell.Io - -JsonObject { - property bool enabled: true - property int hideDelay: 2000 - property bool enableBrightness: true - property bool enableMicrophone: false - property Sizes sizes: Sizes {} - - component Sizes: JsonObject { - property int sliderWidth: 30 - property int sliderHeight: 150 - } -} diff --git a/config/ServiceConfig.qml b/config/ServiceConfig.qml deleted file mode 100644 index d083b7a11..000000000 --- a/config/ServiceConfig.qml +++ /dev/null @@ -1,21 +0,0 @@ -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 useTwelveHourClock: Qt.locale().timeFormat(Locale.ShortFormat).toLowerCase().includes("a") - property string gpuType: "" - property int visualiserBars: 45 - property real audioIncrement: 0.1 - property real brightnessIncrement: 0.1 - property real maxVolume: 1.0 - property bool smartScheme: true - property string defaultPlayer: "Spotify" - property list playerAliases: [ - { - "from": "com.github.th_ch.youtube_music", - "to": "YT Music" - } - ] -} diff --git a/config/SessionConfig.qml b/config/SessionConfig.qml deleted file mode 100644 index f65ec6d84..000000000 --- a/config/SessionConfig.qml +++ /dev/null @@ -1,21 +0,0 @@ -import Quickshell.Io - -JsonObject { - property bool enabled: true - property int dragThreshold: 30 - property bool vimKeybinds: false - property Commands commands: Commands {} - - property Sizes sizes: Sizes {} - - component Commands: JsonObject { - property list logout: ["loginctl", "terminate-user", ""] - property list shutdown: ["systemctl", "poweroff"] - property list hibernate: ["systemctl", "hibernate"] - property list reboot: ["systemctl", "reboot"] - } - - component Sizes: JsonObject { - property int button: 80 - } -} diff --git a/config/SidebarConfig.qml b/config/SidebarConfig.qml deleted file mode 100644 index a871562b9..000000000 --- a/config/SidebarConfig.qml +++ /dev/null @@ -1,11 +0,0 @@ -import Quickshell.Io - -JsonObject { - property bool enabled: true - property int dragThreshold: 80 - property Sizes sizes: Sizes {} - - component Sizes: JsonObject { - property int width: 430 - } -} diff --git a/config/UserPaths.qml b/config/UserPaths.qml deleted file mode 100644 index f8de26782..000000000 --- a/config/UserPaths.qml +++ /dev/null @@ -1,8 +0,0 @@ -import qs.utils -import Quickshell.Io - -JsonObject { - property string wallpaperDir: `${Paths.pictures}/Wallpapers` - property string sessionGif: "root:/assets/kurukuru.gif" - property string mediaGif: "root:/assets/bongocat.gif" -} diff --git a/config/UtilitiesConfig.qml b/config/UtilitiesConfig.qml deleted file mode 100644 index cf4644602..000000000 --- a/config/UtilitiesConfig.qml +++ /dev/null @@ -1,35 +0,0 @@ -import Quickshell.Io - -JsonObject { - property bool enabled: true - property int maxToasts: 4 - - property Sizes sizes: Sizes {} - property Toasts toasts: Toasts {} - property Vpn vpn: Vpn {} - - component Sizes: JsonObject { - property int width: 430 - property int toastWidth: 430 - } - - component Toasts: JsonObject { - property bool configLoaded: true - property bool chargingChanged: true - property bool gameModeChanged: true - property bool dndChanged: true - property bool audioOutputChanged: true - property bool audioInputChanged: true - property bool capsLockChanged: true - property bool numLockChanged: true - property bool kbLayoutChanged: true - property bool kbLimit: true - property bool vpnChanged: true - property bool nowPlaying: false - } - - component Vpn: JsonObject { - property bool enabled: false - property list provider: ["netbird"] - } -} diff --git a/config/WInfoConfig.qml b/config/WInfoConfig.qml deleted file mode 100644 index 502578075..000000000 --- a/config/WInfoConfig.qml +++ /dev/null @@ -1,10 +0,0 @@ -import Quickshell.Io - -JsonObject { - property Sizes sizes: Sizes {} - - component Sizes: JsonObject { - property real heightMult: 0.7 - property real detailsWidth: 500 - } -} diff --git a/extras/version.cpp b/extras/version.cpp index e1a0cf302..d63434170 100644 --- a/extras/version.cpp +++ b/extras/version.cpp @@ -10,7 +10,8 @@ int main(int argc, char* argv[]) { std::cout << GIT_REVISION << std::endl; std::cout << DISTRIBUTOR << std::endl; } else if (arg == "-s" || arg == "--short") { - std::cout << PROJECT_NAME << " " << VERSION << ", revision " << GIT_REVISION << ", distrubuted by: " << DISTRIBUTOR << std::endl; + 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"; diff --git a/flake.lock b/flake.lock index 5b7fbd218..0721599d2 100644 --- a/flake.lock +++ b/flake.lock @@ -8,11 +8,11 @@ ] }, "locked": { - "lastModified": 1769226332, - "narHash": "sha256-JKD9M2+/J4e6nRtcY2XRfpLlOHaGXT4aUHyIG/20qlw=", + "lastModified": 1778644647, + "narHash": "sha256-iQIu4b5by8B3qKeigungSIgRoJg9HS1NiIZwqIbCGYk=", "owner": "caelestia-dots", "repo": "cli", - "rev": "52a3a3c50ef55e3561057e8a74c85cf16f83039f", + "rev": "2ce6213698d1cfa15b4b067d35c3cda634f443dd", "type": "github" }, "original": { @@ -23,11 +23,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1769018530, - "narHash": "sha256-MJ27Cy2NtBEV5tsK+YraYr2g851f3Fl1LpNHDzDX15c=", + "lastModified": 1778869304, + "narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=", "owner": "nixos", "repo": "nixpkgs", - "rev": "88d3861acdd3d2f0e361767018218e51810df8a1", + "rev": "d233902339c02a9c334e7e593de68855ad26c4cb", "type": "github" }, "original": { @@ -44,11 +44,11 @@ ] }, "locked": { - "lastModified": 1768985439, - "narHash": "sha256-qkU4r+l+UPz4dutMMRZSin64HuVZkEv9iFpu9yMWVY0=", + "lastModified": 1778488696, + "narHash": "sha256-QSWgYuZUCNUJ/cxmaq83WkcT7lHQDDfsPVgH+96kIl0=", "ref": "refs/heads/master", - "rev": "191085a8821b35680bba16ce5411fc9dbe912237", - "revCount": 731, + "rev": "7d1c9a9c6721606b129829134d6f614f015621e2", + "revCount": 818, "type": "git", "url": "https://git.outfoxxed.me/outfoxxed/quickshell" }, diff --git a/flake.nix b/flake.nix index 5c884115d..0d7ebb8c2 100644 --- a/flake.nix +++ b/flake.nix @@ -36,7 +36,6 @@ withX11 = false; withI3 = false; }; - app2unit = pkgs.callPackage ./nix/app2unit.nix {inherit pkgs;}; caelestia-cli = inputs.caelestia-cli.packages.${pkgs.stdenv.hostPlatform.system}.default; }; with-cli = caelestia-shell.override {withCli = true;}; diff --git a/last-50-commits.txt b/last-50-commits.txt new file mode 100644 index 000000000..f19ac693c --- /dev/null +++ b/last-50-commits.txt @@ -0,0 +1,50 @@ +8686ac04 fix: update imports and improve layout properties in MonitorIdentifier and Picker components +bf2d52d6 Improve shell panels, monitors, and recording +b3cebc2f [CI] chore: update flake +f1c69bd1 [CI] chore: update flake +7c981749 fix: lock screencopy not working +1b8df3da chore: set crash report url to ours +5c6db1a2 chore: add crash issue template +d9957810 fix: add Global to app properties (#1447) +8934df23 [CI] chore: update flake +8f8c8aaa ci: update action versions +c1d0fd97 [CI] chore: update flake +dc5fc0ca ci: fix update flake workflow +b19fc142 fix: allow non-ascii chars in passwords +d8488a1b feat: add showOverFullscreen option + disable by default +1e0138af fix: close detached popout on fs +cd386820 fix: allow interacting with notifs and osd in fs +d4b5814a feat: display on overlay layer only when fs +6d2598bc fix: slight border showing when fullscreen +5ff6ac61 feat: calculate size from source when single dim requested +178df6f0 fix: handle hidpi for rest of sourceSize uses +7a46c13e fix: handle hidpi properly +ec5c3d57 chore: remove old CachingImageManager +1ab02bcb fix: initial 0 size on launcher wallpaper item +6dfe017c feat: split caching into separate service +4a9ceb48 fix: use original image if requested size is invalid +ebaa9c70 fix: specify sourceSize as a single prop +84a90271 fix: use QSaveFile for atomic writes +66befc1a feat: use image provider for caching +d91a5164 fix: blob exclusion being ignored at corner blends +4bd8dce9 feat: always expire notifs if fullscreen +9e267e17 fix: interaction blocking at edges when fullscreen +0e005f78 feat: use default env + drop expensive fonts +c7847f12 fix: unstretch kuru +84b56dce fix: control center outer rounding +88b8b7e4 chore: add deformScale to example config +d66f633f fix: clamp blob radii at half min dimension +f4067bcb feat: add deform scale config option +886b5ed7 fix: don't cache icons + fix bg colour when transparent image +601b0177 feat: improve sidebar notif expand anim +aedb8551 fix: sidebar notif group height anim +0d04de66 ci: update stable branch on release (#1414) +4bbbf7f7 fix: wrap media position by length (#1410) +7dc4cdbe fix: state layer ripple +0b6ccda8 chore: format +0029add4 feat: m3 expressive menus +a10c89e5 fix: don't use easingCurve type +397b6a9b fix: dash tab hover +3128159b feat: use pch to speed up builds +d177551b feat: forward declare where possible +9c5e424d chore: .. imports -> qs.components diff --git a/modules/BatteryMonitor.qml b/modules/BatteryMonitor.qml index d24cff274..2d13b5a32 100644 --- a/modules/BatteryMonitor.qml +++ b/modules/BatteryMonitor.qml @@ -1,33 +1,31 @@ -import qs.config -import Caelestia +import QtQuick import Quickshell import Quickshell.Services.UPower -import QtQuick +import Caelestia +import Caelestia.Config Scope { id: root - readonly property list warnLevels: [...Config.general.battery.warnLevels].sort((a, b) => b.level - a.level) + readonly property list warnLevels: [...GlobalConfig.general.battery.warnLevels].sort((a, b) => b.level - a.level) Connections { - target: UPower - function onOnBatteryChanged(): void { if (UPower.onBattery) { - if (Config.utilities.toasts.chargingChanged) + if (GlobalConfig.utilities.toasts.chargingChanged) Toaster.toast(qsTr("Charger unplugged"), qsTr("Battery is discharging"), "power_off"); } else { - if (Config.utilities.toasts.chargingChanged) + if (GlobalConfig.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 { - target: UPower.displayDevice - function onPercentageChanged(): void { if (!UPower.onBattery) return; @@ -40,11 +38,13 @@ Scope { } } - if (!hibernateTimer.running && p <= Config.general.battery.criticalLevel) { + if (!hibernateTimer.running && p <= GlobalConfig.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 { diff --git a/modules/ConfigToasts.qml b/modules/ConfigToasts.qml new file mode 100644 index 000000000..81463b8d8 --- /dev/null +++ b/modules/ConfigToasts.qml @@ -0,0 +1,39 @@ +import QtQuick +import Quickshell +import Caelestia +import Caelestia.Config + +Scope { + Connections { + function onLoaded(): void { + if (GlobalConfig.utilities.toasts.configLoaded) + Toaster.toast(qsTr("Config loaded"), qsTr("Config loaded successfully!"), "rule_settings"); + } + + function onLoadFailed(error: string, screen: string): void { + Toaster.toast(qsTr("Failed to parse config%1").arg(screen ? " for " + screen : ""), error, "settings_alert", Toast.Warning); + } + + function onSaveFailed(error: string, screen: string): void { + Toaster.toast(qsTr("Failed to save config%1").arg(screen ? " for " + screen : ""), error, "settings_alert", Toast.Error); + } + + function onUnknownOption(key: string, screen: string): void { + Toaster.toast(qsTr("Unknown option in%1 config").arg(screen ? " " + screen : ""), key, "question_mark", Toast.Warning); + } + + target: GlobalConfig + } + + Connections { + function onLoadFailed(error: string, screen: string): void { + Toaster.toast(qsTr("Failed to parse token config%1").arg(screen ? "for " + screen : ""), error, "settings_alert", Toast.Warning); + } + + function onUnknownOption(key: string, screen: string): void { + Toaster.toast(qsTr("Unknown option in%1 token config").arg(screen ? " " + screen : ""), key, "question_mark", Toast.Warning); + } + + target: TokenConfig + } +} diff --git a/modules/IdleMonitors.qml b/modules/IdleMonitors.qml index b7ce05843..417b0e8ae 100644 --- a/modules/IdleMonitors.qml +++ b/modules/IdleMonitors.qml @@ -1,17 +1,17 @@ pragma ComponentBehavior: Bound import "lock" -import qs.config -import qs.services -import Caelestia.Internal import Quickshell import Quickshell.Wayland +import Caelestia.Config +import Caelestia.Internal +import qs.services Scope { id: root required property Lock lock - readonly property bool enabled: !Config.general.idle.inhibitWhenAudio || !Players.list.some(p => p.isPlaying) + readonly property bool enabled: !GlobalConfig.general.idle.inhibitWhenAudio || !Players.list.some(p => p.isPlaying) function handleIdleAction(action: var): void { if (!action) @@ -29,7 +29,7 @@ Scope { LogindManager { onAboutToSleep: { - if (Config.general.idle.lockBeforeSleep) + if (GlobalConfig.general.idle.lockBeforeSleep) root.lock.lock.locked = true; } onLockRequested: root.lock.lock.locked = true @@ -37,7 +37,7 @@ Scope { } Variants { - model: Config.general.idle.timeouts + model: GlobalConfig.general.idle.timeouts IdleMonitor { required property var modelData diff --git a/modules/MonitorIdentifier.qml b/modules/MonitorIdentifier.qml new file mode 100644 index 000000000..a618c6b52 --- /dev/null +++ b/modules/MonitorIdentifier.qml @@ -0,0 +1,85 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.components.containers +import qs.services +import Caelestia.Config +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland +import QtQuick +import QtQuick.Layouts + +Variants { + id: root + + model: Quickshell.screens + readonly property bool active: Monitors.identifying + + StyledWindow { + id: win + + required property ShellScreen modelData + readonly property var monitor: Hypr.monitorFor(modelData) + + screen: modelData + name: "monitor-identifier" + visible: root.active || identifierRect.opacity > 0 + + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.exclusionMode: ExclusionMode.Ignore + + anchors.top: true + anchors.bottom: true + anchors.left: true + anchors.right: true + + // Click anywhere on the overlay to dismiss + MouseArea { + anchors.fill: parent + onClicked: Monitors.stopIdentification() + } + + StyledRect { + id: identifierRect + anchors.centerIn: parent + implicitWidth: Tokens.padding.large * 14 + implicitHeight: Tokens.padding.large * 14 + radius: Tokens.rounding.large + color: Colours.tPalette.m3surfaceContainer + opacity: root.active ? 0.92 : 0 + + // Prevent the MouseArea behind from stealing this click + MouseArea { + anchors.fill: parent + onClicked: Monitors.stopIdentification() + } + + ColumnLayout { + anchors.centerIn: parent + spacing: Tokens.spacing.small + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: win.monitor?.id ?? "?" + font.pointSize: 96 + font.bold: true + color: Colours.palette.m3primary + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: win.monitor?.name ?? "" + font.pointSize: Tokens.font.size.normal + color: Colours.palette.m3onSurfaceVariant + } + + + } + + Behavior on opacity { + Anim {} + } + } + } +} diff --git a/modules/Shortcuts.qml b/modules/Shortcuts.qml index a62b827eb..9ee796769 100644 --- a/modules/Shortcuts.qml +++ b/modules/Shortcuts.qml @@ -1,23 +1,28 @@ -import qs.components.misc -import qs.modules.controlcenter -import qs.services -import Caelestia +import QtQuick import Quickshell import Quickshell.Io +import Caelestia +import qs.components.misc +import qs.services +import qs.modules.controlcenter Scope { id: root property bool launcherInterrupted - readonly property bool hasFullscreen: Hypr.focusedWorkspace?.toplevels.values.some(t => t.lastIpcObject.fullscreen === 2) ?? false + readonly property bool hasFullscreen: Hypr.focusedWorkspace?.toplevels.values.some(t => t.lastIpcObject.fullscreen > 1) ?? false + // qmllint disable unresolved-type CustomShortcut { + // qmllint enable unresolved-type name: "controlCenter" description: "Open control center" onPressed: WindowFactory.create() } + // qmllint disable unresolved-type CustomShortcut { + // qmllint enable unresolved-type name: "showall" description: "Toggle launcher, dashboard and osd" onPressed: { @@ -28,7 +33,9 @@ Scope { } } + // qmllint disable unresolved-type CustomShortcut { + // qmllint enable unresolved-type name: "dashboard" description: "Toggle dashboard" onPressed: { @@ -39,7 +46,9 @@ Scope { } } + // qmllint disable unresolved-type CustomShortcut { + // qmllint enable unresolved-type name: "session" description: "Toggle session menu" onPressed: { @@ -50,7 +59,9 @@ Scope { } } + // qmllint disable unresolved-type CustomShortcut { + // qmllint enable unresolved-type name: "launcher" description: "Toggle launcher" onPressed: root.launcherInterrupted = false @@ -63,15 +74,41 @@ Scope { } } + // qmllint disable unresolved-type CustomShortcut { + // qmllint enable unresolved-type name: "launcherInterrupt" description: "Interrupt launcher keybind" onPressed: root.launcherInterrupted = true } - IpcHandler { - target: "drawers" + // qmllint disable unresolved-type + CustomShortcut { + // qmllint enable unresolved-type + name: "sidebar" + description: "Toggle sidebar" + onPressed: { + if (root.hasFullscreen) + return; + const visibilities = Visibilities.getForActive(); + visibilities.sidebar = !visibilities.sidebar; + } + } + // qmllint disable unresolved-type + CustomShortcut { + // qmllint enable unresolved-type + 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)) @@ -79,7 +116,7 @@ Scope { const visibilities = Visibilities.getForActive(); visibilities[drawer] = !visibilities[drawer]; } else { - console.warn(`[IPC] Drawer "${drawer}" does not exist`); + console.warn(lc, `Drawer "${drawer}" does not exist`); } } @@ -87,19 +124,19 @@ Scope { const visibilities = Visibilities.getForActive(); return Object.keys(visibilities).filter(k => typeof visibilities[k] === "boolean").join("\n"); } + + target: "drawers" } IpcHandler { - target: "controlCenter" - function open(): void { WindowFactory.create(); } + + target: "controlCenter" } IpcHandler { - target: "toaster" - function info(title: string, message: string, icon: string): void { Toaster.toast(title, message, icon, Toast.Info); } @@ -115,5 +152,14 @@ Scope { function error(title: string, message: string, icon: string): void { Toaster.toast(title, message, icon, Toast.Error); } + + target: "toaster" + } + + LoggingCategory { + id: lc + + name: "caelestia.qml.shortcuts" + defaultLogLevel: LoggingCategory.Info } } diff --git a/modules/areapicker/AreaPicker.qml b/modules/areapicker/AreaPicker.qml index 0d8b2fe13..76cc10399 100644 --- a/modules/areapicker/AreaPicker.qml +++ b/modules/areapicker/AreaPicker.qml @@ -1,10 +1,11 @@ pragma ComponentBehavior: Bound -import qs.components.containers -import qs.components.misc import Quickshell -import Quickshell.Wayland import Quickshell.Io +import Quickshell.Wayland +import qs.components.containers +import qs.components.misc +import qs.services Scope { LazyLoader { @@ -15,7 +16,7 @@ Scope { property bool clipboardOnly Variants { - model: Quickshell.screens + model: Screens.screens StyledWindow { id: win @@ -47,8 +48,6 @@ Scope { } IpcHandler { - target: "picker" - function open(): void { root.freeze = false; root.closing = false; @@ -76,9 +75,13 @@ Scope { root.clipboardOnly = true; root.activeAsync = true; } + + target: "picker" } + // qmllint disable unresolved-type CustomShortcut { + // qmllint enable unresolved-type name: "screenshot" description: "Open screenshot tool" onPressed: { @@ -89,7 +92,9 @@ Scope { } } + // qmllint disable unresolved-type CustomShortcut { + // qmllint enable unresolved-type name: "screenshotFreeze" description: "Open screenshot tool (freeze mode)" onPressed: { @@ -100,7 +105,9 @@ Scope { } } + // qmllint disable unresolved-type CustomShortcut { + // qmllint enable unresolved-type name: "screenshotClip" description: "Open screenshot tool (clipboard)" onPressed: { @@ -111,7 +118,9 @@ Scope { } } + // qmllint disable unresolved-type CustomShortcut { + // qmllint enable unresolved-type name: "screenshotFreezeClip" description: "Open screenshot tool (freeze mode, clipboard)" onPressed: { diff --git a/modules/areapicker/Picker.qml b/modules/areapicker/Picker.qml index 35b35a2e4..75c45ab93 100644 --- a/modules/areapicker/Picker.qml +++ b/modules/areapicker/Picker.qml @@ -1,13 +1,13 @@ pragma ComponentBehavior: Bound -import qs.components -import qs.services -import qs.config -import Caelestia -import Quickshell -import Quickshell.Wayland import QtQuick import QtQuick.Effects +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import Caelestia +import qs.components +import qs.services MouseArea { id: root @@ -71,20 +71,23 @@ MouseArea { } } - 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]); - } - }); + 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(); - } + }, () => { + console.error("Failed to save screenshot"); + closeAnim.start(); + }) +} - onClientsChanged: checkClientRects(mouseX, mouseY) +onClientsChanged: checkClientRects(mouseX, mouseY) anchors.fill: parent opacity: 0 @@ -166,7 +169,7 @@ MouseArea { target: root property: "opacity" to: 0 - duration: Appearance.anim.durations.large + type: Anim.StandardLarge } ExAnim { target: root @@ -191,9 +194,21 @@ MouseArea { } } + 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 @@ -207,6 +222,13 @@ MouseArea { root.save(); } } + + Component.onCompleted: { + if (hasContent && !root.loader.freeze) { + overlay.visible = border.visible = true; + root.save(); + } + } } } @@ -265,7 +287,7 @@ MouseArea { Behavior on opacity { Anim { - duration: Appearance.anim.durations.large + type: Anim.StandardLarge } } @@ -294,7 +316,6 @@ MouseArea { } component ExAnim: Anim { - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + type: Anim.DefaultSpatial } } diff --git a/modules/background/Background.qml b/modules/background/Background.qml index f8484e167..70f6914dd 100644 --- a/modules/background/Background.qml +++ b/modules/background/Background.qml @@ -1,146 +1,158 @@ pragma ComponentBehavior: Bound +import QtQuick +import Quickshell +import Quickshell.Wayland +import Caelestia.Config import qs.components import qs.components.containers import qs.services -import qs.config -import Quickshell -import Quickshell.Wayland -import QtQuick -Loader { - active: Config.background.enabled +Variants { + model: Screens.screens.filter(s => GlobalConfig.forScreen(s.name).background.enabled) - sourceComponent: Variants { - model: Quickshell.screens + StyledWindow { + id: win - StyledWindow { - id: win + required property ShellScreen modelData - required property ShellScreen modelData + screen: modelData + name: "background" + WlrLayershell.exclusionMode: ExclusionMode.Ignore + WlrLayershell.layer: contentItem.Config.background.wallpaperEnabled ? WlrLayer.Background : WlrLayer.Bottom + color: contentItem.Config.background.wallpaperEnabled ? "black" : "transparent" + surfaceFormat.opaque: false - screen: modelData - name: "background" - WlrLayershell.exclusionMode: ExclusionMode.Ignore - WlrLayershell.layer: WlrLayer.Background - color: "black" + anchors.top: true + anchors.bottom: true + anchors.left: true + anchors.right: true - anchors.top: true - anchors.bottom: true - anchors.left: true - anchors.right: true + Item { + id: behindClock - Item { - id: behindClock + anchors.fill: parent + + Loader { + id: wallpaper + + asynchronous: true anchors.fill: parent + active: Config.background.wallpaperEnabled - Wallpaper { - id: wallpaper - } + sourceComponent: Wallpaper {} + } - Visualiser { - anchors.fill: parent - screen: win.modelData - wallpaper: wallpaper - } + Visualiser { + anchors.fill: parent + screen: win.modelData + wallpaper: wallpaper } + } - Loader { - id: clockLoader - active: Config.background.desktopClock.enabled - - anchors.margins: Appearance.padding.large * 2 - anchors.leftMargin: Appearance.padding.large * 2 + Config.bar.sizes.innerWidth + Math.max(Appearance.padding.smaller, Config.border.thickness) - - state: Config.background.desktopClock.position - states: [ - State { - name: "top-left" - AnchorChanges { - target: clockLoader - anchors.top: parent.top - anchors.left: parent.left - } - }, - State { - name: "top-center" - AnchorChanges { - target: clockLoader - anchors.top: parent.top - anchors.horizontalCenter: parent.horizontalCenter - } - }, - State { - name: "top-right" - AnchorChanges { - target: clockLoader - anchors.top: parent.top - anchors.right: parent.right - } - }, - State { - name: "middle-left" - AnchorChanges { - target: clockLoader - anchors.verticalCenter: parent.verticalCenter - anchors.left: parent.left - } - }, - State { - name: "middle-center" - AnchorChanges { - target: clockLoader - anchors.verticalCenter: parent.verticalCenter - anchors.horizontalCenter: parent.horizontalCenter - } - }, - State { - name: "middle-right" - AnchorChanges { - target: clockLoader - anchors.verticalCenter: parent.verticalCenter - anchors.right: parent.right - } - }, - State { - name: "bottom-left" - AnchorChanges { - target: clockLoader - anchors.bottom: parent.bottom - anchors.left: parent.left - } - }, - State { - name: "bottom-center" - AnchorChanges { - target: clockLoader - anchors.bottom: parent.bottom - anchors.horizontalCenter: parent.horizontalCenter - } - }, - State { - name: "bottom-right" - AnchorChanges { - target: clockLoader - anchors.bottom: parent.bottom - anchors.right: parent.right - } - } - ] + Loader { + id: clockLoader + + asynchronous: true + active: Config.background.desktopClock.enabled + + anchors.margins: Tokens.padding.large * 2 + anchors.leftMargin: Tokens.padding.large * 2 + Tokens.sizes.bar.innerWidth + Math.max(Tokens.padding.smaller, Config.border.thickness) + + state: Config.background.desktopClock.position + states: [ + State { + name: "top-left" - transitions: Transition { - AnchorAnimation { - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + 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 } } + ] - sourceComponent: DesktopClock { - wallpaper: behindClock - absX: clockLoader.x - absY: clockLoader.y - } + transitions: Transition { + AnchorAnim {} + } + + sourceComponent: DesktopClock { + wallpaper: behindClock + absX: clockLoader.x + absY: clockLoader.y } } } diff --git a/modules/background/DesktopClock.qml b/modules/background/DesktopClock.qml index 77fe447fe..c7415be8b 100644 --- a/modules/background/DesktopClock.qml +++ b/modules/background/DesktopClock.qml @@ -1,11 +1,11 @@ pragma ComponentBehavior: Bound -import qs.components -import qs.services -import qs.config import QtQuick -import QtQuick.Layouts import QtQuick.Effects +import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.services Item { id: root @@ -14,17 +14,17 @@ Item { required property real absX required property real absY - property real scale: Config.background.desktopClock.scale + property real clockScale: Config.background.desktopClock.scale readonly property bool bgEnabled: Config.background.desktopClock.background.enabled - readonly property bool blurEnabled: bgEnabled && Config.background.desktopClock.background.blur + 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) + implicitWidth: layout.implicitWidth + (Tokens.padding.large * 4 * root.clockScale) + implicitHeight: layout.implicitHeight + (Tokens.padding.large * 2 * root.clockScale) Item { id: clockContainer @@ -40,6 +40,7 @@ Item { } Loader { + asynchronous: true anchors.fill: parent active: root.blurEnabled @@ -62,7 +63,7 @@ Item { visible: root.bgEnabled anchors.fill: parent - radius: Appearance.rounding.large * root.scale + radius: Tokens.rounding.large * root.clockScale opacity: Config.background.desktopClock.background.opacity color: Colours.palette.m3surface @@ -73,43 +74,44 @@ Item { id: layout anchors.centerIn: parent - spacing: Appearance.spacing.larger * root.scale + spacing: Tokens.spacing.larger * root.clockScale RowLayout { - spacing: Appearance.spacing.small + spacing: Tokens.spacing.small StyledText { text: Time.hourStr - font.pointSize: Appearance.font.size.extraLarge * 3 * root.scale + font.pointSize: Tokens.font.size.extraLarge * 3 * root.clockScale font.weight: Font.Bold color: root.safePrimary } StyledText { text: ":" - font.pointSize: Appearance.font.size.extraLarge * 3 * root.scale + font.pointSize: Tokens.font.size.extraLarge * 3 * root.clockScale color: root.safeTertiary opacity: 0.8 - Layout.topMargin: -Appearance.padding.large * 1.5 * root.scale + Layout.topMargin: -Tokens.padding.large * 1.5 * root.clockScale } StyledText { text: Time.minuteStr - font.pointSize: Appearance.font.size.extraLarge * 3 * root.scale + font.pointSize: Tokens.font.size.extraLarge * 3 * root.clockScale font.weight: Font.Bold color: root.safeSecondary } Loader { + asynchronous: true Layout.alignment: Qt.AlignTop - Layout.topMargin: Appearance.padding.large * 1.4 * root.scale + Layout.topMargin: Tokens.padding.large * 1.4 * root.clockScale - active: Config.services.useTwelveHourClock + active: GlobalConfig.services.useTwelveHourClock visible: active sourceComponent: StyledText { text: Time.amPmStr - font.pointSize: Appearance.font.size.large * root.scale + font.pointSize: Tokens.font.size.large * root.clockScale color: root.safeSecondary } } @@ -117,10 +119,10 @@ Item { 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 + Layout.preferredWidth: 4 * root.clockScale + Layout.topMargin: Tokens.spacing.larger * root.clockScale + Layout.bottomMargin: Tokens.spacing.larger * root.clockScale + radius: Tokens.rounding.full color: root.safePrimary opacity: 0.8 } @@ -130,7 +132,7 @@ Item { StyledText { text: Time.format("MMMM").toUpperCase() - font.pointSize: Appearance.font.size.large * root.scale + font.pointSize: Tokens.font.size.large * root.clockScale font.letterSpacing: 4 font.weight: Font.Bold color: root.safeSecondary @@ -138,7 +140,7 @@ Item { StyledText { text: Time.format("dd") - font.pointSize: Appearance.font.size.extraLarge * root.scale + font.pointSize: Tokens.font.size.extraLarge * root.clockScale font.letterSpacing: 2 font.weight: Font.Medium color: root.safePrimary @@ -146,7 +148,7 @@ Item { StyledText { text: Time.format("dddd") - font.pointSize: Appearance.font.size.larger * root.scale + font.pointSize: Tokens.font.size.larger * root.clockScale font.letterSpacing: 2 color: root.safeSecondary } @@ -154,16 +156,15 @@ Item { } } - Behavior on scale { + Behavior on clockScale { Anim { - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + type: Anim.DefaultSpatial } } Behavior on implicitWidth { Anim { - duration: Appearance.anim.durations.small + type: Anim.StandardSmall } } } diff --git a/modules/background/Visualiser.qml b/modules/background/Visualiser.qml index c9bb9efb0..450558792 100644 --- a/modules/background/Visualiser.qml +++ b/modules/background/Visualiser.qml @@ -1,19 +1,19 @@ 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 +import Quickshell +import Caelestia.Config +import Caelestia.Internal +import Caelestia.Services +import qs.components +import qs.services Item { id: root required property ShellScreen screen - required property Wallpaper wallpaper + 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 @@ -21,6 +21,7 @@ Item { opacity: shouldBeActive ? 1 : 0 Loader { + asynchronous: true anchors.fill: parent active: root.opacity > 0 && Config.background.visualiser.blur @@ -42,6 +43,7 @@ Item { layer.enabled: true Loader { + asynchronous: true anchors.fill: parent anchors.topMargin: root.offset anchors.bottomMargin: -root.offset @@ -53,25 +55,29 @@ Item { service: Audio.cava } - Item { - id: content + VisualiserBars { + id: bars anchors.fill: parent anchors.margins: Config.border.thickness - anchors.leftMargin: Visibilities.bars.get(root.screen).exclusiveZone + Appearance.spacing.small * Config.background.visualiser.spacing + anchors.leftMargin: Visibilities.bars.get(root.screen).exclusiveZone + Tokens.spacing.small * Config.background.visualiser.spacing - Side { - content: content - } - Side { - content: content - isRight: true - } + values: Audio.cava.values + primaryColor: Qt.alpha(Colours.palette.m3primary, 0.7) + secondaryColor: Qt.alpha(Colours.palette.m3inversePrimary, 0.7) + rounding: Tokens.rounding.small * Config.background.visualiser.rounding + spacing: Tokens.spacing.small * Config.background.visualiser.spacing + animationDuration: Tokens.anim.durations.normal Behavior on anchors.leftMargin { Anim {} } } + + FrameAnimation { + running: root.opacity > 0 && !bars.settled + onTriggered: bars.advance(frameTime) + } } } } @@ -83,69 +89,4 @@ Item { Behavior on opacity { Anim {} } - - component Side: Repeater { - id: side - - required property Item content - property bool isRight - - model: Config.services.visualiserBars - - ClippingRectangle { - id: bar - - required property int modelData - property real value: Math.max(0, Math.min(1, Audio.cava.values[side.isRight ? modelData : side.count - modelData - 1])) - - clip: true - - x: modelData * ((side.content.width * 0.4) / Config.services.visualiserBars) + (side.isRight ? side.content.width * 0.6 : 0) - implicitWidth: (side.content.width * 0.4) / Config.services.visualiserBars - Appearance.spacing.small * Config.background.visualiser.spacing - - y: side.content.height - height - implicitHeight: bar.value * side.content.height * 0.4 - - color: "transparent" - topLeftRadius: Appearance.rounding.small * Config.background.visualiser.rounding - topRightRadius: Appearance.rounding.small * Config.background.visualiser.rounding - - Rectangle { - topLeftRadius: parent.topLeftRadius - topRightRadius: parent.topRightRadius - - gradient: Gradient { - orientation: Gradient.Vertical - - GradientStop { - position: 0 - color: Qt.alpha(Colours.palette.m3primary, 0.7) - - Behavior on color { - CAnim {} - } - } - GradientStop { - position: 1 - color: Qt.alpha(Colours.palette.m3inversePrimary, 0.7) - - Behavior on color { - CAnim {} - } - } - } - - anchors.left: parent.left - anchors.right: parent.right - y: parent.height - height - implicitHeight: side.content.height * 0.4 - } - - Behavior on value { - Anim { - duration: Appearance.anim.durations.small - } - } - } - } } diff --git a/modules/background/Wallpaper.qml b/modules/background/Wallpaper.qml index b5d7d4af4..8cc2df9d3 100644 --- a/modules/background/Wallpaper.qml +++ b/modules/background/Wallpaper.qml @@ -1,20 +1,19 @@ pragma ComponentBehavior: Bound +import QtQuick +import Caelestia.Config import qs.components -import qs.components.images import qs.components.filedialog +import qs.components.images import qs.services -import qs.config import qs.utils -import QtQuick Item { id: root property string source: Wallpapers.current property Image current: one - - anchors.fill: parent + property bool completed onSourceChanged: { if (!source) @@ -27,43 +26,47 @@ Item { Component.onCompleted: { if (source) - Qt.callLater(() => one.update()); + Qt.callLater(() => { + one.update(); + completed = true; + }); } Loader { + asynchronous: true anchors.fill: parent - active: !root.source + active: root.completed && !root.source sourceComponent: StyledRect { color: Colours.palette.m3surfaceContainer Row { anchors.centerIn: parent - spacing: Appearance.spacing.large + spacing: Tokens.spacing.large MaterialIcon { text: "sentiment_stressed" color: Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.extraLarge * 5 + font.pointSize: Tokens.font.size.extraLarge * 5 } Column { anchors.verticalCenter: parent.verticalCenter - spacing: Appearance.spacing.small + spacing: Tokens.spacing.small StyledText { text: qsTr("Wallpaper missing?") color: Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.extraLarge * 2 + font.pointSize: Tokens.font.size.extraLarge * 2 font.bold: true } StyledRect { - implicitWidth: selectWallText.implicitWidth + Appearance.padding.large * 2 - implicitHeight: selectWallText.implicitHeight + Appearance.padding.small * 2 + implicitWidth: selectWallText.implicitWidth + Tokens.padding.large * 2 + implicitHeight: selectWallText.implicitHeight + Tokens.padding.small * 2 - radius: Appearance.rounding.full + radius: Tokens.rounding.full color: Colours.palette.m3primary FileDialog { @@ -78,10 +81,7 @@ Item { StateLayer { radius: parent.radius color: Colours.palette.m3onPrimary - - function onClicked(): void { - dialog.open(); - } + onClicked: dialog.open() } StyledText { @@ -91,7 +91,7 @@ Item { text: qsTr("Set it now!") color: Colours.palette.m3onPrimary - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large } } } diff --git a/modules/bar/Bar.qml b/modules/bar/Bar.qml index cb384e393..a2ed060e7 100644 --- a/modules/bar/Bar.qml +++ b/modules/bar/Bar.qml @@ -1,30 +1,32 @@ pragma ComponentBehavior: Bound -import qs.services -import qs.config import "popouts" as BarPopouts import "components" import "components/workspaces" -import Quickshell import QtQuick import QtQuick.Layouts +import Quickshell +import Caelestia.Config +import qs.components +import qs.services ColumnLayout { id: root required property ShellScreen screen - required property PersistentProperties visibilities + required property DrawerVisibilities visibilities required property BarPopouts.Wrapper popouts - readonly property int vPadding: Appearance.padding.large + required property bool fullscreen + readonly property int vPadding: Tokens.padding.large function closeTray(): void { if (!Config.bar.tray.compact) return; for (let i = 0; i < repeater.count; i++) { - const item = repeater.itemAt(i); - if (item?.enabled && item.id === "tray") { - item.item.expanded = false; + const loader = repeater.itemAt(i) as WrappedLoader; + if (loader?.enabled && loader.id === "tray") { + (loader.item as Tray).expanded = false; } } } @@ -42,11 +44,9 @@ ColumnLayout { const id = ch.id; const top = ch.y; - const item = ch.item; - const itemHeight = item.implicitHeight; if (id === "statusIcons" && Config.bar.popouts.statusIcons) { - const items = item.items; + const items = (ch.item as StatusIcons).items; const icon = items.childAt(items.width / 2, mapToItem(items, 0, y).y); if (icon) { popouts.currentName = icon.name; @@ -54,9 +54,10 @@ ColumnLayout { popouts.hasCurrent = true; } } else if (id === "tray" && Config.bar.popouts.tray) { - if (!Config.bar.tray.compact || (item.expanded && !item.expandIcon.contains(mapToItem(item.expandIcon, item.implicitWidth / 2, y)))) { - const index = Math.floor(((y - top - item.padding * 2 + item.spacing) / item.layout.implicitHeight) * item.items.count); - const trayItem = item.items.itemAt(index); + 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); @@ -66,11 +67,11 @@ ColumnLayout { } } else { popouts.hasCurrent = false; - item.expanded = true; + tray.expanded = true; } - } else if (id === "activeWindow" && Config.bar.popouts.activeWindow) { + } else if (id === "activeWindow" && Config.bar.popouts.activeWindow && Config.bar.activeWindow.showOnHover) { popouts.currentName = id.toLowerCase(); - popouts.currentCenter = item.mapToItem(root, 0, itemHeight / 2).y; + popouts.currentCenter = (ch.item as Item).mapToItem(root, 0, (ch.item as Item).implicitHeight / 2).y ?? 0; popouts.hasCurrent = true; } } @@ -79,11 +80,11 @@ ColumnLayout { 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 mon = (GlobalConfig.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) + else if (angleDelta.y < 0 || (GlobalConfig.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 @@ -95,13 +96,13 @@ ColumnLayout { // Brightness scroll on bottom half const monitor = Brightness.getMonitorForScreen(screen); if (angleDelta.y > 0) - monitor.setBrightness(monitor.brightness + Config.services.brightnessIncrement); + monitor.setBrightness(monitor.brightness + GlobalConfig.services.brightnessIncrement); else if (angleDelta.y < 0) - monitor.setBrightness(monitor.brightness - Config.services.brightnessIncrement); + monitor.setBrightness(monitor.brightness - GlobalConfig.services.brightnessIncrement); } } - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal Repeater { id: repeater @@ -128,12 +129,15 @@ ColumnLayout { delegate: WrappedLoader { sourceComponent: Workspaces { screen: root.screen + fullscreen: root.fullscreen } } } DelegateChoice { roleValue: "activeWindow" delegate: WrappedLoader { + Layout.fillWidth: true + visible: !root.fullscreen sourceComponent: ActiveWindow { bar: root monitor: Brightness.getMonitorForScreen(root.screen) @@ -143,18 +147,21 @@ ColumnLayout { DelegateChoice { roleValue: "tray" delegate: WrappedLoader { + visible: !root.fullscreen sourceComponent: Tray {} } } DelegateChoice { roleValue: "clock" delegate: WrappedLoader { + visible: !root.fullscreen sourceComponent: Clock {} } } DelegateChoice { roleValue: "statusIcons" delegate: WrappedLoader { + visible: !root.fullscreen sourceComponent: StatusIcons {} } } @@ -170,7 +177,7 @@ ColumnLayout { } component WrappedLoader: Loader { - required property bool enabled + required enabled required property string id required property int index @@ -193,6 +200,7 @@ ColumnLayout { return null; } + asynchronous: true Layout.alignment: Qt.AlignHCenter // Cursed ahh thing to add padding to first and last enabled components diff --git a/modules/bar/BarWrapper.qml b/modules/bar/BarWrapper.qml index 29961b62c..c57dcd647 100644 --- a/modules/bar/BarWrapper.qml +++ b/modules/bar/BarWrapper.qml @@ -1,39 +1,44 @@ pragma ComponentBehavior: Bound -import qs.components -import qs.config -import "popouts" as BarPopouts -import Quickshell import QtQuick +import Quickshell +import Caelestia.Config +import qs.components +import qs.utils +import qs.modules.bar.popouts as BarPopouts Item { id: root required property ShellScreen screen - required property PersistentProperties visibilities + required property DrawerVisibilities visibilities required property BarPopouts.Wrapper popouts - required property bool disabled + required property bool fullscreen + + readonly property bool disabled: Strings.testRegexList(Config.bar.excludedScreens, screen.name) - 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) + readonly property int clampedWidth: Math.max(Config.border.minThickness, implicitWidth) + readonly property int padding: Math.max(Tokens.padding.smaller, Config.border.thickness) + readonly property int contentWidth: Tokens.sizes.bar.innerWidth + padding * 2 + readonly property int exclusiveZone: shouldBeVisible ? contentWidth : Config.border.thickness + readonly property bool shouldBeVisible: !fullscreen && !disabled && (Config.bar.persistent || visibilities.bar || isHovered) property bool isHovered function closeTray(): void { - content.item?.closeTray(); + (content.item as Bar)?.closeTray(); } function checkPopout(y: real): void { - content.item?.checkPopout(y); + (content.item as Bar)?.checkPopout(y); } function handleWheel(y: real, angleDelta: point): void { - content.item?.handleWheel(y, angleDelta); + (content.item as Bar)?.handleWheel(y, angleDelta); } - visible: width > Config.border.thickness - implicitWidth: Config.border.thickness + clip: true + visible: width > 0 + implicitWidth: fullscreen ? 0 : Config.border.thickness states: State { name: "visible" @@ -52,8 +57,7 @@ Item { Anim { target: root property: "implicitWidth" - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + type: Anim.DefaultSpatial } }, Transition { @@ -63,7 +67,7 @@ Item { Anim { target: root property: "implicitWidth" - easing.bezierCurve: Appearance.anim.curves.emphasized + type: Anim.Emphasized } } ] @@ -81,7 +85,8 @@ Item { width: root.contentWidth screen: root.screen visibilities: root.visibilities - popouts: root.popouts + popouts: root.popouts // qmllint disable incompatible-type + fullscreen: root.fullscreen } } } diff --git a/modules/bar/components/ActiveWindow.qml b/modules/bar/components/ActiveWindow.qml index 0c9b21e6f..f39aa4c39 100644 --- a/modules/bar/components/ActiveWindow.qml +++ b/modules/bar/components/ActiveWindow.qml @@ -1,10 +1,10 @@ pragma ComponentBehavior: Bound +import QtQuick +import Caelestia.Config import qs.components import qs.services import qs.utils -import qs.config -import QtQuick Item { id: root @@ -13,6 +13,19 @@ Item { 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); @@ -25,6 +38,32 @@ Item { 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 @@ -46,9 +85,9 @@ Item { TextMetrics { id: metrics - text: Hypr.activeToplevel?.title ?? qsTr("Desktop") - font.pointSize: Appearance.font.size.smaller - font.family: Appearance.font.family.mono + text: root.windowTitle + font.pointSize: root.Tokens.font.size.smaller + font.family: root.Tokens.font.family.mono elide: Qt.ElideRight elideWidth: root.maxHeight - icon.height @@ -62,8 +101,7 @@ Item { Behavior on implicitHeight { Anim { - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + type: Anim.DefaultSpatial } } @@ -72,7 +110,7 @@ Item { anchors.horizontalCenter: icon.horizontalCenter anchors.top: icon.bottom - anchors.topMargin: Appearance.spacing.small + anchors.topMargin: Tokens.spacing.small font.pointSize: metrics.font.pointSize font.family: metrics.font.family @@ -81,10 +119,10 @@ Item { transform: [ Translate { - x: Config.bar.activeWindow.inverted ? -implicitWidth + text.implicitHeight : 0 + x: root.Config.bar.activeWindow.inverted ? -text.implicitWidth + text.implicitHeight : 0 }, Rotation { - angle: Config.bar.activeWindow.inverted ? 270 : 90 + angle: root.Config.bar.activeWindow.inverted ? 270 : 90 origin.x: text.implicitHeight / 2 origin.y: text.implicitHeight / 2 } diff --git a/modules/bar/components/Clock.qml b/modules/bar/components/Clock.qml index 801e93d77..ffe599afd 100644 --- a/modules/bar/components/Clock.qml +++ b/modules/bar/components/Clock.qml @@ -1,38 +1,71 @@ pragma ComponentBehavior: Bound +import QtQuick +import Caelestia.Config import qs.components import qs.services -import qs.config -import QtQuick -Column { +StyledRect { id: root - property color colour: Colours.palette.m3tertiary + readonly property color colour: Colours.palette.m3tertiary + readonly property int padding: Config.bar.clock.background ? Tokens.padding.normal : Tokens.padding.small + + implicitWidth: Tokens.sizes.bar.innerWidth + implicitHeight: layout.implicitHeight + root.padding * 2 + + color: Qt.alpha(Colours.tPalette.m3surfaceContainer, Config.bar.clock.background ? Colours.tPalette.m3surfaceContainer.a : 0) + radius: Tokens.rounding.full - spacing: Appearance.spacing.small + Column { + id: layout - Loader { - anchors.horizontalCenter: parent.horizontalCenter + anchors.centerIn: parent + spacing: Tokens.spacing.small + + Loader { + asynchronous: true + anchors.horizontalCenter: parent.horizontalCenter + + active: Config.bar.clock.showIcon + visible: active + + sourceComponent: MaterialIcon { + text: "calendar_month" + color: root.colour + } + } - active: Config.bar.clock.showIcon - visible: active + StyledText { + anchors.horizontalCenter: parent.horizontalCenter - sourceComponent: MaterialIcon { - text: "calendar_month" + visible: Config.bar.clock.showDate + + horizontalAlignment: StyledText.AlignHCenter + text: Time.format("ddd\nd") + font.pointSize: Tokens.font.size.smaller + font.family: Tokens.font.family.sans color: root.colour } - } - StyledText { - id: text + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + visible: Config.bar.clock.showDate + height: visible ? 1 : 0 - anchors.horizontalCenter: parent.horizontalCenter + width: parent.width * 0.8 + color: root.colour + opacity: 0.2 + } - 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 + StyledText { + anchors.horizontalCenter: parent.horizontalCenter + + horizontalAlignment: StyledText.AlignHCenter + text: Time.format(GlobalConfig.services.useTwelveHourClock ? "hh\nmm\nA" : "hh\nmm") + font.pointSize: Tokens.font.size.smaller + font.family: Tokens.font.family.mono + color: root.colour + } } } diff --git a/modules/bar/components/OsIcon.qml b/modules/bar/components/OsIcon.qml index 2bc386441..94d1a1c4d 100644 --- a/modules/bar/components/OsIcon.qml +++ b/modules/bar/components/OsIcon.qml @@ -1,12 +1,16 @@ +import QtQuick +import Caelestia.Config +import qs.components import qs.components.effects import qs.services -import qs.config import qs.utils -import QtQuick Item { id: root + implicitWidth: Math.round(Tokens.font.size.large * 1.2) + implicitHeight: Math.round(Tokens.font.size.large * 1.2) + MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor @@ -16,13 +20,28 @@ Item { } } - ColouredIcon { + Loader { + asynchronous: true anchors.centerIn: parent - source: SysInfo.osLogo - implicitSize: Appearance.font.size.large * 1.2 - colour: Colours.palette.m3tertiary + sourceComponent: SysInfo.isDefaultLogo ? caelestiaLogo : distroIcon + } + + Component { + id: caelestiaLogo + + Logo { + implicitWidth: Math.round(Tokens.font.size.large * 1.6) + implicitHeight: Math.round(Tokens.font.size.large * 1.6) + } } - implicitWidth: Appearance.font.size.large * 1.2 - implicitHeight: Appearance.font.size.large * 1.2 + Component { + id: distroIcon + + ColouredIcon { + source: SysInfo.osLogo + implicitSize: Math.round(Tokens.font.size.large * 1.2) + colour: Colours.palette.m3tertiary + } + } } diff --git a/modules/bar/components/Power.qml b/modules/bar/components/Power.qml index 917bdf7fd..681a805d7 100644 --- a/modules/bar/components/Power.qml +++ b/modules/bar/components/Power.qml @@ -1,15 +1,14 @@ +import QtQuick +import Caelestia.Config import qs.components import qs.services -import qs.config -import Quickshell -import QtQuick Item { id: root - required property PersistentProperties visibilities + required property DrawerVisibilities visibilities - implicitWidth: icon.implicitHeight + Appearance.padding.small * 2 + implicitWidth: icon.implicitHeight + Tokens.padding.small * 2 implicitHeight: icon.implicitHeight StateLayer { @@ -17,13 +16,9 @@ Item { anchors.fill: undefined anchors.centerIn: parent implicitWidth: implicitHeight - implicitHeight: icon.implicitHeight + Appearance.padding.small * 2 - - radius: Appearance.rounding.full - - function onClicked(): void { - root.visibilities.session = !root.visibilities.session; - } + implicitHeight: icon.implicitHeight + Tokens.padding.small * 2 + radius: Tokens.rounding.full + onClicked: root.visibilities.session = !root.visibilities.session } MaterialIcon { @@ -35,6 +30,6 @@ Item { text: "power_settings_new" color: Colours.palette.m3error font.bold: true - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.font.size.normal } } diff --git a/modules/bar/components/Settings.qml b/modules/bar/components/Settings.qml deleted file mode 100644 index 5d562cef1..000000000 --- a/modules/bar/components/Settings.qml +++ /dev/null @@ -1,41 +0,0 @@ -import qs.components -import qs.modules.controlcenter -import qs.services -import qs.config -import Quickshell -import QtQuick - -Item { - id: root - - implicitWidth: icon.implicitHeight + Appearance.padding.small * 2 - implicitHeight: icon.implicitHeight - - StateLayer { - // Cursed workaround to make the height larger than the parent - anchors.fill: undefined - anchors.centerIn: parent - implicitWidth: implicitHeight - implicitHeight: icon.implicitHeight + Appearance.padding.small * 2 - - radius: Appearance.rounding.full - - function onClicked(): void { - WindowFactory.create(null, { - active: "network" - }); - } - } - - MaterialIcon { - id: icon - - anchors.centerIn: parent - anchors.horizontalCenterOffset: -1 - - text: "settings" - color: Colours.palette.m3onSurface - font.bold: true - font.pointSize: Appearance.font.size.normal - } -} diff --git a/modules/bar/components/SettingsIcon.qml b/modules/bar/components/SettingsIcon.qml deleted file mode 100644 index 5d562cef1..000000000 --- a/modules/bar/components/SettingsIcon.qml +++ /dev/null @@ -1,41 +0,0 @@ -import qs.components -import qs.modules.controlcenter -import qs.services -import qs.config -import Quickshell -import QtQuick - -Item { - id: root - - implicitWidth: icon.implicitHeight + Appearance.padding.small * 2 - implicitHeight: icon.implicitHeight - - StateLayer { - // Cursed workaround to make the height larger than the parent - anchors.fill: undefined - anchors.centerIn: parent - implicitWidth: implicitHeight - implicitHeight: icon.implicitHeight + Appearance.padding.small * 2 - - radius: Appearance.rounding.full - - function onClicked(): void { - WindowFactory.create(null, { - active: "network" - }); - } - } - - MaterialIcon { - id: icon - - anchors.centerIn: parent - anchors.horizontalCenterOffset: -1 - - text: "settings" - color: Colours.palette.m3onSurface - font.bold: true - font.pointSize: Appearance.font.size.normal - } -} diff --git a/modules/bar/components/StatusIcons.qml b/modules/bar/components/StatusIcons.qml index ca7dc2e3a..900e55747 100644 --- a/modules/bar/components/StatusIcons.qml +++ b/modules/bar/components/StatusIcons.qml @@ -1,14 +1,14 @@ pragma ComponentBehavior: Bound -import qs.components -import qs.services -import qs.utils -import qs.config +import QtQuick +import QtQuick.Layouts import Quickshell import Quickshell.Bluetooth import Quickshell.Services.UPower -import QtQuick -import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.services +import qs.utils StyledRect { id: root @@ -17,11 +17,11 @@ StyledRect { readonly property alias items: iconColumn color: Colours.tPalette.m3surfaceContainer - radius: Appearance.rounding.full + radius: Tokens.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) + implicitWidth: Tokens.sizes.bar.innerWidth + implicitHeight: iconColumn.implicitHeight + Tokens.padding.normal * 2 - (Config.bar.status.showLockStatus && !Hypr.capsLock && !Hypr.numLock ? iconColumn.spacing : 0) ColumnLayout { id: iconColumn @@ -29,9 +29,9 @@ StyledRect { anchors.left: parent.left anchors.right: parent.right anchors.bottom: parent.bottom - anchors.bottomMargin: Appearance.padding.normal + anchors.bottomMargin: Tokens.padding.normal - spacing: Appearance.spacing.smaller / 2 + spacing: Tokens.spacing.smaller / 2 // Lock keys status WrappedLoader { @@ -136,7 +136,7 @@ StyledRect { animate: true text: Hypr.kbLayout color: root.colour - font.family: Appearance.font.family.mono + font.family: Tokens.font.family.mono } } @@ -172,15 +172,15 @@ StyledRect { active: Config.bar.status.showBluetooth sourceComponent: ColumnLayout { - spacing: Appearance.spacing.smaller / 2 + spacing: Tokens.spacing.smaller / 2 // Bluetooth icon MaterialIcon { animate: true text: { - if (!Bluetooth.defaultAdapter?.enabled) + if (!Bluetooth.defaultAdapter?.enabled) // qmllint disable unresolved-type return "bluetooth_disabled"; - if (Bluetooth.devices.values.some(d => d.connected)) + if (Bluetooth.devices.values.some(d => d.connected)) // qmllint disable unresolved-type return "bluetooth_connected"; return "bluetooth"; } @@ -190,7 +190,7 @@ StyledRect { // Connected bluetooth devices Repeater { model: ScriptModel { - values: Bluetooth.devices.values.filter(d => d.state !== BluetoothDeviceState.Disconnected) + values: Bluetooth.devices.values.filter(d => d.state !== BluetoothDeviceState.Disconnected) // qmllint disable unresolved-type } MaterialIcon { @@ -204,21 +204,21 @@ StyledRect { fill: 1 SequentialAnimation on opacity { - running: device.modelData?.state !== BluetoothDeviceState.Connected + running: device.modelData?.state !== BluetoothDeviceState.Connected // qmllint disable unresolved-type alwaysRunToEnd: true loops: Animation.Infinite Anim { from: 1 to: 0 - duration: Appearance.anim.durations.large - easing.bezierCurve: Appearance.anim.curves.standardAccel + duration: Tokens.anim.durations.large + easing: Tokens.anim.standardAccel } Anim { from: 0 to: 1 - duration: Appearance.anim.durations.large - easing.bezierCurve: Appearance.anim.curves.standardDecel + duration: Tokens.anim.durations.large + easing: Tokens.anim.standardDecel } } } @@ -264,6 +264,7 @@ StyledRect { component WrappedLoader: Loader { required property string name + asynchronous: true Layout.alignment: Qt.AlignHCenter visible: active } diff --git a/modules/bar/components/Tray.qml b/modules/bar/components/Tray.qml index 96956f6f4..85a920c13 100644 --- a/modules/bar/components/Tray.qml +++ b/modules/bar/components/Tray.qml @@ -1,10 +1,11 @@ pragma ComponentBehavior: Bound +import QtQuick +import Quickshell +import Quickshell.Services.SystemTray +import Caelestia.Config import qs.components import qs.services -import qs.config -import Quickshell.Services.SystemTray -import QtQuick StyledRect { id: root @@ -13,8 +14,8 @@ StyledRect { 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 + readonly property int padding: Config.bar.tray.background ? Tokens.padding.normal : Tokens.padding.small + readonly property int spacing: Config.bar.tray.background ? Tokens.spacing.small : 0 property bool expanded @@ -27,11 +28,11 @@ StyledRect { clip: true visible: height > 0 - implicitWidth: Config.bar.sizes.innerWidth + implicitWidth: Tokens.sizes.bar.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 + radius: Tokens.rounding.full Column { id: layout @@ -39,7 +40,7 @@ StyledRect { anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top anchors.topMargin: root.padding - spacing: Appearance.spacing.small + spacing: Tokens.spacing.small opacity: root.expanded || !Config.bar.tray.compact ? 1 : 0 @@ -48,7 +49,7 @@ StyledRect { properties: "scale" from: 0 to: 1 - easing.bezierCurve: Appearance.anim.curves.standardDecel + easing: Tokens.anim.standardDecel } } @@ -56,7 +57,7 @@ StyledRect { Anim { properties: "scale" to: 1 - easing.bezierCurve: Appearance.anim.curves.standardDecel + easing: Tokens.anim.standardDecel } Anim { properties: "x,y" @@ -66,7 +67,9 @@ StyledRect { Repeater { id: items - model: SystemTray.items + model: ScriptModel { + values: SystemTray.items.values.filter(i => !GlobalConfig.bar.tray.hiddenIcons.includes(i.id)) + } TrayItem {} } @@ -79,23 +82,25 @@ StyledRect { Loader { id: expandIcon + asynchronous: true + anchors.horizontalCenter: parent.horizontalCenter anchors.bottom: parent.bottom - active: Config.bar.tray.compact + active: Config.bar.tray.compact && items.count > 0 sourceComponent: Item { implicitWidth: expandIconInner.implicitWidth - implicitHeight: expandIconInner.implicitHeight - Appearance.padding.small * 2 + implicitHeight: expandIconInner.implicitHeight - Tokens.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 + anchors.bottomMargin: Config.bar.tray.background ? Tokens.padding.small : -Tokens.padding.small text: "expand_less" - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large rotation: root.expanded ? 180 : 0 Behavior on rotation { @@ -111,8 +116,7 @@ StyledRect { Behavior on implicitHeight { Anim { - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + type: Anim.DefaultSpatial } } } diff --git a/modules/bar/components/TrayItem.qml b/modules/bar/components/TrayItem.qml index 99119073d..fefb532c9 100644 --- a/modules/bar/components/TrayItem.qml +++ b/modules/bar/components/TrayItem.qml @@ -1,11 +1,11 @@ pragma ComponentBehavior: Bound +import QtQuick +import Quickshell.Services.SystemTray +import Caelestia.Config import qs.components.effects import qs.services -import qs.config import qs.utils -import Quickshell.Services.SystemTray -import QtQuick MouseArea { id: root @@ -13,8 +13,8 @@ MouseArea { required property SystemTrayItem modelData acceptedButtons: Qt.LeftButton | Qt.RightButton - implicitWidth: Appearance.font.size.small * 2 - implicitHeight: Appearance.font.size.small * 2 + implicitWidth: Tokens.font.size.small * 2 + implicitHeight: Tokens.font.size.small * 2 onClicked: event => { if (event.button === Qt.LeftButton) diff --git a/modules/bar/components/workspaces/ActiveIndicator.qml b/modules/bar/components/workspaces/ActiveIndicator.qml index dae54b371..ebc0caf26 100644 --- a/modules/bar/components/workspaces/ActiveIndicator.qml +++ b/modules/bar/components/workspaces/ActiveIndicator.qml @@ -1,8 +1,10 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Caelestia.Config import qs.components import qs.components.effects import qs.services -import qs.config -import QtQuick StyledRect { id: root @@ -10,6 +12,7 @@ StyledRect { required property int activeWsId required property Repeater workspaces required property Item mask + required property bool fullscreen readonly property int currentWsIdx: { let i = activeWsId - 1; @@ -20,13 +23,12 @@ StyledRect { property real leading: workspaces.count > 0 ? workspaces.itemAt(currentWsIdx)?.y ?? 0 : 0 property real trailing: workspaces.count > 0 ? workspaces.itemAt(currentWsIdx)?.y ?? 0 : 0 - property real currentSize: workspaces.count > 0 ? workspaces.itemAt(currentWsIdx)?.size ?? 0 : 0 + property real 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); - // console.log(ws, lastWs); + const ws = workspaces.itemAt(lastWs) as Workspace; return ws ? Math.min(ws.y + ws.size - offset, s) : 0; } return s; @@ -42,9 +44,9 @@ StyledRect { clip: true y: offset + mask.y - implicitWidth: Config.bar.sizes.innerWidth - Appearance.padding.small * 2 + implicitWidth: Tokens.sizes.bar.innerWidth - Tokens.padding.small * 2 implicitHeight: size - radius: Appearance.rounding.full + radius: Tokens.rounding.full color: Colours.palette.m3primary Colouriser { @@ -61,38 +63,38 @@ StyledRect { } Behavior on leading { - enabled: Config.bar.workspaces.activeTrail + enabled: root.Config.bar.workspaces.activeTrail EAnim {} } Behavior on trailing { - enabled: Config.bar.workspaces.activeTrail + enabled: root.Config.bar.workspaces.activeTrail EAnim { - duration: Appearance.anim.durations.normal * 2 + duration: Tokens.anim.durations.normal * 2 } } Behavior on currentSize { - enabled: Config.bar.workspaces.activeTrail + enabled: root.Config.bar.workspaces.activeTrail EAnim {} } Behavior on offset { - enabled: !Config.bar.workspaces.activeTrail + enabled: !root.Config.bar.workspaces.activeTrail EAnim {} } Behavior on size { - enabled: !Config.bar.workspaces.activeTrail + enabled: !root.Config.bar.workspaces.activeTrail EAnim {} } component EAnim: Anim { - easing.bezierCurve: Appearance.anim.curves.emphasized + type: Anim.Emphasized } } diff --git a/modules/bar/components/workspaces/OccupiedBg.qml b/modules/bar/components/workspaces/OccupiedBg.qml index 56b215e67..2bd3c8cb8 100644 --- a/modules/bar/components/workspaces/OccupiedBg.qml +++ b/modules/bar/components/workspaces/OccupiedBg.qml @@ -1,10 +1,10 @@ pragma ComponentBehavior: Bound +import QtQuick +import Quickshell +import Caelestia.Config import qs.components import qs.services -import qs.config -import Quickshell -import QtQuick Item { id: root @@ -52,8 +52,8 @@ Item { 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 + readonly property Workspace start: root.workspaces.count > 0 ? root.workspaces.itemAt(getWsIdx(modelData.start)) ?? null : null // qmllint disable incompatible-type + readonly property Workspace end: root.workspaces.count > 0 ? root.workspaces.itemAt(getWsIdx(modelData.end)) ?? null : null // qmllint disable incompatible-type function getWsIdx(ws: int): int { let i = ws - 1; @@ -65,18 +65,18 @@ Item { anchors.horizontalCenter: root.horizontalCenter y: (start?.y ?? 0) - 1 - implicitWidth: Config.bar.sizes.innerWidth - Appearance.padding.small * 2 + 2 + implicitWidth: Tokens.sizes.bar.innerWidth - Tokens.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 + radius: Tokens.rounding.full scale: 0 Component.onCompleted: scale = 1 Behavior on scale { Anim { - easing.bezierCurve: Appearance.anim.curves.standardDecel + easing: Tokens.anim.standardDecel } } @@ -90,14 +90,14 @@ Item { } } - component Pill: QtObject { - property int start - property int end - } - Component { id: pillComp Pill {} } + + component Pill: QtObject { + property int start + property int end + } } diff --git a/modules/bar/components/workspaces/SpecialWorkspaces.qml b/modules/bar/components/workspaces/SpecialWorkspaces.qml index ad85af89e..3ed382e90 100644 --- a/modules/bar/components/workspaces/SpecialWorkspaces.qml +++ b/modules/bar/components/workspaces/SpecialWorkspaces.qml @@ -1,21 +1,21 @@ pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Hyprland +import Caelestia.Config 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 ?? "" + readonly property string activeSpecial: (GlobalConfig.bar.workspaces.perMonitorWorkspaces ? monitor : Hypr.focusedMonitor)?.lastIpcObject.specialWorkspace?.name ?? "" layer.enabled: true layer.effect: OpacityMask { @@ -31,7 +31,7 @@ Item { Rectangle { anchors.fill: parent - radius: Appearance.rounding.full + radius: Tokens.rounding.full gradient: Gradient { orientation: Gradient.Vertical @@ -60,7 +60,7 @@ Item { anchors.left: parent.left anchors.right: parent.right - radius: Appearance.rounding.full + radius: Tokens.rounding.full implicitHeight: parent.height / 2 opacity: view.contentY > 0 ? 0 : 1 @@ -74,9 +74,9 @@ Item { anchors.left: parent.left anchors.right: parent.right - radius: Appearance.rounding.full + radius: Tokens.rounding.full implicitHeight: parent.height / 2 - opacity: view.contentY < view.contentHeight - parent.height + Appearance.padding.small ? 0 : 1 + opacity: view.contentY < view.contentHeight - parent.height + Tokens.padding.small ? 0 : 1 Behavior on opacity { Anim {} @@ -88,14 +88,14 @@ Item { id: view anchors.fill: parent - spacing: Appearance.spacing.normal + spacing: Tokens.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)) + values: Hypr.workspaces.values.filter(w => w.name.startsWith("special:") && (!GlobalConfig.bar.workspaces.perMonitorWorkspaces || w.monitor === root.monitor)) } preferredHighlightBegin: 0 @@ -105,150 +105,21 @@ Item { highlightFollowsCurrentItem: false highlight: Item { y: view.currentItem?.y ?? 0 - implicitHeight: view.currentItem?.size ?? 0 + implicitHeight: (view.currentItem as SpecialWsDelegate)?.size ?? 0 Behavior on y { Anim {} } } - delegate: ColumnLayout { - id: ws - - required property HyprlandWorkspace modelData - readonly property int size: label.Layout.preferredHeight + (hasWindows ? windows.implicitHeight + Appearance.padding.small : 0) - property int wsId - property string icon - property bool hasWindows - - anchors.left: view.contentItem.left - anchors.right: view.contentItem.right - - spacing: 0 - - Component.onCompleted: { - wsId = modelData.id; - icon = Icons.getSpecialWsIcon(modelData.name); - hasWindows = Config.bar.workspaces.showWindowsOnSpecialWorkspaces && modelData.lastIpcObject.windows > 0; - } - - // Hacky thing cause modelData gets destroyed before the remove anim finishes - Connections { - target: ws.modelData - - function onIdChanged(): void { - if (ws.modelData) - ws.wsId = ws.modelData.id; - } - - function onNameChanged(): void { - if (ws.modelData) - ws.icon = Icons.getSpecialWsIcon(ws.modelData.name); - } - - function onLastIpcObjectChanged(): void { - if (ws.modelData) - ws.hasWindows = Config.bar.workspaces.showWindowsOnSpecialWorkspaces && ws.modelData.lastIpcObject.windows > 0; - } - } - - Connections { - target: Config.bar.workspaces - - function onShowWindowsOnSpecialWorkspacesChanged(): void { - if (ws.modelData) - ws.hasWindows = Config.bar.workspaces.showWindowsOnSpecialWorkspaces && ws.modelData.lastIpcObject.windows > 0; - } - } - - Loader { - id: label - - Layout.alignment: Qt.AlignHCenter | Qt.AlignTop - Layout.preferredHeight: Config.bar.sizes.innerWidth - Appearance.padding.small * 2 - - sourceComponent: ws.icon.length === 1 ? letterComp : iconComp - - Component { - id: iconComp - - MaterialIcon { - fill: 1 - text: ws.icon - verticalAlignment: Qt.AlignVCenter - } - } - - Component { - id: letterComp - - StyledText { - text: ws.icon - verticalAlignment: Qt.AlignVCenter - } - } - } - - Loader { - id: windows - - Layout.alignment: Qt.AlignHCenter - Layout.fillHeight: true - Layout.preferredHeight: implicitHeight - - visible: active - active: ws.hasWindows - - sourceComponent: Column { - spacing: 0 - - add: Transition { - Anim { - properties: "scale" - from: 0 - to: 1 - easing.bezierCurve: Appearance.anim.curves.standardDecel - } - } - - move: Transition { - Anim { - properties: "scale" - to: 1 - easing.bezierCurve: Appearance.anim.curves.standardDecel - } - Anim { - properties: "x,y" - } - } - - Repeater { - model: ScriptModel { - values: Hypr.toplevels.values.filter(c => c.workspace?.id === ws.wsId) - } - - MaterialIcon { - required property var modelData - - grade: 0 - text: Icons.getAppCategoryIcon(modelData.lastIpcObject.class, "terminal") - color: Colours.palette.m3onSurfaceVariant - } - } - } - - Behavior on Layout.preferredHeight { - Anim {} - } - } - } + delegate: SpecialWsDelegate {} add: Transition { Anim { properties: "scale" from: 0 to: 1 - easing.bezierCurve: Appearance.anim.curves.standardDecel + easing: Tokens.anim.standardDecel } } @@ -256,12 +127,12 @@ Item { Anim { property: "scale" to: 0.5 - duration: Appearance.anim.durations.small + type: Anim.StandardSmall } Anim { property: "opacity" to: 0 - duration: Appearance.anim.durations.small + type: Anim.StandardSmall } } @@ -269,7 +140,7 @@ Item { Anim { properties: "scale" to: 1 - easing.bezierCurve: Appearance.anim.curves.standardDecel + easing: Tokens.anim.standardDecel } Anim { properties: "x,y" @@ -280,7 +151,7 @@ Item { Anim { properties: "scale" to: 1 - easing.bezierCurve: Appearance.anim.curves.standardDecel + easing: Tokens.anim.standardDecel } Anim { properties: "x,y" @@ -289,6 +160,7 @@ Item { } Loader { + asynchronous: true active: Config.bar.workspaces.activeIndicator anchors.fill: parent @@ -300,10 +172,10 @@ Item { anchors.right: parent.right y: (view.currentItem?.y ?? 0) - view.contentY - implicitHeight: view.currentItem?.size ?? 0 + implicitHeight: (view.currentItem as SpecialWsDelegate)?.size ?? 0 color: Colours.palette.m3tertiary - radius: Appearance.rounding.full + radius: Tokens.rounding.full Colouriser { source: view @@ -320,13 +192,13 @@ Item { Behavior on y { Anim { - easing.bezierCurve: Appearance.anim.curves.emphasized + type: Anim.Emphasized } } Behavior on implicitHeight { Anim { - easing.bezierCurve: Appearance.anim.curves.emphasized + type: Anim.Emphasized } } } @@ -341,7 +213,7 @@ Item { drag.target: view.contentItem drag.axis: Drag.YAxis drag.maximumY: 0 - drag.minimumY: Math.min(0, view.height - view.contentHeight - Appearance.padding.small) + drag.minimumY: Math.min(0, view.height - view.contentHeight - Tokens.padding.small) onPressed: event => startY = event.y @@ -349,11 +221,150 @@ Item { if (Math.abs(event.y - startY) > drag.threshold) return; - const ws = view.itemAt(event.x, event.y); + 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"); } } + + component SpecialWsDelegate: ColumnLayout { + id: ws + + required property HyprlandWorkspace modelData + readonly property int size: label.Layout.preferredHeight + (hasWindows ? windows.implicitHeight + Tokens.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 = root.Config.bar.workspaces.showWindowsOnSpecialWorkspaces && ws.modelData.lastIpcObject.windows > 0; + } + + target: ws.modelData + } + + Connections { + function onShowWindowsOnSpecialWorkspacesChanged(): void { + if (ws.modelData) + ws.hasWindows = root.Config.bar.workspaces.showWindowsOnSpecialWorkspaces && ws.modelData.lastIpcObject.windows > 0; + } + + target: root.Config.bar.workspaces + } + + Loader { + id: label + + asynchronous: true + + Layout.alignment: Qt.AlignHCenter | Qt.AlignTop + Layout.preferredHeight: Tokens.sizes.bar.innerWidth - Tokens.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: Tokens.anim.standardDecel + } + } + + move: Transition { + Anim { + properties: "scale" + to: 1 + easing: Tokens.anim.standardDecel + } + Anim { + properties: "x,y" + } + } + + Repeater { + model: ScriptModel { + values: { + const windows = Hypr.toplevels.values.filter(c => c.workspace?.id === ws.wsId); + const maxIcons = root.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 {} + } + } + } } diff --git a/modules/bar/components/workspaces/Workspace.qml b/modules/bar/components/workspaces/Workspace.qml index 3c8238b57..bd581aab1 100644 --- a/modules/bar/components/workspaces/Workspace.qml +++ b/modules/bar/components/workspaces/Workspace.qml @@ -1,10 +1,12 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell +import Caelestia.Config import qs.components import qs.services import qs.utils -import qs.config -import Quickshell -import QtQuick -import QtQuick.Layouts ColumnLayout { id: root @@ -16,7 +18,7 @@ ColumnLayout { 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 size: implicitHeight + (hasWindows ? Tokens.padding.small : 0) readonly property int ws: groupOffset + index + 1 readonly property bool isOccupied: occupied[ws] ?? false @@ -31,7 +33,7 @@ ColumnLayout { id: indicator Layout.alignment: Qt.AlignHCenter | Qt.AlignTop - Layout.preferredHeight: Config.bar.sizes.innerWidth - Appearance.padding.small * 2 + Layout.preferredHeight: Tokens.sizes.bar.innerWidth - Tokens.padding.small * 2 animate: true text: { @@ -55,9 +57,11 @@ ColumnLayout { Loader { id: windows + asynchronous: true + Layout.alignment: Qt.AlignHCenter Layout.fillHeight: true - Layout.topMargin: -Config.bar.sizes.innerWidth / 10 + Layout.topMargin: -Tokens.sizes.bar.innerWidth / 10 visible: active active: root.hasWindows @@ -70,7 +74,7 @@ ColumnLayout { properties: "scale" from: 0 to: 1 - easing.bezierCurve: Appearance.anim.curves.standardDecel + easing: Tokens.anim.standardDecel } } @@ -78,7 +82,7 @@ ColumnLayout { Anim { properties: "scale" to: 1 - easing.bezierCurve: Appearance.anim.curves.standardDecel + easing: Tokens.anim.standardDecel } Anim { properties: "x,y" @@ -87,7 +91,12 @@ ColumnLayout { Repeater { model: ScriptModel { - values: Hypr.toplevels.values.filter(c => c.workspace?.id === root.ws) + values: { + const ws = root.ws; + const windows = Hypr.toplevels.values.filter(c => c.workspace?.id === ws); + const maxIcons = root.Config.bar.workspaces.maxWindowIcons; + return maxIcons > 0 ? windows.slice(0, maxIcons) : windows; + } } MaterialIcon { diff --git a/modules/bar/components/workspaces/Workspaces.qml b/modules/bar/components/workspaces/Workspaces.qml index bfa80ab68..2030bf80e 100644 --- a/modules/bar/components/workspaces/Workspaces.qml +++ b/modules/bar/components/workspaces/Workspaces.qml @@ -1,39 +1,43 @@ pragma ComponentBehavior: Bound -import qs.services -import qs.config -import qs.components -import Quickshell import QtQuick -import QtQuick.Layouts import QtQuick.Effects +import QtQuick.Layouts +import Quickshell +import Caelestia.Config +import qs.components +import qs.services StyledClippingRect { id: root required property ShellScreen screen + required property bool fullscreen - 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 bool onSpecial: (GlobalConfig.bar.workspaces.perMonitorWorkspaces ? Hypr.monitorFor(screen) : Hypr.focusedMonitor)?.lastIpcObject.specialWorkspace?.name !== "" + readonly property int activeWsId: GlobalConfig.bar.workspaces.perMonitorWorkspaces ? (Hypr.monitorFor(screen).activeWorkspace?.id ?? 1) : Hypr.activeWsId - readonly property var occupied: Hypr.workspaces.values.reduce((acc, curr) => { - acc[curr.id] = curr.lastIpcObject.windows > 0; - return acc; - }, {}) + readonly property 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 + implicitWidth: Tokens.sizes.bar.innerWidth + implicitHeight: layout.implicitHeight + Tokens.padding.small * 2 color: Colours.tPalette.m3surfaceContainer - radius: Appearance.rounding.full + radius: Tokens.rounding.full Item { anchors.fill: parent scale: root.onSpecial ? 0.8 : 1 opacity: root.onSpecial ? 0.5 : 1 + visible: !root.fullscreen layer.enabled: root.blur > 0 layer.effect: MultiEffect { @@ -43,10 +47,11 @@ StyledClippingRect { } Loader { + asynchronous: true active: Config.bar.workspaces.occupiedBg anchors.fill: parent - anchors.margins: Appearance.padding.small + anchors.margins: Tokens.padding.small sourceComponent: OccupiedBg { workspaces: workspaces @@ -59,7 +64,7 @@ StyledClippingRect { id: layout anchors.centerIn: parent - spacing: Math.floor(Appearance.spacing.small / 2) + spacing: Math.floor(Tokens.spacing.small / 2) Repeater { id: workspaces @@ -75,6 +80,7 @@ StyledClippingRect { } Loader { + asynchronous: true anchors.horizontalCenter: parent.horizontalCenter active: Config.bar.workspaces.activeIndicator @@ -82,13 +88,14 @@ StyledClippingRect { activeWsId: root.activeWsId workspaces: workspaces mask: layout + fullscreen: root.fullscreen } } MouseArea { anchors.fill: layout onClicked: event => { - const ws = layout.childAt(event.x, event.y).ws; + const ws = (layout.childAt(event.x, event.y) as Workspace)?.ws; if (Hypr.activeWsId !== ws) Hypr.dispatch(`workspace ${ws}`); else @@ -108,8 +115,10 @@ StyledClippingRect { Loader { id: specialWs + asynchronous: true + anchors.fill: parent - anchors.margins: Appearance.padding.small + anchors.margins: Tokens.padding.small active: opacity > 0 @@ -131,7 +140,7 @@ StyledClippingRect { Behavior on blur { Anim { - duration: Appearance.anim.durations.small + type: Anim.StandardSmall } } } diff --git a/modules/bar/popouts/ActiveWindow.qml b/modules/bar/popouts/ActiveWindow.qml index adf7b7740..122466451 100644 --- a/modules/bar/popouts/ActiveWindow.qml +++ b/modules/bar/popouts/ActiveWindow.qml @@ -1,36 +1,37 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell.Wayland +import Quickshell.Widgets +import Caelestia.Config import qs.components import qs.services import qs.utils -import qs.config -import Quickshell.Widgets -import Quickshell.Wayland -import QtQuick -import QtQuick.Layouts Item { id: root - required property Item wrapper + required property PopoutState popouts - implicitWidth: Hypr.activeToplevel ? child.implicitWidth : -Appearance.padding.large * 2 + implicitWidth: Hypr.activeToplevel ? child.implicitWidth : -Tokens.padding.large * 2 implicitHeight: child.implicitHeight Column { id: child anchors.centerIn: parent - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal RowLayout { id: detailsRow anchors.left: parent.left anchors.right: parent.right - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal IconImage { id: icon + asynchronous: true Layout.alignment: Qt.AlignVCenter implicitSize: details.implicitHeight source: Icons.getAppIcon(Hypr.activeToplevel?.lastIpcObject.class ?? "", "image-missing") @@ -45,7 +46,7 @@ Item { StyledText { Layout.fillWidth: true text: Hypr.activeToplevel?.title ?? "" - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.font.size.normal elide: Text.ElideRight } @@ -58,17 +59,14 @@ Item { } Item { - implicitWidth: expandIcon.implicitHeight + Appearance.padding.small * 2 - implicitHeight: expandIcon.implicitHeight + Appearance.padding.small * 2 + implicitWidth: expandIcon.implicitHeight + Tokens.padding.small * 2 + implicitHeight: expandIcon.implicitHeight + Tokens.padding.small * 2 Layout.alignment: Qt.AlignVCenter StateLayer { - radius: Appearance.rounding.normal - - function onClicked(): void { - root.wrapper.detach("winfo"); - } + radius: Tokens.rounding.normal + onClicked: root.popouts.detachRequested("winfo") } MaterialIcon { @@ -79,23 +77,23 @@ Item { text: "chevron_right" - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large } } } ClippingWrapperRectangle { color: "transparent" - radius: Appearance.rounding.small + radius: Tokens.rounding.small ScreencopyView { id: preview - captureSource: Hypr.activeToplevel?.wayland ?? null + captureSource: Hypr.activeToplevel?.wayland ?? null // qmllint disable unresolved-type live: visible - constraintSize.width: Config.bar.sizes.windowPreviewSize - constraintSize.height: Config.bar.sizes.windowPreviewSize + constraintSize.width: Tokens.sizes.bar.windowPreviewSize + constraintSize.height: Tokens.sizes.bar.windowPreviewSize } } } diff --git a/modules/bar/popouts/Audio.qml b/modules/bar/popouts/Audio.qml index 58b29ba8d..e8dc92032 100644 --- a/modules/bar/popouts/Audio.qml +++ b/modules/bar/popouts/Audio.qml @@ -1,23 +1,21 @@ pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell.Services.Pipewire +import Caelestia.Config import qs.components import qs.components.controls import qs.services -import qs.config -import Quickshell -import Quickshell.Services.Pipewire -import QtQuick -import QtQuick.Layouts -import QtQuick.Controls -import "../../controlcenter/network" Item { id: root - required property var wrapper + required property PopoutState popouts - implicitWidth: layout.implicitWidth + Appearance.padding.normal * 2 - implicitHeight: layout.implicitHeight + Appearance.padding.normal * 2 + implicitWidth: layout.implicitWidth + Tokens.padding.normal * 2 + implicitHeight: layout.implicitHeight + Tokens.padding.normal * 2 ButtonGroup { id: sinks @@ -32,7 +30,7 @@ Item { anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal StyledText { text: qsTr("Output device") @@ -55,7 +53,7 @@ Item { } StyledText { - Layout.topMargin: Appearance.spacing.smaller + Layout.topMargin: Tokens.spacing.smaller text: qsTr("Input device") font.weight: 500 } @@ -74,15 +72,15 @@ Item { } StyledText { - Layout.topMargin: Appearance.spacing.smaller - Layout.bottomMargin: -Appearance.spacing.small / 2 + Layout.topMargin: Tokens.spacing.smaller + Layout.bottomMargin: -Tokens.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 + implicitHeight: Tokens.padding.normal * 3 onWheel: event => { if (event.angleDelta.y > 0) @@ -107,14 +105,14 @@ Item { IconTextButton { Layout.fillWidth: true - Layout.topMargin: Appearance.spacing.normal + Layout.topMargin: Tokens.spacing.normal inactiveColour: Colours.palette.m3primaryContainer inactiveOnColour: Colours.palette.m3onPrimaryContainer - verticalPadding: Appearance.padding.small + verticalPadding: Tokens.padding.small text: qsTr("Open settings") icon: "settings" - onClicked: root.wrapper.detach("audio") + onClicked: root.popouts.detachRequested("audio") } } } diff --git a/modules/bar/popouts/Background.qml b/modules/bar/popouts/Background.qml deleted file mode 100644 index 075b69881..000000000 --- a/modules/bar/popouts/Background.qml +++ /dev/null @@ -1,73 +0,0 @@ -import qs.components -import qs.services -import qs.config -import QtQuick -import QtQuick.Shapes - -ShapePath { - id: root - - required property Wrapper wrapper - required property bool invertBottomRounding - readonly property real rounding: wrapper.isDetached ? Appearance.rounding.normal : Config.border.rounding - readonly property bool flatten: wrapper.width < rounding * 2 - readonly property real roundingX: flatten ? wrapper.width / 2 : rounding - property real ibr: invertBottomRounding ? -1 : 1 - - property real sideRounding: startX > 0 ? -1 : 1 - - strokeWidth: -1 - fillColor: Colours.palette.m3surface - - PathArc { - relativeX: root.roundingX - relativeY: root.rounding * root.sideRounding - radiusX: Math.min(root.rounding, root.wrapper.width) - radiusY: root.rounding - direction: root.sideRounding < 0 ? PathArc.Clockwise : PathArc.Counterclockwise - } - PathLine { - relativeX: root.wrapper.width - root.roundingX * 2 - relativeY: 0 - } - PathArc { - relativeX: root.roundingX - relativeY: root.rounding - radiusX: Math.min(root.rounding, root.wrapper.width) - radiusY: root.rounding - } - PathLine { - relativeX: 0 - relativeY: root.wrapper.height - root.rounding * 2 - } - PathArc { - relativeX: -root.roundingX * root.ibr - relativeY: root.rounding - radiusX: Math.min(root.rounding, root.wrapper.width) - radiusY: root.rounding - direction: root.ibr < 0 ? PathArc.Counterclockwise : PathArc.Clockwise - } - PathLine { - relativeX: -(root.wrapper.width - root.roundingX - root.roundingX * root.ibr) - relativeY: 0 - } - PathArc { - relativeX: -root.roundingX - relativeY: root.rounding * root.sideRounding - radiusX: Math.min(root.rounding, root.wrapper.width) - radiusY: root.rounding - direction: root.sideRounding < 0 ? PathArc.Clockwise : PathArc.Counterclockwise - } - - Behavior on fillColor { - CAnim {} - } - - Behavior on ibr { - Anim {} - } - - Behavior on sideRounding { - Anim {} - } -} diff --git a/modules/bar/popouts/Battery.qml b/modules/bar/popouts/Battery.qml index ac975e1b7..93d2012bd 100644 --- a/modules/bar/popouts/Battery.qml +++ b/modules/bar/popouts/Battery.qml @@ -1,16 +1,16 @@ pragma ComponentBehavior: Bound +import QtQuick +import Quickshell.Services.UPower +import Caelestia.Config 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 + spacing: Tokens.spacing.normal + width: Tokens.sizes.bar.batteryWidth StyledText { text: UPower.displayDevice.isLaptopBattery ? qsTr("Remaining: %1%").arg(Math.round(UPower.displayDevice.percentage * 100)) : qsTr("No battery detected") @@ -37,18 +37,19 @@ Column { } Loader { + asynchronous: true anchors.horizontalCenter: parent.horizontalCenter active: PowerProfiles.degradationReason !== PerformanceDegradationReason.None - height: active ? (item?.implicitHeight ?? 0) : 0 + height: active ? ((item as Item)?.implicitHeight ?? 0) : 0 sourceComponent: StyledRect { - implicitWidth: child.implicitWidth + Appearance.padding.normal * 2 - implicitHeight: child.implicitHeight + Appearance.padding.smaller * 2 + implicitWidth: child.implicitWidth + Tokens.padding.normal * 2 + implicitHeight: child.implicitHeight + Tokens.padding.smaller * 2 color: Colours.palette.m3error - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal Column { id: child @@ -57,7 +58,7 @@ Column { Row { anchors.horizontalCenter: parent.horizontalCenter - spacing: Appearance.spacing.small + spacing: Tokens.spacing.small MaterialIcon { anchors.verticalCenter: parent.verticalCenter @@ -71,7 +72,7 @@ Column { anchors.verticalCenter: parent.verticalCenter text: qsTr("Performance Degraded") color: Colours.palette.m3onError - font.family: Appearance.font.family.mono + font.family: Tokens.font.family.mono font.weight: 500 } @@ -108,17 +109,17 @@ Column { 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 + implicitWidth: saver.implicitHeight + balance.implicitHeight + perf.implicitHeight + Tokens.padding.normal * 2 + Tokens.spacing.large * 2 + implicitHeight: Math.max(saver.implicitHeight, balance.implicitHeight, perf.implicitHeight) + Tokens.padding.small * 2 color: Colours.tPalette.m3surfaceContainer - radius: Appearance.rounding.full + radius: Tokens.rounding.full StyledRect { id: indicator color: Colours.palette.m3primary - radius: Appearance.rounding.full + radius: Tokens.rounding.full state: profiles.current states: [ @@ -146,10 +147,8 @@ Column { ] transitions: Transition { - AnchorAnimation { - duration: Appearance.anim.durations.normal - easing.type: Easing.BezierSpline - easing.bezierCurve: Appearance.anim.curves.emphasized + AnchorAnim { + type: AnchorAnim.Emphasized } } } @@ -159,7 +158,7 @@ Column { anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left - anchors.leftMargin: Appearance.padding.small + anchors.leftMargin: Tokens.padding.small profile: PowerProfile.PowerSaver icon: "energy_savings_leaf" @@ -179,7 +178,7 @@ Column { anchors.verticalCenter: parent.verticalCenter anchors.right: parent.right - anchors.rightMargin: Appearance.padding.small + anchors.rightMargin: Tokens.padding.small profile: PowerProfile.Performance icon: "rocket_launch" @@ -200,16 +199,13 @@ Column { required property string icon required property int profile - implicitWidth: icon.implicitHeight + Appearance.padding.small * 2 - implicitHeight: icon.implicitHeight + Appearance.padding.small * 2 + implicitWidth: icon.implicitHeight + Tokens.padding.small * 2 + implicitHeight: icon.implicitHeight + Tokens.padding.small * 2 StateLayer { - radius: Appearance.rounding.full + radius: Tokens.rounding.full color: profiles.current === parent.icon ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface - - function onClicked(): void { - PowerProfiles.profile = parent.profile; - } + onClicked: PowerProfiles.profile = parent.profile } MaterialIcon { @@ -218,7 +214,7 @@ Column { anchors.centerIn: parent text: parent.icon - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large color: profiles.current === text ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface fill: profiles.current === text ? 1 : 0 diff --git a/modules/bar/popouts/Bluetooth.qml b/modules/bar/popouts/Bluetooth.qml index 676da82f5..baca115ea 100644 --- a/modules/bar/popouts/Bluetooth.qml +++ b/modules/bar/popouts/Bluetooth.qml @@ -1,35 +1,35 @@ pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Bluetooth +import Caelestia.Config import qs.components import qs.components.controls import qs.services -import qs.config import qs.utils -import Quickshell -import Quickshell.Bluetooth -import QtQuick -import QtQuick.Layouts -import "../../controlcenter/network" ColumnLayout { id: root - required property Item wrapper + required property PopoutState popouts - spacing: Appearance.spacing.small + width: 300 + spacing: Tokens.spacing.small StyledText { - Layout.topMargin: Appearance.padding.normal - Layout.rightMargin: Appearance.padding.small + Layout.topMargin: Tokens.padding.normal + Layout.rightMargin: Tokens.padding.small text: qsTr("Bluetooth") font.weight: 500 } Toggle { label: qsTr("Enabled") - checked: Bluetooth.defaultAdapter?.enabled ?? false + checked: Bluetooth.defaultAdapter?.enabled ?? false // qmllint disable unresolved-type toggle.onToggled: { - const adapter = Bluetooth.defaultAdapter; + const adapter = Bluetooth.defaultAdapter; // qmllint disable unresolved-type if (adapter) adapter.enabled = checked; } @@ -37,19 +37,19 @@ ColumnLayout { Toggle { label: qsTr("Discovering") - checked: Bluetooth.defaultAdapter?.discovering ?? false + checked: Bluetooth.defaultAdapter?.discovering ?? false // qmllint disable unresolved-type toggle.onToggled: { - const adapter = Bluetooth.defaultAdapter; + const adapter = Bluetooth.defaultAdapter; // qmllint disable unresolved-type if (adapter) adapter.discovering = checked; } } StyledText { - Layout.topMargin: Appearance.spacing.small - Layout.rightMargin: Appearance.padding.small + Layout.topMargin: Tokens.spacing.small + Layout.rightMargin: Tokens.padding.small text: { - const devices = Bluetooth.devices.values; + const devices = Bluetooth.devices.values; // qmllint disable unresolved-type 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) @@ -57,23 +57,23 @@ ColumnLayout { return available; } color: Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.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) + values: [...Bluetooth.devices.values].sort((a, b) => (b.connected - a.connected) || (b.paired - a.paired) || a.name.localeCompare(b.name)).slice(0, 5) // qmllint disable unresolved-type } RowLayout { id: device required property BluetoothDevice modelData - readonly property bool loading: modelData.state === BluetoothDeviceState.Connecting || modelData.state === BluetoothDeviceState.Disconnecting + readonly property bool loading: modelData.state === BluetoothDeviceState.Connecting || modelData.state === BluetoothDeviceState.Disconnecting // qmllint disable unresolved-type Layout.fillWidth: true - Layout.rightMargin: Appearance.padding.small - spacing: Appearance.spacing.small + Layout.rightMargin: Tokens.padding.small + spacing: Tokens.spacing.small opacity: 0 scale: 0.7 @@ -96,20 +96,27 @@ ColumnLayout { } StyledText { - Layout.leftMargin: Appearance.spacing.small / 2 - Layout.rightMargin: Appearance.spacing.small / 2 + Layout.leftMargin: Tokens.spacing.small / 2 + Layout.rightMargin: Tokens.spacing.small / 2 Layout.fillWidth: true text: device.modelData.name + elide: Text.ElideRight + } + + MaterialIcon { + visible: device.modelData.state === BluetoothDeviceState.Connected // qmllint disable unresolved-type + text: Icons.getBatteryIcon(device.modelData.batteryAvailable ? device.modelData.battery * 100 : -1) + color: device.modelData.battery < 0.2 ? Colours.palette.m3error : Colours.palette.m3onSurfaceVariant } StyledRect { id: connectBtn implicitWidth: implicitHeight - implicitHeight: connectIcon.implicitHeight + Appearance.padding.small + implicitHeight: connectIcon.implicitHeight + Tokens.padding.small - radius: Appearance.rounding.full - color: Qt.alpha(Colours.palette.m3primary, device.modelData.state === BluetoothDeviceState.Connected ? 1 : 0) + radius: Tokens.rounding.full + color: Qt.alpha(Colours.palette.m3primary, device.modelData.state === BluetoothDeviceState.Connected ? 1 : 0) // qmllint disable unresolved-type CircularIndicator { anchors.fill: parent @@ -117,12 +124,9 @@ ColumnLayout { } StateLayer { - color: device.modelData.state === BluetoothDeviceState.Connected ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface + color: device.modelData.state === BluetoothDeviceState.Connected ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface // qmllint disable unresolved-type disabled: device.loading - - function onClicked(): void { - device.modelData.connected = !device.modelData.connected; - } + onClicked: device.modelData.connected = !device.modelData.connected } MaterialIcon { @@ -131,7 +135,7 @@ ColumnLayout { anchors.centerIn: parent animate: true text: device.modelData.connected ? "link_off" : "link" - color: device.modelData.state === BluetoothDeviceState.Connected ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface + color: device.modelData.state === BluetoothDeviceState.Connected ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface // qmllint disable unresolved-type opacity: device.loading ? 0 : 1 @@ -142,17 +146,16 @@ ColumnLayout { } Loader { + visible: status === Loader.Ready + asynchronous: true active: device.modelData.bonded sourceComponent: Item { implicitWidth: connectBtn.implicitWidth implicitHeight: connectBtn.implicitHeight StateLayer { - radius: Appearance.rounding.full - - function onClicked(): void { - device.modelData.forget(); - } + radius: Tokens.rounding.full + onClicked: device.modelData.forget() } MaterialIcon { @@ -166,14 +169,14 @@ ColumnLayout { IconTextButton { Layout.fillWidth: true - Layout.topMargin: Appearance.spacing.normal + Layout.topMargin: Tokens.spacing.normal inactiveColour: Colours.palette.m3primaryContainer inactiveOnColour: Colours.palette.m3onPrimaryContainer - verticalPadding: Appearance.padding.small + verticalPadding: Tokens.padding.small text: qsTr("Open settings") icon: "settings" - onClicked: root.wrapper.detach("bluetooth") + onClicked: root.popouts.detachRequested("bluetooth") } component Toggle: RowLayout { @@ -182,8 +185,8 @@ ColumnLayout { property alias toggle: toggle Layout.fillWidth: true - Layout.rightMargin: Appearance.padding.small - spacing: Appearance.spacing.normal + Layout.rightMargin: Tokens.padding.small + spacing: Tokens.spacing.normal StyledText { Layout.fillWidth: true diff --git a/modules/bar/popouts/ClipWrapper.qml b/modules/bar/popouts/ClipWrapper.qml new file mode 100644 index 000000000..ab80d2cc9 --- /dev/null +++ b/modules/bar/popouts/ClipWrapper.qml @@ -0,0 +1,67 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import qs.components +import qs.modules.bar.popouts // Need to import this module so the Wrapper type is the same as others + +Item { + id: root + + required property ShellScreen screen + required property real borderThickness + + readonly property alias content: content + property real offsetScale: x > 0 || content.hasCurrent ? 0 : 1 + + visible: width > 0 && height > 0 + clip: true + + implicitWidth: content.implicitWidth * (1 - offsetScale) + implicitHeight: content.implicitHeight + + x: content.isDetached ? (parent.width - content.nonAnimWidth) / 2 : 0 + y: { + if (content.isDetached) + return (parent.height - content.nonAnimHeight) / 2; + + const off = content.currentCenter - borderThickness - content.nonAnimHeight / 2; + const diff = parent.height - Math.floor(off + content.nonAnimHeight); + if (diff < 0) + return off + diff; + return Math.max(off, 0); + } + + Behavior on offsetScale { + Anim { + type: Anim.DefaultSpatial + } + } + + Behavior on x { + Anim { + duration: content.animLength + easing: content.animCurve + } + } + + Behavior on y { + enabled: root.offsetScale < 1 + + Anim { + duration: content.animLength + easing: content.animCurve + } + } + + Wrapper { + id: content + + screen: root.screen + offsetScale: root.offsetScale + + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: (-implicitWidth - 5) * root.offsetScale + } +} diff --git a/modules/bar/popouts/Content.qml b/modules/bar/popouts/Content.qml index 40768444f..42c5a7667 100644 --- a/modules/bar/popouts/Content.qml +++ b/modules/bar/popouts/Content.qml @@ -1,43 +1,41 @@ pragma ComponentBehavior: Bound -import qs.components -import qs.config +import "./kblayout" +import QtQuick import Quickshell import Quickshell.Services.SystemTray -import QtQuick - -import "./kblayout" +import Caelestia.Config +import qs.components Item { id: root - required property Item wrapper + 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 + implicitWidth: (currentPopout?.implicitWidth ?? 0) + Tokens.padding.large * 2 + implicitHeight: (currentPopout?.implicitHeight ?? 0) + Tokens.padding.large * 2 Item { id: content anchors.fill: parent - anchors.margins: Appearance.padding.large + anchors.margins: Tokens.padding.large Popout { name: "activewindow" sourceComponent: ActiveWindow { - wrapper: root.wrapper + popouts: root.popouts } } Popout { id: networkPopout + name: "network" sourceComponent: Network { - wrapper: root.wrapper + popouts: root.popouts view: "wireless" } } @@ -45,60 +43,64 @@ Item { Popout { name: "ethernet" sourceComponent: Network { - wrapper: root.wrapper + popouts: root.popouts view: "ethernet" } } Popout { id: passwordPopout + name: "wirelesspassword" sourceComponent: WirelessPassword { id: passwordComponent - wrapper: root.wrapper - network: networkPopout.item?.passwordNetwork ?? null + + popouts: root.popouts + network: (networkPopout.item as Network)?.passwordNetwork ?? null } Connections { - target: root.wrapper function onCurrentNameChanged() { // Update network immediately when password popout becomes active - if (root.wrapper.currentName === "wirelesspassword") { + if (root.popouts.currentName === "wirelesspassword") { // Set network immediately if available - if (networkPopout.item && networkPopout.item.passwordNetwork) { + if ((networkPopout.item as Network)?.passwordNetwork) { if (passwordPopout.item) { - passwordPopout.item.network = networkPopout.item.passwordNetwork; + (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 && networkPopout.item.passwordNetwork) { - passwordPopout.item.network = networkPopout.item.passwordNetwork; + if (passwordPopout.item && (networkPopout.item as Network)?.passwordNetwork) { + (passwordPopout.item as WirelessPassword).network = (networkPopout.item as Network).passwordNetwork; } }, 100); } } + + target: root.popouts } Connections { - target: networkPopout function onItemChanged() { // When network popout loads, update password popout if it's active - if (root.wrapper.currentName === "wirelesspassword" && passwordPopout.item) { + if (root.popouts.currentName === "wirelesspassword" && passwordPopout.item) { Qt.callLater(() => { - if (networkPopout.item && networkPopout.item.passwordNetwork) { - passwordPopout.item.network = networkPopout.item.passwordNetwork; + if ((networkPopout.item as Network)?.passwordNetwork) { + (passwordPopout.item as WirelessPassword).network = (networkPopout.item as Network).passwordNetwork; } }); } } + + target: networkPopout } } Popout { name: "bluetooth" sourceComponent: Bluetooth { - wrapper: root.wrapper + popouts: root.popouts } } @@ -110,15 +112,13 @@ Item { Popout { name: "audio" sourceComponent: Audio { - wrapper: root.wrapper + popouts: root.popouts } } Popout { name: "kblayout" - sourceComponent: KbLayout { - wrapper: root.wrapper - } + sourceComponent: KbLayout {} } Popout { @@ -128,7 +128,7 @@ Item { Repeater { model: ScriptModel { - values: [...SystemTray.items.values] + values: SystemTray.items.values.filter(i => !GlobalConfig.bar.tray.hiddenIcons.includes(i.id)) } Popout { @@ -141,22 +141,22 @@ Item { sourceComponent: trayMenuComp Connections { - target: root.wrapper - function onHasCurrentChanged(): void { - if (root.wrapper.hasCurrent && trayMenu.shouldBeActive) { + if (root.popouts.hasCurrent && trayMenu.shouldBeActive) { trayMenu.sourceComponent = null; trayMenu.sourceComponent = trayMenuComp; } } + + target: root.popouts } Component { id: trayMenuComp TrayMenu { - popouts: root.wrapper - trayItem: trayMenu.modelData.menu + popouts: root.popouts + trayItem: trayMenu.modelData.menu // qmllint disable unresolved-type } } } @@ -167,10 +167,9 @@ Item { id: popout required property string name - readonly property bool shouldBeActive: root.wrapper.currentName === name + readonly property bool shouldBeActive: root.popouts.currentName === name - anchors.verticalCenter: parent.verticalCenter - anchors.right: parent.right + anchors.centerIn: parent opacity: 0 scale: 0.8 @@ -195,7 +194,7 @@ Item { SequentialAnimation { Anim { properties: "opacity,scale" - duration: Appearance.anim.durations.small + type: Anim.StandardSmall } PropertyAction { target: popout diff --git a/modules/bar/popouts/LockStatus.qml b/modules/bar/popouts/LockStatus.qml index 7d74530e3..caab7487f 100644 --- a/modules/bar/popouts/LockStatus.qml +++ b/modules/bar/popouts/LockStatus.qml @@ -1,10 +1,10 @@ +import QtQuick.Layouts +import Caelestia.Config import qs.components import qs.services -import qs.config -import QtQuick.Layouts ColumnLayout { - spacing: Appearance.spacing.small + spacing: Tokens.spacing.small StyledText { text: qsTr("Capslock: %1").arg(Hypr.capsLock ? "Enabled" : "Disabled") diff --git a/modules/bar/popouts/Network.qml b/modules/bar/popouts/Network.qml index 5b32e4a6e..63b69b571 100644 --- a/modules/bar/popouts/Network.qml +++ b/modules/bar/popouts/Network.qml @@ -1,33 +1,33 @@ pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Quickshell +import Caelestia.Config import qs.components import qs.components.controls import qs.services -import qs.config import qs.utils -import Quickshell -import QtQuick -import QtQuick.Layouts ColumnLayout { id: root - required property Item wrapper + 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 + spacing: Tokens.spacing.small + width: Tokens.sizes.bar.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 + Layout.topMargin: visible ? Tokens.padding.normal : 0 + Layout.rightMargin: Tokens.padding.small text: qsTr("Wireless") font.weight: 500 } @@ -43,11 +43,11 @@ ColumnLayout { 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) + Layout.topMargin: visible ? Tokens.spacing.small : 0 + Layout.rightMargin: Tokens.padding.small + text: qsTr("%1 networks available").arg(Nmcli.networks.length) // qmllint disable missing-property color: Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small } Repeater { @@ -70,8 +70,8 @@ ColumnLayout { visible: root.view === "wireless" Layout.preferredHeight: visible ? implicitHeight : 0 Layout.fillWidth: true - Layout.rightMargin: Appearance.padding.small - spacing: Appearance.spacing.small + Layout.rightMargin: Tokens.padding.small + spacing: Tokens.spacing.small opacity: 0 scale: 0.7 @@ -97,12 +97,12 @@ ColumnLayout { MaterialIcon { visible: networkItem.modelData.isSecure text: "lock" - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small } StyledText { - Layout.leftMargin: Appearance.spacing.small / 2 - Layout.rightMargin: Appearance.spacing.small / 2 + Layout.leftMargin: Tokens.spacing.small / 2 + Layout.rightMargin: Tokens.spacing.small / 2 Layout.fillWidth: true text: networkItem.modelData.ssid elide: Text.ElideRight @@ -112,9 +112,9 @@ ColumnLayout { StyledRect { implicitWidth: implicitHeight - implicitHeight: wirelessConnectIcon.implicitHeight + Appearance.padding.small + implicitHeight: wirelessConnectIcon.implicitHeight + Tokens.padding.small - radius: Appearance.rounding.full + radius: Tokens.rounding.full color: Qt.alpha(Colours.palette.m3primary, networkItem.modelData.active ? 1 : 0) CircularIndicator { @@ -126,7 +126,7 @@ ColumnLayout { color: networkItem.modelData.active ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface disabled: networkItem.loading || !Nmcli.wifiEnabled - function onClicked(): void { + onClicked: { if (networkItem.modelData.active) { Nmcli.disconnectFromNetwork(); } else { @@ -135,7 +135,7 @@ ColumnLayout { // Password is required - show password dialog root.passwordNetwork = network; root.showPasswordDialog = true; - root.wrapper.currentName = "wirelesspassword"; + root.popouts.currentName = "wirelesspassword"; }); // Clear connecting state if connection succeeds immediately (saved profile) @@ -165,27 +165,24 @@ ColumnLayout { StyledRect { visible: root.view === "wireless" Layout.preferredHeight: visible ? implicitHeight : 0 - Layout.topMargin: visible ? Appearance.spacing.small : 0 + Layout.topMargin: visible ? Tokens.spacing.small : 0 Layout.fillWidth: true - implicitHeight: rescanBtn.implicitHeight + Appearance.padding.small * 2 + implicitHeight: rescanBtn.implicitHeight + Tokens.padding.small * 2 - radius: Appearance.rounding.full + radius: Tokens.rounding.full color: Colours.palette.m3primaryContainer StateLayer { color: Colours.palette.m3onPrimaryContainer disabled: Nmcli.scanning || !Nmcli.wifiEnabled - - function onClicked(): void { - Nmcli.rescanWifi(); - } + onClicked: Nmcli.rescanWifi() } RowLayout { id: rescanBtn anchors.centerIn: parent - spacing: Appearance.spacing.small + spacing: Tokens.spacing.small opacity: Nmcli.scanning ? 0 : 1 MaterialIcon { @@ -210,9 +207,9 @@ ColumnLayout { CircularIndicator { anchors.centerIn: parent - strokeWidth: Appearance.padding.small / 2 + strokeWidth: Tokens.padding.small / 2 bgColour: "transparent" - implicitSize: parent.implicitHeight - Appearance.padding.smaller * 2 + implicitSize: parent.implicitHeight - Tokens.padding.smaller * 2 running: Nmcli.scanning } } @@ -221,8 +218,8 @@ ColumnLayout { StyledText { visible: root.view === "ethernet" Layout.preferredHeight: visible ? implicitHeight : 0 - Layout.topMargin: visible ? Appearance.padding.normal : 0 - Layout.rightMargin: Appearance.padding.small + Layout.topMargin: visible ? Tokens.padding.normal : 0 + Layout.rightMargin: Tokens.padding.small text: qsTr("Ethernet") font.weight: 500 } @@ -230,11 +227,11 @@ ColumnLayout { StyledText { visible: root.view === "ethernet" Layout.preferredHeight: visible ? implicitHeight : 0 - Layout.topMargin: visible ? Appearance.spacing.small : 0 - Layout.rightMargin: Appearance.padding.small + Layout.topMargin: visible ? Tokens.spacing.small : 0 + Layout.rightMargin: Tokens.padding.small text: qsTr("%1 devices available").arg(Nmcli.ethernetDevices.length) color: Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small } Repeater { @@ -256,8 +253,8 @@ ColumnLayout { visible: root.view === "ethernet" Layout.preferredHeight: visible ? implicitHeight : 0 Layout.fillWidth: true - Layout.rightMargin: Appearance.padding.small - spacing: Appearance.spacing.small + Layout.rightMargin: Tokens.padding.small + spacing: Tokens.spacing.small opacity: 0 scale: 0.7 @@ -281,8 +278,8 @@ ColumnLayout { } StyledText { - Layout.leftMargin: Appearance.spacing.small / 2 - Layout.rightMargin: Appearance.spacing.small / 2 + Layout.leftMargin: Tokens.spacing.small / 2 + Layout.rightMargin: Tokens.spacing.small / 2 Layout.fillWidth: true text: ethernetItem.modelData.interface || qsTr("Unknown") elide: Text.ElideRight @@ -292,9 +289,9 @@ ColumnLayout { StyledRect { implicitWidth: implicitHeight - implicitHeight: connectIcon.implicitHeight + Appearance.padding.small + implicitHeight: connectIcon.implicitHeight + Tokens.padding.small - radius: Appearance.rounding.full + radius: Tokens.rounding.full color: Qt.alpha(Colours.palette.m3primary, ethernetItem.modelData.connected ? 1 : 0) CircularIndicator { @@ -306,7 +303,7 @@ ColumnLayout { color: ethernetItem.modelData.connected ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface disabled: ethernetItem.loading - function onClicked(): void { + onClicked: { if (ethernetItem.modelData.connected && ethernetItem.modelData.connection) { Nmcli.disconnectEthernet(ethernetItem.modelData.connection, () => {}); } else { @@ -334,8 +331,6 @@ ColumnLayout { } Connections { - target: Nmcli - function onActiveChanged(): void { if (Nmcli.active && root.connectingToSsid === Nmcli.active.ssid) { root.connectingToSsid = ""; @@ -343,8 +338,8 @@ ColumnLayout { if (root.showPasswordDialog && root.passwordNetwork && Nmcli.active.ssid === root.passwordNetwork.ssid) { root.showPasswordDialog = false; root.passwordNetwork = null; - if (root.wrapper.currentName === "wirelesspassword") { - root.wrapper.currentName = "network"; + if (root.popouts.currentName === "wirelesspassword") { + root.popouts.currentName = "network"; } } } @@ -354,17 +349,20 @@ ColumnLayout { if (!Nmcli.scanning) scanIcon.rotation = 0; } + + target: Nmcli } Connections { - target: root.wrapper function onCurrentNameChanged(): void { // Clear password network when leaving password dialog - if (root.wrapper.currentName !== "wirelesspassword" && root.showPasswordDialog) { + if (root.popouts.currentName !== "wirelesspassword" && root.showPasswordDialog) { root.showPasswordDialog = false; root.passwordNetwork = null; } } + + target: root.popouts } component Toggle: RowLayout { @@ -373,8 +371,8 @@ ColumnLayout { property alias toggle: toggle Layout.fillWidth: true - Layout.rightMargin: Appearance.padding.small - spacing: Appearance.spacing.normal + Layout.rightMargin: Tokens.padding.small + spacing: Tokens.spacing.normal StyledText { Layout.fillWidth: true diff --git a/modules/bar/popouts/PopoutState.qml b/modules/bar/popouts/PopoutState.qml new file mode 100644 index 000000000..6be8169b4 --- /dev/null +++ b/modules/bar/popouts/PopoutState.qml @@ -0,0 +1,8 @@ +import QtQuick + +QtObject { + property string currentName + property bool hasCurrent + + signal detachRequested(mode: string) +} diff --git a/modules/bar/popouts/TrayMenu.qml b/modules/bar/popouts/TrayMenu.qml index 9b743db19..7975d1bf4 100644 --- a/modules/bar/popouts/TrayMenu.qml +++ b/modules/bar/popouts/TrayMenu.qml @@ -1,21 +1,21 @@ pragma ComponentBehavior: Bound -import qs.components -import qs.services -import qs.config -import Quickshell -import Quickshell.Widgets import QtQuick import QtQuick.Controls +import Quickshell +import Quickshell.Widgets +import Caelestia.Config +import qs.components +import qs.services StackView { id: root - required property Item popouts + required property PopoutState popouts required property QsMenuHandle trayItem - implicitWidth: currentItem.implicitWidth - implicitHeight: currentItem.implicitHeight + implicitWidth: currentItem?.implicitWidth ?? 0 + implicitHeight: currentItem?.implicitHeight ?? 0 initialItem: SubMenu { handle: root.trayItem @@ -26,6 +26,12 @@ StackView { popEnter: NoAnim {} popExit: NoAnim {} + Component { + id: subMenuComp + + SubMenu {} + } + component NoAnim: Transition { NumberAnimation { duration: 0 @@ -39,8 +45,8 @@ StackView { property bool isSubMenu property bool shown - padding: Appearance.padding.smaller - spacing: Appearance.spacing.small + padding: Tokens.padding.smaller + spacing: Tokens.spacing.small opacity: shown ? 1 : 0 scale: shown ? 1 : 0.8 @@ -72,15 +78,16 @@ StackView { required property QsMenuEntry modelData - implicitWidth: Config.bar.sizes.trayMenuWidth + implicitWidth: Tokens.sizes.bar.trayMenuWidth implicitHeight: modelData.isSeparator ? 1 : children.implicitHeight - radius: Appearance.rounding.full + radius: Tokens.rounding.full color: modelData.isSeparator ? Colours.palette.m3outlineVariant : "transparent" Loader { id: children + asynchronous: true anchors.left: parent.left anchors.right: parent.right @@ -90,14 +97,14 @@ StackView { implicitHeight: label.implicitHeight StateLayer { - anchors.margins: -Appearance.padding.small / 2 - anchors.leftMargin: -Appearance.padding.smaller - anchors.rightMargin: -Appearance.padding.smaller + anchors.margins: -Tokens.padding.small / 2 + anchors.leftMargin: -Tokens.padding.smaller + anchors.rightMargin: -Tokens.padding.smaller radius: item.radius disabled: !item.modelData.enabled - function onClicked(): void { + onClicked: { const entry = item.modelData; if (entry.hasChildren) root.push(subMenuComp.createObject(null, { @@ -114,11 +121,13 @@ StackView { Loader { id: icon + asynchronous: true anchors.left: parent.left active: item.modelData.icon !== "" sourceComponent: IconImage { + asynchronous: true implicitSize: label.implicitHeight source: item.modelData.icon @@ -129,7 +138,7 @@ StackView { id: label anchors.left: icon.right - anchors.leftMargin: icon.active ? Appearance.spacing.smaller : 0 + anchors.leftMargin: icon.active ? Tokens.spacing.smaller : 0 text: labelMetrics.elidedText color: item.modelData.enabled ? Colours.palette.m3onSurface : Colours.palette.m3outline @@ -143,12 +152,13 @@ StackView { 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) + elideWidth: root.Tokens.sizes.bar.trayMenuWidth - (icon.active ? icon.implicitWidth + label.anchors.leftMargin : 0) - (expand.active ? expand.implicitWidth + root.Tokens.spacing.normal : 0) } Loader { id: expand + asynchronous: true anchors.verticalCenter: parent.verticalCenter anchors.right: parent.right @@ -165,11 +175,12 @@ StackView { } Loader { + asynchronous: true active: menu.isSubMenu sourceComponent: Item { implicitWidth: back.implicitWidth - implicitHeight: back.implicitHeight + Appearance.spacing.small / 2 + implicitHeight: back.implicitHeight + Tokens.spacing.small / 2 Item { anchors.bottom: parent.bottom @@ -178,20 +189,17 @@ StackView { StyledRect { anchors.fill: parent - anchors.margins: -Appearance.padding.small / 2 - anchors.leftMargin: -Appearance.padding.smaller - anchors.rightMargin: -Appearance.padding.smaller * 2 + anchors.margins: -Tokens.padding.small / 2 + anchors.leftMargin: -Tokens.padding.smaller + anchors.rightMargin: -Tokens.padding.smaller * 2 - radius: Appearance.rounding.full + radius: Tokens.rounding.full color: Colours.palette.m3secondaryContainer StateLayer { radius: parent.radius color: Colours.palette.m3onSecondaryContainer - - function onClicked(): void { - root.pop(); - } + onClicked: root.pop() } } @@ -216,10 +224,4 @@ StackView { } } } - - Component { - id: subMenuComp - - SubMenu {} - } } diff --git a/modules/bar/popouts/WirelessPassword.qml b/modules/bar/popouts/WirelessPassword.qml index 96639e711..f6a635fed 100644 --- a/modules/bar/popouts/WirelessPassword.qml +++ b/modules/bar/popouts/WirelessPassword.qml @@ -1,27 +1,100 @@ pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Quickshell +import Caelestia.Config import qs.components import qs.components.controls import qs.services -import qs.config import qs.utils -import Quickshell -import QtQuick -import QtQuick.Layouts ColumnLayout { id: root - required property Item wrapper + required property PopoutState popouts property var network: null property bool isClosing: false - readonly property bool shouldBeVisible: root.wrapper.currentName === "wirelesspassword" + 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: Tokens.spacing.normal + implicitWidth: 400 + implicitHeight: content.implicitHeight + Tokens.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 { - target: root.wrapper function onCurrentNameChanged() { - if (root.wrapper.currentName === "wirelesspassword") { + if (root.popouts.currentName === "wirelesspassword") { // Update network when popout becomes active Qt.callLater(() => { // Try to get network from parent Content's networkPopout @@ -38,10 +111,13 @@ ColumnLayout { }); } } + + target: root.popouts } Timer { id: focusTimer + interval: 150 onTriggered: { root.forceActiveFocus(); @@ -49,41 +125,16 @@ ColumnLayout { } } - spacing: Appearance.spacing.normal - - implicitWidth: 400 - implicitHeight: content.implicitHeight + Appearance.padding.large * 2 - - visible: shouldBeVisible || isClosing - enabled: shouldBeVisible && !isClosing - focus: enabled - - Component.onCompleted: { - if (shouldBeVisible) { - // Use Timer for actual delay to ensure dialog is fully rendered - focusTimer.start(); - } - } - - onShouldBeVisibleChanged: { - if (shouldBeVisible) { - // Use Timer for actual delay to ensure dialog is fully rendered - focusTimer.start(); - } - } - - Keys.onEscapePressed: closeDialog() - StyledRect { Layout.fillWidth: true Layout.preferredWidth: 400 - implicitHeight: content.implicitHeight + Appearance.padding.large * 2 - - radius: Appearance.rounding.normal + implicitHeight: content.implicitHeight + Tokens.padding.large * 2 + radius: Tokens.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 {} @@ -113,33 +164,32 @@ ColumnLayout { } } - Keys.onEscapePressed: root.closeDialog() - ColumnLayout { id: content anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - anchors.margins: Appearance.padding.large + anchors.margins: Tokens.padding.large - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal MaterialIcon { Layout.alignment: Qt.AlignHCenter text: "lock" - font.pointSize: Appearance.font.size.extraLarge * 2 + font.pointSize: Tokens.font.size.extraLarge * 2 } StyledText { Layout.alignment: Qt.AlignHCenter text: qsTr("Enter password") - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large font.weight: 500 } StyledText { id: networkNameText + Layout.alignment: Qt.AlignHCenter text: { if (root.network) { @@ -151,14 +201,15 @@ ColumnLayout { return qsTr("Network: Unknown"); } color: Colours.palette.m3outline - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small } Timer { + property int attempts: 0 + interval: 50 running: root.shouldBeVisible && (!root.network || !root.network.ssid) repeat: true - property int attempts: 0 onTriggered: { attempts++; // Keep trying to get network from Network component @@ -186,7 +237,7 @@ ColumnLayout { id: statusText Layout.alignment: Qt.AlignHCenter - Layout.topMargin: Appearance.spacing.small + Layout.topMargin: Tokens.spacing.small visible: connectButton.connecting || connectButton.hasError text: { if (connectButton.hasError) { @@ -198,23 +249,30 @@ ColumnLayout { return ""; } color: connectButton.hasError ? Colours.palette.m3error : Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small font.weight: 400 wrapMode: Text.WordWrap - Layout.maximumWidth: parent.width - Appearance.padding.large * 2 + Layout.maximumWidth: parent.width - Tokens.padding.large * 2 } FocusScope { id: passwordContainer + + property string passwordBuffer: "" + objectName: "passwordContainer" - Layout.topMargin: Appearance.spacing.large + Layout.topMargin: Tokens.spacing.large Layout.fillWidth: true - implicitHeight: Math.max(48, charList.implicitHeight + Appearance.padding.normal * 2) - + implicitHeight: Math.max(48, charList.implicitHeight + Tokens.padding.normal * 2) focus: true activeFocusOnTab: true - property string passwordBuffer: "" + Component.onCompleted: { + if (root.shouldBeVisible) { + // Use Timer for actual delay to ensure focus works correctly + passwordFocusTimer.start(); + } + } Keys.onPressed: event => { // Ensure we have focus when receiving keyboard input @@ -222,6 +280,11 @@ ColumnLayout { forceActiveFocus(); } + if (event.key === Qt.Key_Escape) { + event.accepted = false; + closeDialog(); + } + // Clear error when user starts typing if (connectButton.hasError && event.text && event.text.length > 0) { connectButton.hasError = false; @@ -240,13 +303,16 @@ ColumnLayout { } event.accepted = true; } else if (event.text && event.text.length > 0) { + if (event.key === Qt.Key_Tab) { + event.accepted = false; + return; + } passwordBuffer += event.text; event.accepted = true; } } Connections { - target: root function onShouldBeVisibleChanged(): void { if (root.shouldBeVisible) { // Use Timer for actual delay to ensure focus works correctly @@ -255,26 +321,22 @@ ColumnLayout { 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 + radius: Tokens.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: { @@ -303,11 +365,8 @@ ColumnLayout { StateLayer { hoverEnabled: false cursorShape: Qt.IBeamCursor - radius: Appearance.rounding.normal - - function onClicked(): void { - passwordContainer.forceActiveFocus(); - } + radius: Tokens.rounding.normal + onClicked: passwordContainer.forceActiveFocus() } StyledText { @@ -316,8 +375,8 @@ ColumnLayout { anchors.centerIn: parent text: qsTr("Password") color: Colours.palette.m3outline - font.pointSize: Appearance.font.size.normal - font.family: Appearance.font.family.mono + font.pointSize: Tokens.font.size.normal + font.family: Tokens.font.family.mono opacity: passwordContainer.passwordBuffer ? 0 : 1 Behavior on opacity { @@ -332,10 +391,10 @@ ColumnLayout { anchors.centerIn: parent implicitWidth: fullWidth - implicitHeight: Appearance.font.size.normal + implicitHeight: Tokens.font.size.normal orientation: Qt.Horizontal - spacing: Appearance.spacing.small / 2 + spacing: Tokens.spacing.small / 2 interactive: false model: ScriptModel { @@ -349,7 +408,7 @@ ColumnLayout { implicitHeight: charList.implicitHeight color: Colours.palette.m3onSurface - radius: Appearance.rounding.small / 2 + radius: Tokens.rounding.small / 2 opacity: 0 scale: 0 @@ -392,8 +451,7 @@ ColumnLayout { Behavior on scale { Anim { - duration: Appearance.anim.durations.expressiveFastSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + type: Anim.FastSpatial } } } @@ -405,15 +463,15 @@ ColumnLayout { } RowLayout { - Layout.topMargin: Appearance.spacing.normal + Layout.topMargin: Tokens.spacing.normal Layout.fillWidth: true - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal TextButton { id: cancelButton Layout.fillWidth: true - Layout.minimumHeight: Appearance.font.size.normal + Appearance.padding.normal * 2 + Layout.minimumHeight: Tokens.font.size.normal + Tokens.padding.normal * 2 inactiveColour: Colours.palette.m3secondaryContainer inactiveOnColour: Colours.palette.m3onSecondaryContainer text: qsTr("Cancel") @@ -428,7 +486,7 @@ ColumnLayout { property bool hasError: false Layout.fillWidth: true - Layout.minimumHeight: Appearance.font.size.normal + Appearance.padding.normal * 2 + Layout.minimumHeight: Tokens.font.size.normal + Tokens.padding.normal * 2 inactiveColour: Colours.palette.m3primary inactiveOnColour: Colours.palette.m3onPrimary text: qsTr("Connect") @@ -491,45 +549,14 @@ ColumnLayout { } } - function checkConnectionStatus(): void { - if (!root.shouldBeVisible || !connectButton.connecting) { - return; - } - - // Check if we're connected to the target network (case-insensitive SSID comparison) - const isConnected = root.network && Nmcli.active && Nmcli.active.ssid && Nmcli.active.ssid.toLowerCase().trim() === root.network.ssid.toLowerCase().trim(); - - if (isConnected) { - // Successfully connected - give it a moment for network list to update - // Use Timer for actual delay - connectionSuccessTimer.start(); - return; - } - - // Check for connection failures - if pending connection was cleared but we're not connected - if (Nmcli.pendingConnection === null && connectButton.connecting) { - // Wait a bit more before giving up (allow time for connection to establish) - if (connectionMonitor.repeatCount > 10) { - connectionMonitor.stop(); - connectButton.connecting = false; - connectButton.hasError = true; - connectButton.enabled = true; - connectButton.text = qsTr("Connect"); - passwordContainer.passwordBuffer = ""; - // Delete the failed connection - if (root.network && root.network.ssid) { - Nmcli.forgetNetwork(root.network.ssid); - } - } - } - } - Timer { id: connectionMonitor + + property int repeatCount: 0 + interval: 1000 repeat: true triggeredOnStart: false - property int repeatCount: 0 onTriggered: { repeatCount++; @@ -545,6 +572,7 @@ ColumnLayout { Timer { id: connectionSuccessTimer + interval: 500 onTriggered: { // Double-check connection is still active @@ -555,8 +583,8 @@ ColumnLayout { connectButton.connecting = false; connectButton.text = qsTr("Connect"); // Return to network popout on successful connection - if (root.wrapper.currentName === "wirelesspassword") { - root.wrapper.currentName = "network"; + if (root.popouts.currentName === "wirelesspassword") { + root.popouts.currentName = "network"; } closeDialog(); } @@ -565,12 +593,12 @@ ColumnLayout { } Connections { - target: Nmcli function onActiveChanged() { if (root.shouldBeVisible) { root.checkConnectionStatus(); } } + function onConnectionFailed(ssid: string) { if (root.shouldBeVisible && root.network && root.network.ssid === ssid && connectButton.connecting) { connectionMonitor.stop(); @@ -583,23 +611,7 @@ ColumnLayout { Nmcli.forgetNetwork(ssid); } } - } - - function closeDialog(): void { - if (isClosing) { - return; - } - - isClosing = true; - passwordContainer.passwordBuffer = ""; - connectButton.connecting = false; - connectButton.hasError = false; - connectButton.text = qsTr("Connect"); - connectionMonitor.stop(); - // Return to network popout - if (root.wrapper.currentName === "wirelesspassword") { - root.wrapper.currentName = "network"; - } + target: Nmcli } } diff --git a/modules/bar/popouts/Wrapper.qml b/modules/bar/popouts/Wrapper.qml index 05a1d3c9e..7a66d3b97 100644 --- a/modules/bar/popouts/Wrapper.qml +++ b/modules/bar/popouts/Wrapper.qml @@ -1,57 +1,66 @@ pragma ComponentBehavior: Bound +import QtQuick +import Quickshell +import Quickshell.Hyprland +import Quickshell.Wayland +import Caelestia.Config 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 +import qs.modules.windowinfo Item { id: root required property ShellScreen screen + required property real offsetScale + + readonly property alias content: content + readonly property alias winfo: winfo + readonly property alias controlCenter: controlCenter - readonly property real nonAnimWidth: x > 0 || hasCurrent ? children.find(c => c.shouldBeActive)?.implicitWidth ?? content.implicitWidth : 0 + readonly property real nonAnimWidth: children.find(c => c.shouldBeActive)?.implicitWidth ?? content.implicitWidth readonly property real nonAnimHeight: children.find(c => c.shouldBeActive)?.implicitHeight ?? content.implicitHeight - readonly property Item current: content.item?.current ?? null + readonly property Item current: (content.item as Content)?.current ?? null + readonly property bool isDetached: detachedMode.length > 0 - property string currentName + property alias currentName: popoutState.currentName + property alias hasCurrent: popoutState.hasCurrent property real currentCenter - property bool hasCurrent property string detachedMode property string queuedMode - readonly property bool isDetached: detachedMode.length > 0 - property int animLength: Appearance.anim.durations.normal - property list animCurve: Appearance.anim.curves.emphasized + // Dummy object so Tokens attached prop resolves to global config + // Anim configs are not per-monitor + readonly property QtObject dummy: QtObject {} + property int animLength: dummy.Tokens.anim.durations.expressiveDefaultSpatial + property var animCurve: dummy.Tokens.anim.expressiveDefaultSpatial // The easingCurve type is Qt 6.11+ so we gotta use var for now + + function setAnims(detach: bool): void { + const type = `expressive${detach ? "Slow" : "Default"}Spatial`; + animLength = dummy.Tokens.anim.durations[type]; + animCurve = dummy.Tokens.anim[type]; + } function detach(mode: string): void { - animLength = Appearance.anim.durations.large; + setAnims(true); if (mode === "winfo") { detachedMode = mode; } else { queuedMode = mode; detachedMode = "any"; } + setAnims(false); 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 @@ -59,7 +68,7 @@ Item { Keys.onEscapePressed: { // Forward escape to password popout if active, otherwise close if (currentName === "wirelesspassword" && content.item) { - const passwordPopout = content.item.children.find(c => c.name === "wirelesspassword"); + const passwordPopout = (content.item as Content)?.children.find(c => c.name === "wirelesspassword"); if (passwordPopout && passwordPopout.item) { passwordPopout.item.closeDialog(); return; @@ -75,6 +84,12 @@ Item { } } + PopoutState { + id: popoutState + + onDetachRequested: mode => root.detach(mode) + } + HyprlandFocusGrab { active: root.isDetached windows: [QsWindow.window] @@ -82,15 +97,7 @@ Item { } Binding { - when: root.isDetached - - target: QsWindow.window - property: "WlrLayershell.keyboardFocus" - value: WlrKeyboardFocus.OnDemand - } - - Binding { - when: root.hasCurrent && root.currentName === "wirelesspassword" + when: root.isDetached || (root.hasCurrent && root.currentName === "wirelesspassword") target: QsWindow.window property: "WlrLayershell.keyboardFocus" @@ -101,15 +108,16 @@ Item { id: content shouldBeActive: root.hasCurrent && !root.detachedMode - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter + anchors.fill: parent sourceComponent: Content { - wrapper: root + popouts: popoutState } } Comp { + id: winfo + shouldBeActive: root.detachedMode === "winfo" anchors.centerIn: parent @@ -120,48 +128,31 @@ Item { } Comp { + id: controlCenter + shouldBeActive: root.detachedMode === "any" anchors.centerIn: parent sourceComponent: ControlCenter { screen: root.screen active: root.queuedMode - - function close(): void { - root.close(); - } - } - } - - Behavior on x { - Anim { - duration: root.animLength - easing.bezierCurve: root.animCurve - } - } - - Behavior on y { - enabled: root.implicitWidth > 0 - - Anim { - duration: root.animLength - easing.bezierCurve: root.animCurve + onClose: root.close() } } Behavior on implicitWidth { Anim { duration: root.animLength - easing.bezierCurve: root.animCurve + easing: root.animCurve } } Behavior on implicitHeight { - enabled: root.implicitWidth > 0 + enabled: root.offsetScale < 1 Anim { duration: root.animLength - easing.bezierCurve: root.animCurve + easing: root.animCurve } } @@ -173,6 +164,7 @@ Item { active: false opacity: 0 + // Makes the loader load on the same frame shouldBeActive becomes true, which ensures size is set states: State { name: "active" when: comp.shouldBeActive diff --git a/modules/bar/popouts/kblayout/KbLayout.qml b/modules/bar/popouts/kblayout/KbLayout.qml index 94b6f7ec5..d8f9a462d 100644 --- a/modules/bar/popouts/kblayout/KbLayout.qml +++ b/modules/bar/popouts/kblayout/KbLayout.qml @@ -3,51 +3,47 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Controls import QtQuick.Layouts +import Caelestia.Config import qs.components -import qs.components.controls import qs.services -import qs.config -import qs.utils - -import "." ColumnLayout { id: root - required property Item wrapper + function refresh() { + kb.refresh(); + } + + spacing: Tokens.spacing.small + width: Tokens.sizes.bar.kbLayoutWidth - spacing: Appearance.spacing.small - width: Config.bar.sizes.kbLayoutWidth + Component.onCompleted: kb.start() KbLayoutModel { id: kb } - function refresh() { - kb.refresh(); - } - Component.onCompleted: kb.start() - StyledText { - Layout.topMargin: Appearance.padding.normal - Layout.rightMargin: Appearance.padding.small + Layout.topMargin: Tokens.padding.normal + Layout.rightMargin: Tokens.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 + Layout.rightMargin: Tokens.padding.small + Layout.topMargin: Tokens.spacing.small clip: true interactive: true implicitHeight: Math.min(contentHeight, 320) visible: kb.visibleModel.count > 0 - spacing: Appearance.spacing.small + spacing: Tokens.spacing.small add: Transition { NumberAnimation { @@ -85,54 +81,55 @@ ColumnLayout { } 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) - - readonly property bool isDisabled: layoutIndex > 3 + height: Math.max(36, rowText.implicitHeight + Tokens.padding.small * 2) + ToolTip.visible: isDisabled && layer.containsMouse + ToolTip.text: "XKB limitation: maximum 4 layouts allowed" StateLayer { id: layer + + onClicked: { + 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: !isDisabled - - function onClicked(): void { - if (!isDisabled) - kb.switchTo(layoutIndex); - } + radius: Tokens.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: label + anchors.leftMargin: Tokens.padding.small + anchors.rightMargin: Tokens.padding.small + text: kbDelegate.label elide: Text.ElideRight - opacity: isDisabled ? 0.4 : 1.0 + opacity: kbDelegate.isDisabled ? 0.4 : 1.0 } - - ToolTip.visible: isDisabled && layer.containsMouse - ToolTip.text: "XKB limitation: maximum 4 layouts allowed" } } Rectangle { visible: kb.activeLabel.length > 0 Layout.fillWidth: true - Layout.rightMargin: Appearance.padding.small - Layout.topMargin: Appearance.spacing.small + Layout.rightMargin: Tokens.padding.small + Layout.topMargin: Tokens.spacing.small - height: 1 + implicitHeight: 1 color: Colours.palette.m3onSurfaceVariant opacity: 0.35 } @@ -142,9 +139,9 @@ ColumnLayout { visible: kb.activeLabel.length > 0 Layout.fillWidth: true - Layout.rightMargin: Appearance.padding.small - Layout.topMargin: Appearance.spacing.small - spacing: Appearance.spacing.small + Layout.rightMargin: Tokens.padding.small + Layout.topMargin: Tokens.spacing.small + spacing: Tokens.spacing.small opacity: 1 scale: 1 @@ -163,16 +160,18 @@ ColumnLayout { } Connections { - target: kb function onActiveLabelChanged() { if (!activeRow.visible) return; popIn.restart(); } + + target: kb } SequentialAnimation { id: popIn + running: false ParallelAnimation { diff --git a/modules/bar/popouts/kblayout/KbLayoutModel.qml b/modules/bar/popouts/kblayout/KbLayoutModel.qml index 437109530..f62d0b989 100644 --- a/modules/bar/popouts/kblayout/KbLayoutModel.qml +++ b/modules/bar/popouts/kblayout/KbLayoutModel.qml @@ -1,24 +1,20 @@ pragma ComponentBehavior: Bound import QtQuick - -import Quickshell import Quickshell.Io - -import qs.config import Caelestia +import Caelestia.Config + +// TODO: handle this better later Item { id: model - visible: false - ListModel { - id: _visibleModel - } property alias visibleModel: _visibleModel - property string activeLabel: "" property int activeIndex: -1 + property var _xkbMap: ({}) + property bool _notifiedLimit: false function start() { _xkbXmlBase.running = true; @@ -35,31 +31,6 @@ Item { _switchProc.running = true; } - ListModel { - id: _layoutsModel - } - - property var _xkbMap: ({}) - property bool _notifiedLimit: false - - Process { - id: _xkbXmlBase - command: ["xmllint", "--xpath", "//layout/configItem[name and description]", "/usr/share/X11/xkb/rules/base.xml"] - stdout: StdioCollector { - onStreamFinished: _buildXmlMap(text) - } - onRunningChanged: if (!running && (typeof exitCode !== "undefined") && exitCode !== 0) - _xkbXmlEvdev.running = true - } - - Process { - id: _xkbXmlEvdev - command: ["xmllint", "--xpath", "//layout/configItem[name and description]", "/usr/share/X11/xkb/rules/evdev.xml"] - stdout: StdioCollector { - onStreamFinished: _buildXmlMap(text) - } - } - function _buildXmlMap(xml) { const map = {}; @@ -105,8 +76,84 @@ Item { 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 (!GlobalConfig.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; + } + + visible: false + + ListModel { + id: _visibleModel + } + + ListModel { + id: _layoutsModel + } + + 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) // qmllint disable missing-property + _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: { @@ -114,7 +161,7 @@ Item { const j = JSON.parse(text); const raw = (j?.str || j?.value || "").toString().trim(); if (raw.length) { - _setLayouts(raw); + model._setLayouts(raw); _fetchActiveLayouts.running = true; return; } @@ -126,6 +173,7 @@ Item { Process { id: _fetchLayoutsFromDevices + command: ["hyprctl", "-j", "devices"] stdout: StdioCollector { onStreamFinished: { @@ -134,7 +182,7 @@ Item { const kb = dev?.keyboards?.find(k => k.main) || dev?.keyboards?.[0]; const raw = (kb?.layout || "").trim(); if (raw.length) - _setLayouts(raw); + model._setLayouts(raw); } catch (e) {} _fetchActiveLayouts.running = true; } @@ -143,6 +191,7 @@ Item { Process { id: _fetchActiveLayouts + command: ["hyprctl", "-j", "devices"] stdout: StdioCollector { onStreamFinished: { @@ -151,66 +200,22 @@ Item { const kb = dev?.keyboards?.find(k => k.main) || dev?.keyboards?.[0]; const idx = kb?.active_layout_index ?? -1; - activeIndex = idx >= 0 ? idx : -1; - activeLabel = (idx >= 0 && idx < _layoutsModel.count) ? _layoutsModel.get(idx).label : ""; + model.activeIndex = idx >= 0 ? idx : -1; + model.activeLabel = (idx >= 0 && idx < _layoutsModel.count) ? _layoutsModel.get(idx).label : ""; } catch (e) { - activeIndex = -1; - activeLabel = ""; + model.activeIndex = -1; + model.activeLabel = ""; } - _rebuildVisible(); + model._rebuildVisible(); } } } Process { id: _switchProc + onRunningChanged: if (!running) _fetchActiveLayouts.running = true } - - function _setLayouts(raw) { - const parts = raw.split(",").map(s => s.trim()).filter(Boolean); - _layoutsModel.clear(); - - const seen = new Set(); - let idx = 0; - - for (const p of parts) { - if (seen.has(p)) - continue; - seen.add(p); - _layoutsModel.append({ - layoutIndex: idx, - token: p, - label: _pretty(p) - }); - idx++; - } - } - - function _rebuildVisible() { - _visibleModel.clear(); - - let arr = []; - for (let i = 0; i < _layoutsModel.count; i++) - arr.push(_layoutsModel.get(i)); - - arr = arr.filter(i => i.layoutIndex !== activeIndex); - arr.forEach(i => _visibleModel.append(i)); - - if (!Config.utilities.toasts.kbLimit) - return; - - if (_layoutsModel.count > 4) { - Toaster.toast(qsTr("Keyboard layout limit"), qsTr("XKB supports only 4 layouts at a time"), "warning"); - } - } - - function _pretty(token) { - const code = token.replace(/\(.*\)$/, "").trim(); - if (_xkbMap[code]) - return code.toUpperCase() + " - " + _xkbMap[code]; - return code.toUpperCase() + " - " + code; - } } diff --git a/modules/controlcenter/ControlCenter.qml b/modules/controlcenter/ControlCenter.qml index 4aacfad99..c8b435caa 100644 --- a/modules/controlcenter/ControlCenter.qml +++ b/modules/controlcenter/ControlCenter.qml @@ -1,34 +1,34 @@ pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Quickshell +import Caelestia.Config 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 + readonly property int rounding: floating ? 0 : Tokens.rounding.large 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 { - } + signal close - implicitWidth: implicitHeight * Config.controlCenter.sizes.ratio - implicitHeight: screen.height * Config.controlCenter.sizes.heightMult + implicitWidth: implicitHeight * Tokens.sizes.controlCenter.ratio + implicitHeight: screen.height * Tokens.sizes.controlCenter.heightMult GridLayout { anchors.fill: parent @@ -42,6 +42,7 @@ Item { Layout.fillWidth: true Layout.columnSpan: 2 + asynchronous: true active: root.floating visible: active @@ -60,8 +61,6 @@ Item { color: Colours.tPalette.m3surfaceContainer CustomMouseArea { - anchors.fill: parent - function onWheel(event: WheelEvent): void { // Prevent tab switching during initial opening animation to avoid blank pages if (!panes.initialOpeningComplete) { @@ -73,6 +72,8 @@ Item { else if (event.angleDelta.y > 0) root.session.activeIndex = Math.max(root.session.activeIndex - 1, 0); } + + anchors.fill: parent } NavRail { @@ -95,6 +96,4 @@ Item { session: root.session } } - - readonly property bool initialOpeningComplete: panes.initialOpeningComplete } diff --git a/modules/controlcenter/NavRail.qml b/modules/controlcenter/NavRail.qml index e61a741a3..a126c8009 100644 --- a/modules/controlcenter/NavRail.qml +++ b/modules/controlcenter/NavRail.qml @@ -1,12 +1,12 @@ pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Quickshell +import Caelestia.Config import qs.components import qs.services -import qs.config import qs.modules.controlcenter -import Quickshell -import QtQuick -import QtQuick.Layouts Item { id: root @@ -15,23 +15,23 @@ Item { required property Session session required property bool initialOpeningComplete - implicitWidth: layout.implicitWidth + Appearance.padding.larger * 4 - implicitHeight: layout.implicitHeight + Appearance.padding.large * 2 + implicitWidth: layout.implicitWidth + Tokens.padding.larger * 4 + implicitHeight: layout.implicitHeight + Tokens.padding.large * 2 ColumnLayout { id: layout anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter - anchors.leftMargin: Appearance.padding.larger * 2 - spacing: Appearance.spacing.normal + anchors.leftMargin: Tokens.padding.larger * 2 + spacing: Tokens.spacing.normal states: State { name: "expanded" when: root.session.navExpanded PropertyChanges { - layout.spacing: Appearance.spacing.small + layout.spacing: root.Tokens.spacing.small } } @@ -42,7 +42,8 @@ Item { } Loader { - Layout.topMargin: Appearance.spacing.large + Layout.topMargin: Tokens.spacing.large + asynchronous: true active: !root.session.floating visible: active @@ -50,23 +51,23 @@ Item { 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 + implicitHeight: root.session.navExpanded ? normalWinIcon.implicitHeight + Tokens.padding.normal * 2 : nonAnimWidth color: Colours.palette.m3primaryContainer - radius: Appearance.rounding.small + radius: Tokens.rounding.small StateLayer { id: normalWinState - color: Colours.palette.m3onPrimaryContainer - - function onClicked(): void { + onClicked: { root.session.root.close(); WindowFactory.create(null, { active: root.session.active, navExpanded: root.session.navExpanded }); } + + color: Colours.palette.m3onPrimaryContainer } MaterialIcon { @@ -74,11 +75,11 @@ Item { anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter - anchors.leftMargin: Appearance.padding.large + anchors.leftMargin: Tokens.padding.large text: "select_window" color: Colours.palette.m3onPrimaryContainer - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large fill: 1 } @@ -87,7 +88,7 @@ Item { anchors.left: normalWinIcon.right anchors.verticalCenter: parent.verticalCenter - anchors.leftMargin: Appearance.spacing.normal + anchors.leftMargin: Tokens.spacing.normal text: qsTr("Float window") color: Colours.palette.m3onPrimaryContainer @@ -95,22 +96,20 @@ Item { Behavior on opacity { Anim { - duration: Appearance.anim.durations.small + type: Anim.StandardSmall } } } Behavior on implicitWidth { Anim { - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + type: Anim.DefaultSpatial } } Behavior on implicitHeight { Anim { - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + type: Anim.DefaultSpatial } } } @@ -121,7 +120,8 @@ Item { NavItem { required property int index - Layout.topMargin: index === 0 ? Appearance.spacing.large * 2 : 0 + + Layout.topMargin: index === 0 ? Tokens.spacing.large * 2 : 0 icon: PaneRegistry.getByIndex(index).icon label: PaneRegistry.getByIndex(index).label } @@ -146,7 +146,7 @@ Item { 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 + background.implicitHeight: icon.implicitHeight + root.Tokens.padding.normal * 2 item.implicitHeight: background.implicitHeight } } @@ -154,35 +154,34 @@ Item { transitions: Transition { Anim { property: "opacity" - duration: Appearance.anim.durations.small + type: Anim.StandardSmall } Anim { properties: "implicitWidth,implicitHeight" - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + type: Anim.DefaultSpatial } } StyledRect { id: background - radius: Appearance.rounding.full + radius: Tokens.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 + implicitHeight: icon.implicitHeight + Tokens.padding.small StateLayer { - color: item.active ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface - - function onClicked(): void { + onClicked: { // 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 { @@ -190,11 +189,11 @@ Item { anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter - anchors.leftMargin: Appearance.padding.large + anchors.leftMargin: Tokens.padding.large text: item.icon color: item.active ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large fill: item.active ? 1 : 0 Behavior on fill { @@ -207,7 +206,7 @@ Item { anchors.left: icon.right anchors.verticalCenter: parent.verticalCenter - anchors.leftMargin: Appearance.spacing.normal + anchors.leftMargin: Tokens.spacing.normal opacity: 0 text: item.label @@ -220,10 +219,10 @@ Item { anchors.horizontalCenter: icon.horizontalCenter anchors.top: icon.bottom - anchors.topMargin: Appearance.spacing.small / 2 + anchors.topMargin: Tokens.spacing.small / 2 text: item.label - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small font.capitalization: Font.Capitalize } } diff --git a/modules/controlcenter/PaneRegistry.qml b/modules/controlcenter/PaneRegistry.qml index c2a0f3840..f26a2aade 100644 --- a/modules/controlcenter/PaneRegistry.qml +++ b/modules/controlcenter/PaneRegistry.qml @@ -36,11 +36,29 @@ QtObject { readonly property string icon: "task_alt" readonly property string component: "taskbar/TaskbarPane.qml" }, + QtObject { + readonly property string id: "notifications" + readonly property string label: "notifications" + readonly property string icon: "notifications" + readonly property string component: "notifications/NotificationsPane.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: "monitors" + readonly property string label: "monitors" + readonly property string icon: "monitor" + readonly property string component: "monitors/MonitorsPane.qml" + }, + QtObject { + readonly property string id: "dashboard" + readonly property string label: "dashboard" + readonly property string icon: "dashboard" + readonly property string component: "dashboard/DashboardPane.qml" } ] @@ -54,7 +72,7 @@ QtObject { return result; } - function getByIndex(index: int): QtObject { + function getByIndex(index: int): var { if (index >= 0 && index < panes.length) { return panes[index]; } @@ -70,12 +88,12 @@ QtObject { return -1; } - function getByLabel(label: string): QtObject { + function getByLabel(label: string): var { const index = getIndexByLabel(label); return getByIndex(index); } - function getById(id: string): QtObject { + function getById(id: string): var { for (let i = 0; i < panes.length; i++) { if (panes[i].id === id) { return panes[i]; diff --git a/modules/controlcenter/Panes.qml b/modules/controlcenter/Panes.qml index 4a4460ca4..e8803011f 100644 --- a/modules/controlcenter/Panes.qml +++ b/modules/controlcenter/Panes.qml @@ -5,14 +5,17 @@ import "network" import "audio" import "appearance" import "taskbar" +import "notifications" import "launcher" +import "monitors" +import "dashboard" +import QtQuick +import QtQuick.Layouts +import Quickshell.Widgets +import Caelestia.Config import qs.components import qs.services -import qs.config import qs.modules.controlcenter -import Quickshell.Widgets -import QtQuick -import QtQuick.Layouts ClippingRectangle { id: root @@ -36,26 +39,27 @@ ClippingRectangle { } Connections { - target: root.session - 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 - property bool animationComplete: true - property bool initialOpeningComplete: false - Timer { id: animationDelayTimer - interval: Appearance.anim.durations.normal + + interval: Tokens.anim.durations.normal onTriggered: { layout.animationComplete = true; } @@ -63,7 +67,8 @@ ClippingRectangle { Timer { id: initialOpeningTimer - interval: Appearance.anim.durations.large + + interval: Tokens.anim.durations.large running: true onTriggered: { layout.initialOpeningComplete = true; @@ -75,6 +80,7 @@ ClippingRectangle { Pane { required property int index + paneIndex: index componentPath: PaneRegistry.getByIndex(index).component } @@ -85,11 +91,12 @@ ClippingRectangle { } Connections { - target: root.session function onActiveIndexChanged(): void { layout.animationComplete = false; animationDelayTimer.restart(); } + + target: root.session } } @@ -98,10 +105,6 @@ ClippingRectangle { required property int paneIndex required property string componentPath - - implicitWidth: root.width - implicitHeight: root.height - property bool hasBeenLoaded: false function updateActive(): void { @@ -124,10 +127,14 @@ ClippingRectangle { loader.active = shouldBeActive; } + implicitWidth: root.width + implicitHeight: root.height + Loader { id: loader anchors.fill: parent + asynchronous: true clip: false active: false @@ -155,20 +162,22 @@ ClippingRectangle { } Connections { - target: root.session function onActiveIndexChanged(): void { pane.updateActive(); } + + target: root.session } Connections { - target: layout function onInitialOpeningCompleteChanged(): void { pane.updateActive(); } function onAnimationCompleteChanged(): void { pane.updateActive(); } + + target: layout } } } diff --git a/modules/controlcenter/Session.qml b/modules/controlcenter/Session.qml index 8a8545f0f..33805609d 100644 --- a/modules/controlcenter/Session.qml +++ b/modules/controlcenter/Session.qml @@ -1,5 +1,5 @@ -import QtQuick import "./state" +import QtQuick import qs.modules.controlcenter QtObject { @@ -16,6 +16,7 @@ QtObject { readonly property EthernetState ethernet: EthernetState {} readonly property LauncherState launcher: LauncherState {} readonly property VpnState vpn: VpnState {} + readonly property MonitorState monitor: MonitorState {} onActiveChanged: activeIndex = Math.max(0, panes.indexOf(active)) onActiveIndexChanged: if (panes[activeIndex]) diff --git a/modules/controlcenter/WindowFactory.qml b/modules/controlcenter/WindowFactory.qml index abcf5df19..dc0dc4a07 100644 --- a/modules/controlcenter/WindowFactory.qml +++ b/modules/controlcenter/WindowFactory.qml @@ -1,9 +1,9 @@ pragma Singleton +import QtQuick +import Quickshell import qs.components import qs.services -import Quickshell -import QtQuick Singleton { id: root @@ -47,11 +47,8 @@ Singleton { anchors.fill: parent screen: win.screen + onClose: win.destroy() floating: true - - function close(): void { - win.destroy(); - } } Behavior on color { diff --git a/modules/controlcenter/WindowTitle.qml b/modules/controlcenter/WindowTitle.qml index fb7160893..8f66968ba 100644 --- a/modules/controlcenter/WindowTitle.qml +++ b/modules/controlcenter/WindowTitle.qml @@ -1,8 +1,8 @@ +import QtQuick +import Quickshell +import Caelestia.Config import qs.components import qs.services -import qs.config -import Quickshell -import QtQuick StyledRect { id: root @@ -10,7 +10,7 @@ StyledRect { required property ShellScreen screen required property Session session - implicitHeight: text.implicitHeight + Appearance.padding.normal + implicitHeight: text.implicitHeight + Tokens.padding.normal color: Colours.tPalette.m3surfaceContainer StyledText { @@ -21,24 +21,24 @@ StyledRect { text: qsTr("Caelestia Settings - %1").arg(root.session.active) font.capitalization: Font.Capitalize - font.pointSize: Appearance.font.size.larger + font.pointSize: Tokens.font.size.larger font.weight: 500 } Item { anchors.right: parent.right anchors.top: parent.top - anchors.margins: Appearance.padding.normal + anchors.margins: Tokens.padding.normal implicitWidth: implicitHeight - implicitHeight: closeIcon.implicitHeight + Appearance.padding.small + implicitHeight: closeIcon.implicitHeight + Tokens.padding.small StateLayer { - radius: Appearance.rounding.full - - function onClicked(): void { + onClicked: { QsWindow.window.destroy(); } + + radius: Tokens.rounding.full } MaterialIcon { diff --git a/modules/controlcenter/appearance/AppearancePane.qml b/modules/controlcenter/appearance/AppearancePane.qml index 42511677a..a1e0e42c6 100644 --- a/modules/controlcenter/appearance/AppearancePane.qml +++ b/modules/controlcenter/appearance/AppearancePane.qml @@ -4,26 +4,26 @@ import ".." import "../components" import "./sections" import "../../launcher/services" +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Caelestia.Config +import Caelestia.Models import qs.components +import qs.components.containers import qs.components.controls import qs.components.effects -import qs.components.containers import qs.components.images import qs.services -import qs.config import qs.utils -import Caelestia.Models -import Quickshell -import Quickshell.Widgets -import QtQuick -import QtQuick.Layouts Item { id: root required property Session session - property real animDurationsScale: Config.appearance.anim.durations.scale ?? 1 + property real animDurationsScale: GlobalConfig.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" @@ -31,9 +31,9 @@ Item { 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 bool transparencyEnabled: GlobalConfig.appearance.transparency.enabled ?? false + property real transparencyBase: GlobalConfig.appearance.transparency.base ?? 0.85 + property real transparencyLayers: GlobalConfig.appearance.transparency.layers ?? 0.4 property real borderRounding: Config.border.rounding ?? 1 property real borderThickness: Config.border.thickness ?? 1 @@ -48,52 +48,53 @@ Item { property bool desktopClockBackgroundBlur: Config.background.desktopClock.background.blur ?? false property bool desktopClockInvertColors: Config.background.desktopClock.invertColors ?? false property bool backgroundEnabled: Config.background.enabled ?? true + property bool wallpaperEnabled: Config.background.wallpaperEnabled ?? true property bool visualiserEnabled: Config.background.visualiser.enabled ?? false property bool visualiserAutoHide: Config.background.visualiser.autoHide ?? true property real visualiserRounding: Config.background.visualiser.rounding ?? 1 property real visualiserSpacing: Config.background.visualiser.spacing ?? 1 - anchors.fill: parent - function saveConfig() { - Config.appearance.anim.durations.scale = root.animDurationsScale; - - Config.appearance.font.family.material = root.fontFamilyMaterial; - Config.appearance.font.family.mono = root.fontFamilyMono; - Config.appearance.font.family.sans = root.fontFamilySans; - Config.appearance.font.size.scale = root.fontSizeScale; - - Config.appearance.padding.scale = root.paddingScale; - Config.appearance.rounding.scale = root.roundingScale; - Config.appearance.spacing.scale = root.spacingScale; - - Config.appearance.transparency.enabled = root.transparencyEnabled; - Config.appearance.transparency.base = root.transparencyBase; - Config.appearance.transparency.layers = root.transparencyLayers; - - Config.background.desktopClock.enabled = root.desktopClockEnabled; - Config.background.enabled = root.backgroundEnabled; - Config.background.desktopClock.scale = root.desktopClockScale; - Config.background.desktopClock.position = root.desktopClockPosition; - Config.background.desktopClock.shadow.enabled = root.desktopClockShadowEnabled; - Config.background.desktopClock.shadow.opacity = root.desktopClockShadowOpacity; - Config.background.desktopClock.shadow.blur = root.desktopClockShadowBlur; - Config.background.desktopClock.background.enabled = root.desktopClockBackgroundEnabled; - Config.background.desktopClock.background.opacity = root.desktopClockBackgroundOpacity; - Config.background.desktopClock.background.blur = root.desktopClockBackgroundBlur; - Config.background.desktopClock.invertColors = root.desktopClockInvertColors; - - Config.background.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(); + GlobalConfig.appearance.anim.durations.scale = root.animDurationsScale; + + GlobalConfig.appearance.font.family.material = root.fontFamilyMaterial; + GlobalConfig.appearance.font.family.mono = root.fontFamilyMono; + GlobalConfig.appearance.font.family.sans = root.fontFamilySans; + GlobalConfig.appearance.font.size.scale = root.fontSizeScale; + + GlobalConfig.appearance.padding.scale = root.paddingScale; + GlobalConfig.appearance.rounding.scale = root.roundingScale; + GlobalConfig.appearance.spacing.scale = root.spacingScale; + + GlobalConfig.appearance.transparency.enabled = root.transparencyEnabled; + GlobalConfig.appearance.transparency.base = root.transparencyBase; + GlobalConfig.appearance.transparency.layers = root.transparencyLayers; + + GlobalConfig.background.desktopClock.enabled = root.desktopClockEnabled; + GlobalConfig.background.enabled = root.backgroundEnabled; + GlobalConfig.background.desktopClock.scale = root.desktopClockScale; + GlobalConfig.background.desktopClock.position = root.desktopClockPosition; + GlobalConfig.background.desktopClock.shadow.enabled = root.desktopClockShadowEnabled; + GlobalConfig.background.desktopClock.shadow.opacity = root.desktopClockShadowOpacity; + GlobalConfig.background.desktopClock.shadow.blur = root.desktopClockShadowBlur; + GlobalConfig.background.desktopClock.background.enabled = root.desktopClockBackgroundEnabled; + GlobalConfig.background.desktopClock.background.opacity = root.desktopClockBackgroundOpacity; + GlobalConfig.background.desktopClock.background.blur = root.desktopClockBackgroundBlur; + GlobalConfig.background.desktopClock.invertColors = root.desktopClockInvertColors; + + GlobalConfig.background.wallpaperEnabled = root.wallpaperEnabled; + + GlobalConfig.background.visualiser.enabled = root.visualiserEnabled; + GlobalConfig.background.visualiser.autoHide = root.visualiserAutoHide; + GlobalConfig.background.visualiser.rounding = root.visualiserRounding; + GlobalConfig.background.visualiser.spacing = root.visualiserSpacing; + + GlobalConfig.border.rounding = root.borderRounding; + GlobalConfig.border.thickness = root.borderThickness; } + anchors.fill: parent + Component { id: appearanceRightContentComponent @@ -108,9 +109,9 @@ Item { StyledText { Layout.alignment: Qt.AlignHCenter - Layout.bottomMargin: Appearance.spacing.normal + Layout.bottomMargin: Tokens.spacing.normal text: qsTr("Wallpaper") - font.pointSize: Appearance.font.size.extraLarge + font.pointSize: Tokens.font.size.extraLarge font.weight: 600 } @@ -119,8 +120,9 @@ Item { Layout.fillWidth: true Layout.fillHeight: true - Layout.bottomMargin: -Appearance.padding.large * 2 + Layout.bottomMargin: -Tokens.padding.large * 2 + asynchronous: true active: { const isActive = root.session.activeIndex === 3; const isAdjacent = Math.abs(root.session.activeIndex - 3) === 1; @@ -132,7 +134,7 @@ Item { onStatusChanged: { if (status === Loader.Error) { - console.error("[AppearancePane] Wallpaper loader error!"); + console.error(lc, "Wallpaper loader error!"); } } @@ -148,10 +150,11 @@ Item { anchors.fill: parent leftContent: Component { - StyledFlickable { id: sidebarFlickable + readonly property var rootPane: root + flickableDirection: Flickable.VerticalFlick contentHeight: sidebarLayout.height @@ -161,20 +164,20 @@ Item { ColumnLayout { id: sidebarLayout - anchors.left: parent.left - anchors.right: parent.right - spacing: Appearance.spacing.small readonly property var rootPane: sidebarFlickable.rootPane - readonly property bool allSectionsExpanded: themeModeSection.expanded && colorVariantSection.expanded && colorSchemeSection.expanded && animationsSection.expanded && fontsSection.expanded && scalesSection.expanded && transparencySection.expanded && borderSection.expanded && backgroundSection.expanded + anchors.left: parent.left + anchors.right: parent.right + spacing: Tokens.spacing.small + RowLayout { - spacing: Appearance.spacing.smaller + spacing: Tokens.spacing.smaller StyledText { text: qsTr("Appearance") - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large font.weight: 500 } @@ -215,31 +218,37 @@ Item { 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 } } @@ -248,4 +257,11 @@ Item { rightContent: appearanceRightContentComponent } + + LoggingCategory { + id: lc + + name: "caelestia.qml.controlcenter.appearance" + defaultLogLevel: LoggingCategory.Info + } } diff --git a/modules/controlcenter/appearance/sections/AnimationsSection.qml b/modules/controlcenter/appearance/sections/AnimationsSection.qml index 0cba5cecd..412441abc 100644 --- a/modules/controlcenter/appearance/sections/AnimationsSection.qml +++ b/modules/controlcenter/appearance/sections/AnimationsSection.qml @@ -2,13 +2,13 @@ pragma ComponentBehavior: Bound import ".." import "../../components" +import QtQuick +import QtQuick.Layouts +import Caelestia.Config import qs.components -import qs.components.controls import qs.components.containers +import qs.components.controls import qs.services -import qs.config -import QtQuick -import QtQuick.Layouts CollapsibleSection { id: root @@ -19,7 +19,7 @@ CollapsibleSection { showBackground: true SectionContainer { - contentSpacing: Appearance.spacing.normal + contentSpacing: Tokens.spacing.normal SliderInput { Layout.fillWidth: true diff --git a/modules/controlcenter/appearance/sections/BackgroundSection.qml b/modules/controlcenter/appearance/sections/BackgroundSection.qml index 2f75c9e0e..3f31f10b4 100644 --- a/modules/controlcenter/appearance/sections/BackgroundSection.qml +++ b/modules/controlcenter/appearance/sections/BackgroundSection.qml @@ -2,13 +2,13 @@ pragma ComponentBehavior: Bound import ".." import "../../components" +import QtQuick +import QtQuick.Layouts +import Caelestia.Config import qs.components -import qs.components.controls import qs.components.containers +import qs.components.controls import qs.services -import qs.config -import QtQuick -import QtQuick.Layouts CollapsibleSection { id: root @@ -27,10 +27,19 @@ CollapsibleSection { } } + SwitchRow { + label: qsTr("Wallpaper enabled") + checked: rootPane.wallpaperEnabled + onToggled: checked => { + rootPane.wallpaperEnabled = checked; + rootPane.saveConfig(); + } + } + StyledText { - Layout.topMargin: Appearance.spacing.normal + Layout.topMargin: Tokens.spacing.normal text: qsTr("Desktop Clock") - font.pointSize: Appearance.font.size.larger + font.pointSize: Tokens.font.size.larger font.weight: 500 } @@ -46,9 +55,6 @@ CollapsibleSection { SectionContainer { id: posContainer - contentSpacing: Appearance.spacing.small - z: 1 - readonly property var pos: (rootPane.desktopClockPosition || "top-left").split('-') readonly property string currentV: pos[0] readonly property string currentH: pos[1] @@ -58,9 +64,12 @@ CollapsibleSection { rootPane.saveConfig(); } + contentSpacing: Tokens.spacing.small + z: 1 + StyledText { text: qsTr("Positioning") - font.pointSize: Appearance.font.size.larger + font.pointSize: Tokens.font.size.larger font.weight: 500 } @@ -70,19 +79,22 @@ CollapsibleSection { menuItems: [ MenuItem { + property string val: "top" + text: qsTr("Top") icon: "vertical_align_top" - property string val: "top" }, MenuItem { + property string val: "middle" + text: qsTr("Middle") icon: "vertical_align_center" - property string val: "middle" }, MenuItem { + property string val: "bottom" + text: qsTr("Bottom") icon: "vertical_align_bottom" - property string val: "bottom" } ] @@ -104,19 +116,22 @@ CollapsibleSection { menuItems: [ MenuItem { + property string val: "left" + text: qsTr("Left") icon: "align_horizontal_left" - property string val: "left" }, MenuItem { + property string val: "center" + text: qsTr("Center") icon: "align_horizontal_center" - property string val: "center" }, MenuItem { + property string val: "right" + text: qsTr("Right") icon: "align_horizontal_right" - property string val: "right" } ] @@ -141,11 +156,11 @@ CollapsibleSection { } SectionContainer { - contentSpacing: Appearance.spacing.small + contentSpacing: Tokens.spacing.small StyledText { text: qsTr("Shadow") - font.pointSize: Appearance.font.size.larger + font.pointSize: Tokens.font.size.larger font.weight: 500 } @@ -159,7 +174,7 @@ CollapsibleSection { } SectionContainer { - contentSpacing: Appearance.spacing.normal + contentSpacing: Tokens.spacing.normal SliderInput { Layout.fillWidth: true @@ -184,7 +199,7 @@ CollapsibleSection { } SectionContainer { - contentSpacing: Appearance.spacing.normal + contentSpacing: Tokens.spacing.normal SliderInput { Layout.fillWidth: true @@ -210,11 +225,11 @@ CollapsibleSection { } SectionContainer { - contentSpacing: Appearance.spacing.small + contentSpacing: Tokens.spacing.small StyledText { text: qsTr("Background") - font.pointSize: Appearance.font.size.larger + font.pointSize: Tokens.font.size.larger font.weight: 500 } @@ -237,7 +252,7 @@ CollapsibleSection { } SectionContainer { - contentSpacing: Appearance.spacing.normal + contentSpacing: Tokens.spacing.normal SliderInput { Layout.fillWidth: true @@ -263,9 +278,9 @@ CollapsibleSection { } StyledText { - Layout.topMargin: Appearance.spacing.normal + Layout.topMargin: Tokens.spacing.normal text: qsTr("Visualiser") - font.pointSize: Appearance.font.size.larger + font.pointSize: Tokens.font.size.larger font.weight: 500 } @@ -288,7 +303,7 @@ CollapsibleSection { } SectionContainer { - contentSpacing: Appearance.spacing.normal + contentSpacing: Tokens.spacing.normal SliderInput { Layout.fillWidth: true @@ -313,7 +328,7 @@ CollapsibleSection { } SectionContainer { - contentSpacing: Appearance.spacing.normal + contentSpacing: Tokens.spacing.normal SliderInput { Layout.fillWidth: true diff --git a/modules/controlcenter/appearance/sections/BorderSection.qml b/modules/controlcenter/appearance/sections/BorderSection.qml index 9532d70d6..7dbd2dbe6 100644 --- a/modules/controlcenter/appearance/sections/BorderSection.qml +++ b/modules/controlcenter/appearance/sections/BorderSection.qml @@ -2,13 +2,13 @@ pragma ComponentBehavior: Bound import ".." import "../../components" +import QtQuick +import QtQuick.Layouts +import Caelestia.Config import qs.components -import qs.components.controls import qs.components.containers +import qs.components.controls import qs.services -import qs.config -import QtQuick -import QtQuick.Layouts CollapsibleSection { id: root @@ -19,7 +19,7 @@ CollapsibleSection { showBackground: true SectionContainer { - contentSpacing: Appearance.spacing.normal + contentSpacing: Tokens.spacing.normal SliderInput { Layout.fillWidth: true @@ -43,14 +43,14 @@ CollapsibleSection { } SectionContainer { - contentSpacing: Appearance.spacing.normal + contentSpacing: Tokens.spacing.normal SliderInput { Layout.fillWidth: true label: qsTr("Border thickness") value: rootPane.borderThickness - from: 0.1 + from: 0 to: 100 decimals: 1 suffix: "px" diff --git a/modules/controlcenter/appearance/sections/ColorSchemeSection.qml b/modules/controlcenter/appearance/sections/ColorSchemeSection.qml index 95cb4b725..0a65cbdc0 100644 --- a/modules/controlcenter/appearance/sections/ColorSchemeSection.qml +++ b/modules/controlcenter/appearance/sections/ColorSchemeSection.qml @@ -2,14 +2,14 @@ pragma ComponentBehavior: Bound import ".." import "../../../launcher/services" +import QtQuick +import QtQuick.Layouts +import Quickshell +import Caelestia.Config import qs.components -import qs.components.controls import qs.components.containers +import qs.components.controls import qs.services -import qs.config -import Quickshell -import QtQuick -import QtQuick.Layouts CollapsibleSection { title: qsTr("Color scheme") @@ -18,7 +18,7 @@ CollapsibleSection { ColumnLayout { Layout.fillWidth: true - spacing: Appearance.spacing.small / 2 + spacing: Tokens.spacing.small / 2 Repeater { model: Schemes.list @@ -32,12 +32,13 @@ CollapsibleSection { readonly property bool isCurrent: schemeKey === Schemes.currentScheme color: Qt.alpha(Colours.tPalette.m3surfaceContainer, isCurrent ? Colours.tPalette.m3surfaceContainer.a : 0) - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal border.width: isCurrent ? 1 : 0 border.color: Colours.palette.m3primary + implicitHeight: schemeRow.implicitHeight + Tokens.padding.normal * 2 StateLayer { - function onClicked(): void { + onClicked: { const name = modelData.name; const flavour = modelData.flavour; const schemeKey = `${name} ${flavour}`; @@ -53,6 +54,7 @@ CollapsibleSection { Timer { id: reloadTimer + interval: 300 onTriggered: { Schemes.reload(); @@ -63,9 +65,9 @@ CollapsibleSection { id: schemeRow anchors.fill: parent - anchors.margins: Appearance.padding.normal + anchors.margins: Tokens.padding.normal - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal StyledRect { id: preview @@ -76,15 +78,16 @@ CollapsibleSection { border.color: Qt.alpha(`#${modelData.colours?.outline}`, 0.5) color: `#${modelData.colours?.surface}` - radius: Appearance.rounding.full + radius: Tokens.rounding.full implicitWidth: iconPlaceholder.implicitWidth implicitHeight: iconPlaceholder.implicitWidth MaterialIcon { id: iconPlaceholder + visible: false text: "circle" - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large } Item { @@ -102,7 +105,7 @@ CollapsibleSection { implicitWidth: preview.implicitWidth color: `#${modelData.colours?.primary}` - radius: Appearance.rounding.full + radius: Tokens.rounding.full } } } @@ -113,12 +116,12 @@ CollapsibleSection { StyledText { text: modelData.flavour ?? "" - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.font.size.normal } StyledText { text: modelData.name ?? "" - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small color: Colours.palette.m3outline elide: Text.ElideRight @@ -128,17 +131,16 @@ CollapsibleSection { } Loader { + asynchronous: true active: isCurrent sourceComponent: MaterialIcon { text: "check" color: Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large } } } - - implicitHeight: schemeRow.implicitHeight + Appearance.padding.normal * 2 } } } diff --git a/modules/controlcenter/appearance/sections/ColorVariantSection.qml b/modules/controlcenter/appearance/sections/ColorVariantSection.qml index 3aa17dd9c..c612485d0 100644 --- a/modules/controlcenter/appearance/sections/ColorVariantSection.qml +++ b/modules/controlcenter/appearance/sections/ColorVariantSection.qml @@ -2,14 +2,14 @@ pragma ComponentBehavior: Bound import ".." import "../../../launcher/services" +import QtQuick +import QtQuick.Layouts +import Quickshell +import Caelestia.Config import qs.components -import qs.components.controls import qs.components.containers +import qs.components.controls import qs.services -import qs.config -import Quickshell -import QtQuick -import QtQuick.Layouts CollapsibleSection { title: qsTr("Color variant") @@ -18,7 +18,7 @@ CollapsibleSection { ColumnLayout { Layout.fillWidth: true - spacing: Appearance.spacing.small / 2 + spacing: Tokens.spacing.small / 2 Repeater { model: M3Variants.list @@ -29,12 +29,13 @@ CollapsibleSection { Layout.fillWidth: true color: Qt.alpha(Colours.tPalette.m3surfaceContainer, modelData.variant === Schemes.currentVariant ? Colours.tPalette.m3surfaceContainer.a : 0) - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal border.width: modelData.variant === Schemes.currentVariant ? 1 : 0 border.color: Colours.palette.m3primary + implicitHeight: variantRow.implicitHeight + Tokens.padding.normal * 2 StateLayer { - function onClicked(): void { + onClicked: { const variant = modelData.variant; Schemes.currentVariant = variant; @@ -48,6 +49,7 @@ CollapsibleSection { Timer { id: reloadTimer + interval: 300 onTriggered: { Schemes.reload(); @@ -60,13 +62,13 @@ CollapsibleSection { anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - anchors.margins: Appearance.padding.normal + anchors.margins: Tokens.padding.normal - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal MaterialIcon { text: modelData.icon - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large fill: modelData.variant === Schemes.currentVariant ? 1 : 0 } @@ -80,11 +82,9 @@ CollapsibleSection { visible: modelData.variant === Schemes.currentVariant text: "check" color: Colours.palette.m3primary - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large } } - - implicitHeight: variantRow.implicitHeight + Appearance.padding.normal * 2 } } } diff --git a/modules/controlcenter/appearance/sections/FontsSection.qml b/modules/controlcenter/appearance/sections/FontsSection.qml index 3988863af..10040c587 100644 --- a/modules/controlcenter/appearance/sections/FontsSection.qml +++ b/modules/controlcenter/appearance/sections/FontsSection.qml @@ -2,13 +2,13 @@ pragma ComponentBehavior: Bound import ".." import "../../components" +import QtQuick +import QtQuick.Layouts +import Caelestia.Config import qs.components -import qs.components.controls import qs.components.containers +import qs.components.controls import qs.services -import qs.config -import QtQuick -import QtQuick.Layouts CollapsibleSection { id: root @@ -19,62 +19,64 @@ CollapsibleSection { showBackground: true CollapsibleSection { - id: materialFontSection - title: qsTr("Material font family") + id: sansFontSection + + title: qsTr("Sans-serif font family") expanded: true showBackground: true nested: true Loader { - id: materialFontLoader Layout.fillWidth: true Layout.preferredHeight: item ? Math.min(item.contentHeight, 300) : 0 - active: materialFontSection.expanded + asynchronous: true + active: sansFontSection.expanded sourceComponent: StyledListView { - id: materialFontList - property alias contentHeight: materialFontList.contentHeight + id: sansFontList + + property alias contentHeight: sansFontList.contentHeight clip: true - spacing: Appearance.spacing.small / 2 + spacing: Tokens.spacing.small / 2 model: Qt.fontFamilies() StyledScrollBar.vertical: StyledScrollBar { - flickable: materialFontList + flickable: sansFontList } delegate: StyledRect { required property string modelData required property int index + readonly property bool isCurrent: modelData === rootPane.fontFamilySans width: ListView.view.width - - readonly property bool isCurrent: modelData === rootPane.fontFamilyMaterial color: Qt.alpha(Colours.tPalette.m3surfaceContainer, isCurrent ? Colours.tPalette.m3surfaceContainer.a : 0) - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal border.width: isCurrent ? 1 : 0 border.color: Colours.palette.m3primary + implicitHeight: fontFamilySansRow.implicitHeight + Tokens.padding.normal * 2 StateLayer { - function onClicked(): void { - rootPane.fontFamilyMaterial = modelData; + onClicked: { + rootPane.fontFamilySans = modelData; rootPane.saveConfig(); } } RowLayout { - id: fontFamilyMaterialRow + id: fontFamilySansRow anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - anchors.margins: Appearance.padding.normal + anchors.margins: Tokens.padding.normal - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal StyledText { text: modelData - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.font.size.normal } Item { @@ -82,17 +84,16 @@ CollapsibleSection { } Loader { + asynchronous: true active: isCurrent sourceComponent: MaterialIcon { text: "check" color: Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large } } } - - implicitHeight: fontFamilyMaterialRow.implicitHeight + Appearance.padding.normal * 2 } } } @@ -100,6 +101,7 @@ CollapsibleSection { CollapsibleSection { id: monoFontSection + title: qsTr("Monospace font family") expanded: false showBackground: true @@ -108,14 +110,16 @@ CollapsibleSection { 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 + spacing: Tokens.spacing.small / 2 model: Qt.fontFamilies() StyledScrollBar.vertical: StyledScrollBar { @@ -125,17 +129,17 @@ CollapsibleSection { delegate: StyledRect { required property string modelData required property int index + readonly property bool isCurrent: modelData === rootPane.fontFamilyMono width: ListView.view.width - - readonly property bool isCurrent: modelData === rootPane.fontFamilyMono color: Qt.alpha(Colours.tPalette.m3surfaceContainer, isCurrent ? Colours.tPalette.m3surfaceContainer.a : 0) - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal border.width: isCurrent ? 1 : 0 border.color: Colours.palette.m3primary + implicitHeight: fontFamilyMonoRow.implicitHeight + Tokens.padding.normal * 2 StateLayer { - function onClicked(): void { + onClicked: { rootPane.fontFamilyMono = modelData; rootPane.saveConfig(); } @@ -147,13 +151,13 @@ CollapsibleSection { anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - anchors.margins: Appearance.padding.normal + anchors.margins: Tokens.padding.normal - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal StyledText { text: modelData - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.font.size.normal } Item { @@ -161,78 +165,82 @@ CollapsibleSection { } Loader { + asynchronous: true active: isCurrent sourceComponent: MaterialIcon { text: "check" color: Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large } } } - - implicitHeight: fontFamilyMonoRow.implicitHeight + Appearance.padding.normal * 2 } } } } CollapsibleSection { - id: sansFontSection - title: qsTr("Sans-serif font family") + 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 - active: sansFontSection.expanded + asynchronous: true + active: materialFontSection.expanded sourceComponent: StyledListView { - id: sansFontList - property alias contentHeight: sansFontList.contentHeight + id: materialFontList + + property alias contentHeight: materialFontList.contentHeight clip: true - spacing: Appearance.spacing.small / 2 - model: Qt.fontFamilies() + spacing: Tokens.spacing.small / 2 + model: Qt.fontFamilies().filter(f => f.startsWith("Material Symbols")) StyledScrollBar.vertical: StyledScrollBar { - flickable: sansFontList + flickable: materialFontList } delegate: StyledRect { required property string modelData required property int index + readonly property bool isCurrent: modelData === rootPane.fontFamilyMaterial width: ListView.view.width - - readonly property bool isCurrent: modelData === rootPane.fontFamilySans color: Qt.alpha(Colours.tPalette.m3surfaceContainer, isCurrent ? Colours.tPalette.m3surfaceContainer.a : 0) - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal border.width: isCurrent ? 1 : 0 border.color: Colours.palette.m3primary + implicitHeight: fontFamilyMaterialRow.implicitHeight + Tokens.padding.normal * 2 StateLayer { - function onClicked(): void { - rootPane.fontFamilySans = modelData; + onClicked: { + rootPane.fontFamilyMaterial = modelData; rootPane.saveConfig(); } } RowLayout { - id: fontFamilySansRow + id: fontFamilyMaterialRow anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - anchors.margins: Appearance.padding.normal + anchors.margins: Tokens.padding.normal - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal StyledText { text: modelData - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.font.size.normal } Item { @@ -240,24 +248,23 @@ CollapsibleSection { } Loader { + asynchronous: true active: isCurrent sourceComponent: MaterialIcon { text: "check" color: Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large } } } - - implicitHeight: fontFamilySansRow.implicitHeight + Appearance.padding.normal * 2 } } } } SectionContainer { - contentSpacing: Appearance.spacing.normal + contentSpacing: Tokens.spacing.normal SliderInput { Layout.fillWidth: true diff --git a/modules/controlcenter/appearance/sections/ScalesSection.qml b/modules/controlcenter/appearance/sections/ScalesSection.qml index b0e6e38b8..dac6226e8 100644 --- a/modules/controlcenter/appearance/sections/ScalesSection.qml +++ b/modules/controlcenter/appearance/sections/ScalesSection.qml @@ -2,13 +2,13 @@ pragma ComponentBehavior: Bound import ".." import "../../components" +import QtQuick +import QtQuick.Layouts +import Caelestia.Config import qs.components -import qs.components.controls import qs.components.containers +import qs.components.controls import qs.services -import qs.config -import QtQuick -import QtQuick.Layouts CollapsibleSection { id: root @@ -19,7 +19,7 @@ CollapsibleSection { showBackground: true SectionContainer { - contentSpacing: Appearance.spacing.normal + contentSpacing: Tokens.spacing.normal SliderInput { Layout.fillWidth: true @@ -43,7 +43,7 @@ CollapsibleSection { } SectionContainer { - contentSpacing: Appearance.spacing.normal + contentSpacing: Tokens.spacing.normal SliderInput { Layout.fillWidth: true @@ -67,7 +67,7 @@ CollapsibleSection { } SectionContainer { - contentSpacing: Appearance.spacing.normal + contentSpacing: Tokens.spacing.normal SliderInput { Layout.fillWidth: true diff --git a/modules/controlcenter/appearance/sections/ThemeModeSection.qml b/modules/controlcenter/appearance/sections/ThemeModeSection.qml index 04eed9113..aab53b8bd 100644 --- a/modules/controlcenter/appearance/sections/ThemeModeSection.qml +++ b/modules/controlcenter/appearance/sections/ThemeModeSection.qml @@ -1,12 +1,12 @@ pragma ComponentBehavior: Bound import ".." +import QtQuick +import Caelestia.Config import qs.components -import qs.components.controls import qs.components.containers +import qs.components.controls import qs.services -import qs.config -import QtQuick CollapsibleSection { title: qsTr("Theme mode") diff --git a/modules/controlcenter/appearance/sections/TransparencySection.qml b/modules/controlcenter/appearance/sections/TransparencySection.qml index 9a48629c1..f2f15ba13 100644 --- a/modules/controlcenter/appearance/sections/TransparencySection.qml +++ b/modules/controlcenter/appearance/sections/TransparencySection.qml @@ -2,13 +2,13 @@ pragma ComponentBehavior: Bound import ".." import "../../components" +import QtQuick +import QtQuick.Layouts +import Caelestia.Config import qs.components -import qs.components.controls import qs.components.containers +import qs.components.controls import qs.services -import qs.config -import QtQuick -import QtQuick.Layouts CollapsibleSection { id: root @@ -28,7 +28,7 @@ CollapsibleSection { } SectionContainer { - contentSpacing: Appearance.spacing.normal + contentSpacing: Tokens.spacing.normal SliderInput { Layout.fillWidth: true @@ -53,7 +53,7 @@ CollapsibleSection { } SectionContainer { - contentSpacing: Appearance.spacing.normal + contentSpacing: Tokens.spacing.normal SliderInput { Layout.fillWidth: true diff --git a/modules/controlcenter/audio/AudioPane.qml b/modules/controlcenter/audio/AudioPane.qml index 01d90be70..f3edb7319 100644 --- a/modules/controlcenter/audio/AudioPane.qml +++ b/modules/controlcenter/audio/AudioPane.qml @@ -2,15 +2,15 @@ pragma ComponentBehavior: Bound import ".." import "../components" +import QtQuick +import QtQuick.Layouts +import Quickshell.Widgets +import Caelestia.Config import qs.components +import qs.components.containers import qs.components.controls import qs.components.effects -import qs.components.containers import qs.services -import qs.config -import Quickshell.Widgets -import QtQuick -import QtQuick.Layouts Item { id: root @@ -23,9 +23,9 @@ Item { anchors.fill: parent leftContent: Component { - StyledFlickable { id: leftAudioFlickable + flickableDirection: Flickable.VerticalFlick contentHeight: leftContent.height @@ -38,15 +38,15 @@ Item { anchors.left: parent.left anchors.right: parent.right - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal RowLayout { Layout.fillWidth: true - spacing: Appearance.spacing.smaller + spacing: Tokens.spacing.smaller StyledText { text: qsTr("Audio") - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large font.weight: 500 } @@ -64,15 +64,15 @@ Item { ColumnLayout { Layout.fillWidth: true - spacing: Appearance.spacing.small + spacing: Tokens.spacing.small RowLayout { Layout.fillWidth: true - spacing: Appearance.spacing.small + spacing: Tokens.spacing.small StyledText { text: qsTr("Devices (%1)").arg(Audio.sinks.length) - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.font.size.normal font.weight: 500 } } @@ -93,10 +93,11 @@ Item { Layout.fillWidth: true color: Audio.sink?.id === modelData.id ? Colours.layer(Colours.palette.m3surfaceContainer, 2) : "transparent" - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal + implicitHeight: outputRowLayout.implicitHeight + Tokens.padding.normal * 2 StateLayer { - function onClicked(): void { + onClicked: { Audio.setAudioSink(modelData); } } @@ -107,13 +108,13 @@ Item { anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - anchors.margins: Appearance.padding.normal + anchors.margins: Tokens.padding.normal - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal MaterialIcon { text: Audio.sink?.id === modelData.id ? "speaker" : "speaker_group" - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large fill: Audio.sink?.id === modelData.id ? 1 : 0 } @@ -126,8 +127,6 @@ Item { font.weight: Audio.sink?.id === modelData.id ? 500 : 400 } } - - implicitHeight: outputRowLayout.implicitHeight + Appearance.padding.normal * 2 } } } @@ -142,15 +141,15 @@ Item { ColumnLayout { Layout.fillWidth: true - spacing: Appearance.spacing.small + spacing: Tokens.spacing.small RowLayout { Layout.fillWidth: true - spacing: Appearance.spacing.small + spacing: Tokens.spacing.small StyledText { text: qsTr("Devices (%1)").arg(Audio.sources.length) - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.font.size.normal font.weight: 500 } } @@ -171,10 +170,11 @@ Item { Layout.fillWidth: true color: Audio.source?.id === modelData.id ? Colours.layer(Colours.palette.m3surfaceContainer, 2) : "transparent" - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal + implicitHeight: inputRowLayout.implicitHeight + Tokens.padding.normal * 2 StateLayer { - function onClicked(): void { + onClicked: { Audio.setAudioSource(modelData); } } @@ -185,13 +185,13 @@ Item { anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - anchors.margins: Appearance.padding.normal + anchors.margins: Tokens.padding.normal - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal MaterialIcon { text: "mic" - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large fill: Audio.source?.id === modelData.id ? 1 : 0 } @@ -204,8 +204,6 @@ Item { font.weight: Audio.source?.id === modelData.id ? 500 : 400 } } - - implicitHeight: inputRowLayout.implicitHeight + Appearance.padding.normal * 2 } } } @@ -217,6 +215,7 @@ Item { rightContent: Component { StyledFlickable { id: rightAudioFlickable + flickableDirection: Flickable.VerticalFlick contentHeight: contentLayout.height @@ -230,7 +229,7 @@ Item { anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal SettingsHeader { icon: "volume_up" @@ -243,19 +242,19 @@ Item { } SectionContainer { - contentSpacing: Appearance.spacing.normal + contentSpacing: Tokens.spacing.normal ColumnLayout { Layout.fillWidth: true - spacing: Appearance.spacing.small + spacing: Tokens.spacing.small RowLayout { Layout.fillWidth: true - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal StyledText { text: qsTr("Volume") - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.font.size.normal font.weight: 500 } @@ -265,6 +264,7 @@ Item { StyledInputField { id: outputVolumeInput + Layout.preferredWidth: 70 validator: IntValidator { bottom: 0 @@ -276,15 +276,6 @@ Item { text = Math.round(Audio.volume * 100).toString(); } - Connections { - target: Audio - function onVolumeChanged() { - if (!outputVolumeInput.hasFocus) { - outputVolumeInput.text = Math.round(Audio.volume * 100).toString(); - } - } - } - onTextEdited: text => { if (hasFocus) { const val = parseInt(text); @@ -300,24 +291,34 @@ Item { text = Math.round(Audio.volume * 100).toString(); } } + + Connections { + function onVolumeChanged() { + if (!outputVolumeInput.hasFocus) { + outputVolumeInput.text = Math.round(Audio.volume * 100).toString(); + } + } + + target: Audio + } } StyledText { text: "%" color: Colours.palette.m3outline - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.font.size.normal opacity: Audio.muted ? 0.5 : 1 } StyledRect { implicitWidth: implicitHeight - implicitHeight: muteIcon.implicitHeight + Appearance.padding.normal * 2 + implicitHeight: muteIcon.implicitHeight + Tokens.padding.normal * 2 - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal color: Audio.muted ? Colours.palette.m3secondary : Colours.palette.m3secondaryContainer StateLayer { - function onClicked(): void { + onClicked: { if (Audio.sink?.audio) { Audio.sink.audio.muted = !Audio.sink.audio.muted; } @@ -336,8 +337,9 @@ Item { StyledSlider { id: outputVolumeSlider + Layout.fillWidth: true - implicitHeight: Appearance.padding.normal * 3 + implicitHeight: Tokens.padding.normal * 3 value: Audio.volume enabled: !Audio.muted @@ -358,19 +360,19 @@ Item { } SectionContainer { - contentSpacing: Appearance.spacing.normal + contentSpacing: Tokens.spacing.normal ColumnLayout { Layout.fillWidth: true - spacing: Appearance.spacing.small + spacing: Tokens.spacing.small RowLayout { Layout.fillWidth: true - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal StyledText { text: qsTr("Volume") - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.font.size.normal font.weight: 500 } @@ -380,6 +382,7 @@ Item { StyledInputField { id: inputVolumeInput + Layout.preferredWidth: 70 validator: IntValidator { bottom: 0 @@ -391,15 +394,6 @@ Item { text = Math.round(Audio.sourceVolume * 100).toString(); } - Connections { - target: Audio - function onSourceVolumeChanged() { - if (!inputVolumeInput.hasFocus) { - inputVolumeInput.text = Math.round(Audio.sourceVolume * 100).toString(); - } - } - } - onTextEdited: text => { if (hasFocus) { const val = parseInt(text); @@ -415,24 +409,34 @@ Item { text = Math.round(Audio.sourceVolume * 100).toString(); } } + + Connections { + function onSourceVolumeChanged() { + if (!inputVolumeInput.hasFocus) { + inputVolumeInput.text = Math.round(Audio.sourceVolume * 100).toString(); + } + } + + target: Audio + } } StyledText { text: "%" color: Colours.palette.m3outline - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.font.size.normal opacity: Audio.sourceMuted ? 0.5 : 1 } StyledRect { implicitWidth: implicitHeight - implicitHeight: muteInputIcon.implicitHeight + Appearance.padding.normal * 2 + implicitHeight: muteInputIcon.implicitHeight + Tokens.padding.normal * 2 - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal color: Audio.sourceMuted ? Colours.palette.m3secondary : Colours.palette.m3secondaryContainer StateLayer { - function onClicked(): void { + onClicked: { if (Audio.source?.audio) { Audio.source.audio.muted = !Audio.source.audio.muted; } @@ -451,8 +455,9 @@ Item { StyledSlider { id: inputVolumeSlider + Layout.fillWidth: true - implicitHeight: Appearance.padding.normal * 3 + implicitHeight: Tokens.padding.normal * 3 value: Audio.sourceVolume enabled: !Audio.sourceMuted @@ -473,11 +478,11 @@ Item { } SectionContainer { - contentSpacing: Appearance.spacing.normal + contentSpacing: Tokens.spacing.normal ColumnLayout { Layout.fillWidth: true - spacing: Appearance.spacing.small + spacing: Tokens.spacing.small Repeater { model: Audio.streams @@ -488,15 +493,15 @@ Item { required property int index Layout.fillWidth: true - spacing: Appearance.spacing.smaller + spacing: Tokens.spacing.smaller RowLayout { Layout.fillWidth: true - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal MaterialIcon { text: "apps" - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.font.size.normal fill: 0 } @@ -505,12 +510,13 @@ Item { elide: Text.ElideRight maximumLineCount: 1 text: Audio.getStreamName(modelData) - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.font.size.normal font.weight: 500 } StyledInputField { id: streamVolumeInput + Layout.preferredWidth: 70 validator: IntValidator { bottom: 0 @@ -522,15 +528,6 @@ Item { text = Math.round(Audio.getStreamVolume(modelData) * 100).toString(); } - Connections { - target: modelData - function onAudioChanged() { - if (!streamVolumeInput.hasFocus && modelData?.audio) { - streamVolumeInput.text = Math.round(modelData.audio.volume * 100).toString(); - } - } - } - onTextEdited: text => { if (hasFocus) { const val = parseInt(text); @@ -546,24 +543,34 @@ Item { text = Math.round(Audio.getStreamVolume(modelData) * 100).toString(); } } + + Connections { + function onAudioChanged() { + if (!streamVolumeInput.hasFocus && modelData?.audio) { + streamVolumeInput.text = Math.round(modelData.audio.volume * 100).toString(); + } + } + + target: modelData + } } StyledText { text: "%" color: Colours.palette.m3outline - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.font.size.normal opacity: Audio.getStreamMuted(modelData) ? 0.5 : 1 } StyledRect { implicitWidth: implicitHeight - implicitHeight: streamMuteIcon.implicitHeight + Appearance.padding.normal * 2 + implicitHeight: streamMuteIcon.implicitHeight + Tokens.padding.normal * 2 - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal color: Audio.getStreamMuted(modelData) ? Colours.palette.m3secondary : Colours.palette.m3secondaryContainer StateLayer { - function onClicked(): void { + onClicked: { Audio.setStreamMuted(modelData, !Audio.getStreamMuted(modelData)); } } @@ -580,7 +587,7 @@ Item { StyledSlider { Layout.fillWidth: true - implicitHeight: Appearance.padding.normal * 3 + implicitHeight: Tokens.padding.normal * 3 value: Audio.getStreamVolume(modelData) enabled: !Audio.getStreamMuted(modelData) @@ -593,12 +600,13 @@ Item { } Connections { - target: modelData function onAudioChanged() { if (modelData?.audio) { value = modelData.audio.volume; } } + + target: modelData } } } @@ -609,7 +617,7 @@ Item { visible: Audio.streams.length === 0 text: qsTr("No applications currently playing audio") color: Colours.palette.m3outline - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small horizontalAlignment: Text.AlignHCenter } } diff --git a/modules/controlcenter/bluetooth/BtPane.qml b/modules/controlcenter/bluetooth/BtPane.qml index 7d3b9ca33..b2b9c0f69 100644 --- a/modules/controlcenter/bluetooth/BtPane.qml +++ b/modules/controlcenter/bluetooth/BtPane.qml @@ -3,13 +3,13 @@ pragma ComponentBehavior: Bound import ".." import "../components" import "." +import QtQuick +import Quickshell.Bluetooth +import Quickshell.Widgets +import Caelestia.Config import qs.components -import qs.components.controls import qs.components.containers -import qs.config -import Quickshell.Widgets -import Quickshell.Bluetooth -import QtQuick +import qs.components.controls SplitPaneWithDetails { id: root @@ -53,6 +53,7 @@ SplitPaneWithDetails { rightSettingsComponent: Component { StyledFlickable { id: settingsFlickable + flickableDirection: Flickable.VerticalFlick contentHeight: settingsInner.height diff --git a/modules/controlcenter/bluetooth/Details.qml b/modules/controlcenter/bluetooth/Details.qml index 529904546..415752265 100644 --- a/modules/controlcenter/bluetooth/Details.qml +++ b/modules/controlcenter/bluetooth/Details.qml @@ -2,16 +2,16 @@ pragma ComponentBehavior: Bound import ".." import "../components" +import QtQuick +import QtQuick.Layouts +import Quickshell.Bluetooth +import Caelestia.Config import qs.components +import qs.components.containers 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 @@ -54,12 +54,12 @@ StyledFlickable { sections: [ Component { ColumnLayout { - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal StyledText { - Layout.topMargin: Appearance.spacing.large + Layout.topMargin: Tokens.spacing.large text: qsTr("Connection status") - font.pointSize: Appearance.font.size.larger + font.pointSize: Tokens.font.size.larger font.weight: 500 } @@ -70,9 +70,9 @@ StyledFlickable { StyledRect { Layout.fillWidth: true - implicitHeight: deviceStatus.implicitHeight + Appearance.padding.large * 2 + implicitHeight: deviceStatus.implicitHeight + Tokens.padding.large * 2 - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal color: Colours.tPalette.m3surfaceContainer ColumnLayout { @@ -81,9 +81,9 @@ StyledFlickable { anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - anchors.margins: Appearance.padding.large + anchors.margins: Tokens.padding.large - spacing: Appearance.spacing.larger + spacing: Tokens.spacing.larger Toggle { label: qsTr("Connected") @@ -113,12 +113,12 @@ StyledFlickable { }, Component { ColumnLayout { - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal StyledText { - Layout.topMargin: Appearance.spacing.large + Layout.topMargin: Tokens.spacing.large text: qsTr("Device properties") - font.pointSize: Appearance.font.size.larger + font.pointSize: Tokens.font.size.larger font.weight: 500 } @@ -129,9 +129,9 @@ StyledFlickable { StyledRect { Layout.fillWidth: true - implicitHeight: deviceProps.implicitHeight + Appearance.padding.large * 2 + implicitHeight: deviceProps.implicitHeight + Tokens.padding.large * 2 - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal color: Colours.tPalette.m3surfaceContainer ColumnLayout { @@ -140,19 +140,19 @@ StyledFlickable { anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - anchors.margins: Appearance.padding.large + anchors.margins: Tokens.padding.large - spacing: Appearance.spacing.larger + spacing: Tokens.spacing.larger RowLayout { Layout.fillWidth: true - spacing: Appearance.spacing.small + spacing: Tokens.spacing.small Item { id: renameDevice Layout.fillWidth: true - Layout.rightMargin: Appearance.spacing.small + Layout.rightMargin: Tokens.spacing.small implicitHeight: renameLabel.implicitHeight + deviceNameEdit.implicitHeight @@ -167,15 +167,13 @@ StyledFlickable { PropertyChanges { renameDevice.implicitHeight: deviceNameEdit.implicitHeight renameLabel.opacity: 0 - deviceNameEdit.padding: Appearance.padding.normal + deviceNameEdit.padding: root.Tokens.padding.normal } } transitions: Transition { - AnchorAnimation { - duration: Appearance.anim.durations.normal - easing.type: Easing.BezierSpline - easing.bezierCurve: Appearance.anim.curves.standard + AnchorAnim { + type: AnchorAnim.Standard } Anim { properties: "implicitHeight,opacity,padding" @@ -189,7 +187,7 @@ StyledFlickable { text: qsTr("Device name") color: Colours.palette.m3outline - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small } StyledTextField { @@ -198,7 +196,7 @@ StyledFlickable { anchors.left: parent.left anchors.right: parent.right anchors.top: renameLabel.bottom - anchors.leftMargin: root.session.bt.editingDeviceName ? 0 : -Appearance.padding.normal + anchors.leftMargin: root.session.bt.editingDeviceName ? 0 : -Tokens.padding.normal text: root.device?.name ?? "" readOnly: !root.session.bt.editingDeviceName @@ -207,11 +205,11 @@ StyledFlickable { root.device.name = text; } - leftPadding: Appearance.padding.normal - rightPadding: Appearance.padding.normal + leftPadding: Tokens.padding.normal + rightPadding: Tokens.padding.normal background: StyledRect { - radius: Appearance.rounding.small + radius: Tokens.rounding.small border.width: 2 border.color: Colours.palette.m3primary opacity: root.session.bt.editingDeviceName ? 1 : 0 @@ -233,21 +231,21 @@ StyledFlickable { StyledRect { implicitWidth: implicitHeight - implicitHeight: cancelEditIcon.implicitHeight + Appearance.padding.smaller * 2 + implicitHeight: cancelEditIcon.implicitHeight + Tokens.padding.smaller * 2 - radius: Appearance.rounding.small + radius: Tokens.rounding.small color: Colours.palette.m3secondaryContainer opacity: root.session.bt.editingDeviceName ? 1 : 0 scale: root.session.bt.editingDeviceName ? 1 : 0.5 StateLayer { - color: Colours.palette.m3onSecondaryContainer - disabled: !root.session.bt.editingDeviceName - - function onClicked(): void { + onClicked: { root.session.bt.editingDeviceName = false; deviceNameEdit.text = Qt.binding(() => root.device?.name ?? ""); } + + color: Colours.palette.m3onSecondaryContainer + disabled: !root.session.bt.editingDeviceName } MaterialIcon { @@ -265,29 +263,28 @@ StyledFlickable { Behavior on scale { Anim { - duration: Appearance.anim.durations.expressiveFastSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + type: Anim.FastSpatial } } } StyledRect { implicitWidth: implicitHeight - implicitHeight: editIcon.implicitHeight + Appearance.padding.smaller * 2 + implicitHeight: editIcon.implicitHeight + Tokens.padding.smaller * 2 - radius: root.session.bt.editingDeviceName ? Appearance.rounding.small : implicitHeight / 2 * Math.min(1, Appearance.rounding.scale) + radius: root.session.bt.editingDeviceName ? Tokens.rounding.small : implicitHeight / 2 * Math.min(1, Tokens.rounding.scale) color: Qt.alpha(Colours.palette.m3primary, root.session.bt.editingDeviceName ? 1 : 0) StateLayer { - color: root.session.bt.editingDeviceName ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface - - function onClicked(): void { + onClicked: { 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 { @@ -322,12 +319,12 @@ StyledFlickable { }, Component { ColumnLayout { - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal StyledText { - Layout.topMargin: Appearance.spacing.large + Layout.topMargin: Tokens.spacing.large text: qsTr("Device information") - font.pointSize: Appearance.font.size.larger + font.pointSize: Tokens.font.size.larger font.weight: 500 } @@ -338,9 +335,9 @@ StyledFlickable { StyledRect { Layout.fillWidth: true - implicitHeight: deviceInfo.implicitHeight + Appearance.padding.large * 2 + implicitHeight: deviceInfo.implicitHeight + Tokens.padding.large * 2 - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal color: Colours.tPalette.m3surfaceContainer ColumnLayout { @@ -349,88 +346,83 @@ StyledFlickable { anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - anchors.margins: Appearance.padding.large + anchors.margins: Tokens.padding.large - spacing: Appearance.spacing.small / 2 + spacing: Tokens.spacing.small / 2 StyledText { text: root.device?.batteryAvailable ? qsTr("Device battery (%1%)").arg(root.device.battery * 100) : qsTr("Battery unavailable") } RowLayout { - Layout.topMargin: Appearance.spacing.small / 2 - Layout.fillWidth: true - Layout.preferredHeight: Appearance.padding.smaller - spacing: Appearance.spacing.small / 2 + id: batteryPercent - StyledRect { - Layout.fillHeight: true - implicitWidth: root.device?.batteryAvailable ? parent.width * root.device.battery : 0 - radius: Appearance.rounding.full - color: Colours.palette.m3primary - } + Layout.topMargin: Tokens.spacing.small / 2 + Layout.fillWidth: true + Layout.preferredHeight: Tokens.padding.smaller + spacing: Tokens.spacing.small / 2 StyledRect { Layout.fillWidth: true Layout.fillHeight: true - radius: Appearance.rounding.full + radius: Tokens.rounding.full color: Colours.palette.m3secondaryContainer StyledRect { - anchors.right: parent.right + anchors.left: parent.left anchors.top: parent.top anchors.bottom: parent.bottom anchors.margins: parent.height * 0.25 - implicitWidth: height - radius: Appearance.rounding.full + implicitWidth: root.device?.batteryAvailable ? batteryPercent.width * root.device.battery : 0 + radius: Tokens.rounding.full color: Colours.palette.m3primary } } } StyledText { - Layout.topMargin: Appearance.spacing.normal + Layout.topMargin: Tokens.spacing.normal text: qsTr("Dbus path") } StyledText { text: root.device?.dbusPath ?? "" color: Colours.palette.m3outline - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small } StyledText { - Layout.topMargin: Appearance.spacing.normal + Layout.topMargin: Tokens.spacing.normal text: qsTr("MAC address") } StyledText { text: root.device?.address ?? "" color: Colours.palette.m3outline - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small } StyledText { - Layout.topMargin: Appearance.spacing.normal + Layout.topMargin: Tokens.spacing.normal text: qsTr("Bonded") } StyledText { text: root.device?.bonded ? qsTr("Yes") : qsTr("No") color: Colours.palette.m3outline - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small } StyledText { - Layout.topMargin: Appearance.spacing.normal + Layout.topMargin: Tokens.spacing.normal text: qsTr("System name") } StyledText { text: root.device?.deviceName ?? "" color: Colours.palette.m3outline - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small } } } @@ -443,7 +435,7 @@ StyledFlickable { ColumnLayout { anchors.right: fabRoot.right anchors.bottom: fabRoot.top - anchors.bottomMargin: Appearance.padding.normal + anchors.bottomMargin: Tokens.padding.normal Repeater { id: fabMenu @@ -475,9 +467,9 @@ StyledFlickable { Layout.alignment: Qt.AlignRight - implicitHeight: fabMenuItemInner.implicitHeight + Appearance.padding.larger * 2 + implicitHeight: fabMenuItemInner.implicitHeight + Tokens.padding.larger * 2 - radius: Appearance.rounding.full + radius: Tokens.rounding.full color: Colours.palette.m3primaryContainer opacity: 0 @@ -487,7 +479,7 @@ StyledFlickable { when: root.session.bt.fabMenuOpen PropertyChanges { - fabMenuItem.implicitWidth: fabMenuItemInner.implicitWidth + Appearance.padding.large * 2 + fabMenuItem.implicitWidth: fabMenuItemInner.implicitWidth + root.Tokens.padding.large * 2 fabMenuItem.opacity: 1 fabMenuItemInner.opacity: 1 } @@ -499,17 +491,16 @@ StyledFlickable { SequentialAnimation { PauseAnimation { - duration: (fabMenu.count - 1 - fabMenuItem.index) * Appearance.anim.durations.small / 8 + duration: (fabMenu.count - 1 - fabMenuItem.index) * Tokens.anim.durations.small / 8 } ParallelAnimation { Anim { property: "implicitWidth" - duration: Appearance.anim.durations.expressiveFastSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + type: Anim.FastSpatial } Anim { property: "opacity" - duration: Appearance.anim.durations.small + type: Anim.StandardSmall } } } @@ -519,17 +510,16 @@ StyledFlickable { SequentialAnimation { PauseAnimation { - duration: fabMenuItem.index * Appearance.anim.durations.small / 8 + duration: fabMenuItem.index * Tokens.anim.durations.small / 8 } ParallelAnimation { Anim { property: "implicitWidth" - duration: Appearance.anim.durations.expressiveFastSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + type: Anim.FastSpatial } Anim { property: "opacity" - duration: Appearance.anim.durations.small + type: Anim.StandardSmall } } } @@ -537,7 +527,7 @@ StyledFlickable { ] StateLayer { - function onClicked(): void { + onClicked: { root.session.bt.fabMenuOpen = false; const name = fabMenuItem.modelData.name; @@ -554,7 +544,7 @@ StyledFlickable { id: fabMenuItemInner anchors.centerIn: parent - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal opacity: 0 MaterialIcon { @@ -572,7 +562,7 @@ StyledFlickable { Behavior on Layout.preferredWidth { Anim { - duration: Appearance.anim.durations.small + type: Anim.StandardSmall } } } @@ -599,7 +589,7 @@ StyledFlickable { implicitWidth: 64 implicitHeight: 64 - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal color: root.session.bt.fabMenuOpen ? Colours.palette.m3primary : Colours.palette.m3primaryContainer states: State { @@ -610,15 +600,14 @@ StyledFlickable { fabBg.implicitWidth: 48 fabBg.implicitHeight: 48 fabBg.radius: 48 / 2 - fab.font.pointSize: Appearance.font.size.larger + fab.font.pointSize: Tokens.font.size.larger } } transitions: Transition { Anim { properties: "implicitWidth,implicitHeight" - duration: Appearance.anim.durations.expressiveFastSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + type: Anim.FastSpatial } Anim { properties: "radius,font.pointSize" @@ -635,11 +624,11 @@ StyledFlickable { StateLayer { id: fabState - color: root.session.bt.fabMenuOpen ? Colours.palette.m3onPrimary : Colours.palette.m3onPrimaryContainer - - function onClicked(): void { + onClicked: { root.session.bt.fabMenuOpen = !root.session.bt.fabMenuOpen; } + + color: root.session.bt.fabMenuOpen ? Colours.palette.m3onPrimary : Colours.palette.m3onPrimaryContainer } MaterialIcon { @@ -649,7 +638,7 @@ StyledFlickable { 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 + font.pointSize: Tokens.font.size.large fill: 1 } } @@ -661,7 +650,7 @@ StyledFlickable { property alias toggle: toggle Layout.fillWidth: true - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal StyledText { Layout.fillWidth: true diff --git a/modules/controlcenter/bluetooth/DeviceList.qml b/modules/controlcenter/bluetooth/DeviceList.qml index 2a2bde934..419410eef 100644 --- a/modules/controlcenter/bluetooth/DeviceList.qml +++ b/modules/controlcenter/bluetooth/DeviceList.qml @@ -2,16 +2,16 @@ pragma ComponentBehavior: Bound import ".." import "../components" +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Bluetooth +import Caelestia.Config import qs.components -import qs.components.controls import qs.components.containers +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 @@ -32,11 +32,11 @@ DeviceList { headerComponent: Component { RowLayout { - spacing: Appearance.spacing.smaller + spacing: Tokens.spacing.smaller StyledText { text: qsTr("Bluetooth") - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large font.weight: 500 } @@ -48,9 +48,9 @@ DeviceList { toggled: Bluetooth.defaultAdapter?.enabled ?? false icon: "power" accent: "Tertiary" - iconSize: Appearance.font.size.normal - horizontalPadding: Appearance.padding.normal - verticalPadding: Appearance.padding.smaller + iconSize: Tokens.font.size.normal + horizontalPadding: Tokens.padding.normal + verticalPadding: Tokens.padding.smaller tooltip: qsTr("Toggle Bluetooth") onClicked: { @@ -64,9 +64,9 @@ DeviceList { 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 + iconSize: Tokens.font.size.normal + horizontalPadding: Tokens.padding.normal + verticalPadding: Tokens.padding.smaller tooltip: qsTr("Make discoverable") onClicked: { @@ -80,9 +80,9 @@ DeviceList { 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 + iconSize: Tokens.font.size.normal + horizontalPadding: Tokens.padding.normal + verticalPadding: Tokens.padding.smaller tooltip: qsTr("Make pairable") onClicked: { @@ -96,9 +96,9 @@ DeviceList { toggled: Bluetooth.defaultAdapter?.discovering ?? false icon: "bluetooth_searching" accent: "Secondary" - iconSize: Appearance.font.size.normal - horizontalPadding: Appearance.padding.normal - verticalPadding: Appearance.padding.smaller + iconSize: Tokens.font.size.normal + horizontalPadding: Tokens.padding.normal + verticalPadding: Tokens.padding.smaller tooltip: qsTr("Scan for devices") onClicked: { @@ -112,9 +112,9 @@ DeviceList { toggled: !root.session.bt.active icon: "settings" accent: "Primary" - iconSize: Appearance.font.size.normal - horizontalPadding: Appearance.padding.normal - verticalPadding: Appearance.padding.smaller + iconSize: Tokens.font.size.normal + horizontalPadding: Tokens.padding.normal + verticalPadding: Tokens.padding.smaller tooltip: qsTr("Bluetooth settings") onClicked: { @@ -137,15 +137,15 @@ DeviceList { readonly property bool connected: modelData && modelData.state === BluetoothDeviceState.Connected width: ListView.view ? ListView.view.width : undefined - implicitHeight: deviceInner.implicitHeight + Appearance.padding.normal * 2 + implicitHeight: deviceInner.implicitHeight + Tokens.padding.normal * 2 color: Qt.alpha(Colours.tPalette.m3surfaceContainer, root.activeItem === modelData ? Colours.tPalette.m3surfaceContainer.a : 0) - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal StateLayer { id: stateLayer - function onClicked(): void { + onClicked: { if (device.modelData) root.session.bt.active = device.modelData; } @@ -155,15 +155,15 @@ DeviceList { id: deviceInner anchors.fill: parent - anchors.margins: Appearance.padding.normal + anchors.margins: Tokens.padding.normal - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal StyledRect { implicitWidth: implicitHeight - implicitHeight: icon.implicitHeight + Appearance.padding.normal * 2 + implicitHeight: icon.implicitHeight + Tokens.padding.normal * 2 - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal color: device.connected ? Colours.palette.m3primaryContainer : (device.modelData && device.modelData.bonded) ? Colours.palette.m3secondaryContainer : Colours.tPalette.m3surfaceContainerHigh StyledRect { @@ -178,7 +178,7 @@ DeviceList { 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 + font.pointSize: Tokens.font.size.large fill: device.connected ? 1 : 0 Behavior on fill { @@ -202,7 +202,7 @@ DeviceList { 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 + font.pointSize: Tokens.font.size.small elide: Text.ElideRight } } @@ -211,9 +211,9 @@ DeviceList { id: connectBtn implicitWidth: implicitHeight - implicitHeight: connectIcon.implicitHeight + Appearance.padding.smaller * 2 + implicitHeight: connectIcon.implicitHeight + Tokens.padding.smaller * 2 - radius: Appearance.rounding.full + radius: Tokens.rounding.full color: Qt.alpha(Colours.palette.m3primaryContainer, device.connected ? 1 : 0) CircularIndicator { @@ -222,10 +222,7 @@ DeviceList { } StateLayer { - color: device.connected ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface - disabled: device.loading - - function onClicked(): void { + onClicked: { if (device.loading) return; @@ -239,6 +236,9 @@ DeviceList { } } } + + color: device.connected ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface + disabled: device.loading } MaterialIcon { diff --git a/modules/controlcenter/bluetooth/Settings.qml b/modules/controlcenter/bluetooth/Settings.qml index c5472406b..1202cc5bf 100644 --- a/modules/controlcenter/bluetooth/Settings.qml +++ b/modules/controlcenter/bluetooth/Settings.qml @@ -2,21 +2,21 @@ pragma ComponentBehavior: Bound import ".." import "../components" +import QtQuick +import QtQuick.Layouts +import Quickshell.Bluetooth +import Caelestia.Config 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 + spacing: Tokens.spacing.normal SettingsHeader { icon: "bluetooth" @@ -24,9 +24,9 @@ ColumnLayout { } StyledText { - Layout.topMargin: Appearance.spacing.large + Layout.topMargin: Tokens.spacing.large text: qsTr("Adapter status") - font.pointSize: Appearance.font.size.larger + font.pointSize: Tokens.font.size.larger font.weight: 500 } @@ -37,9 +37,9 @@ ColumnLayout { StyledRect { Layout.fillWidth: true - implicitHeight: adapterStatus.implicitHeight + Appearance.padding.large * 2 + implicitHeight: adapterStatus.implicitHeight + Tokens.padding.large * 2 - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal color: Colours.tPalette.m3surfaceContainer ColumnLayout { @@ -48,9 +48,9 @@ ColumnLayout { anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - anchors.margins: Appearance.padding.large + anchors.margins: Tokens.padding.large - spacing: Appearance.spacing.larger + spacing: Tokens.spacing.larger Toggle { label: qsTr("Powered") @@ -85,9 +85,9 @@ ColumnLayout { } StyledText { - Layout.topMargin: Appearance.spacing.large + Layout.topMargin: Tokens.spacing.large text: qsTr("Adapter properties") - font.pointSize: Appearance.font.size.larger + font.pointSize: Tokens.font.size.larger font.weight: 500 } @@ -98,9 +98,9 @@ ColumnLayout { StyledRect { Layout.fillWidth: true - implicitHeight: adapterSettings.implicitHeight + Appearance.padding.large * 2 + implicitHeight: adapterSettings.implicitHeight + Tokens.padding.large * 2 - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal color: Colours.tPalette.m3surfaceContainer ColumnLayout { @@ -109,13 +109,13 @@ ColumnLayout { anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - anchors.margins: Appearance.padding.large + anchors.margins: Tokens.padding.large - spacing: Appearance.spacing.larger + spacing: Tokens.spacing.larger RowLayout { Layout.fillWidth: true - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal StyledText { Layout.fillWidth: true @@ -127,28 +127,28 @@ ColumnLayout { property bool expanded - implicitWidth: adapterPicker.implicitWidth + Appearance.padding.normal * 2 - implicitHeight: adapterPicker.implicitHeight + Appearance.padding.smaller * 2 + implicitWidth: adapterPicker.implicitWidth + Tokens.padding.normal * 2 + implicitHeight: adapterPicker.implicitHeight + Tokens.padding.smaller * 2 StateLayer { - radius: Appearance.rounding.small - - function onClicked(): void { + onClicked: { adapterPickerButton.expanded = !adapterPickerButton.expanded; } + + radius: Tokens.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 + anchors.margins: Tokens.padding.normal + anchors.topMargin: Tokens.padding.smaller + anchors.bottomMargin: Tokens.padding.smaller + spacing: Tokens.spacing.normal StyledText { - Layout.leftMargin: Appearance.padding.small + Layout.leftMargin: Tokens.padding.small text: Bluetooth.defaultAdapter?.name ?? qsTr("None") } @@ -170,8 +170,7 @@ ColumnLayout { Behavior on scale { Anim { - duration: Appearance.anim.durations.expressiveFastSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + type: Anim.FastSpatial } } } @@ -185,7 +184,7 @@ ColumnLayout { implicitHeight: adapterPickerButton.expanded ? adapterList.implicitHeight : adapterPickerButton.implicitHeight color: Colours.palette.m3secondaryContainer - radius: Appearance.rounding.small + radius: Tokens.rounding.small opacity: adapterPickerButton.expanded ? 1 : 0 scale: adapterPickerButton.expanded ? 1 : 0.7 @@ -207,15 +206,15 @@ ColumnLayout { required property BluetoothAdapter modelData Layout.fillWidth: true - implicitHeight: adapterInner.implicitHeight + Appearance.padding.normal * 2 + implicitHeight: adapterInner.implicitHeight + Tokens.padding.normal * 2 StateLayer { - disabled: !adapterPickerButton.expanded - - function onClicked(): void { + onClicked: { adapterPickerButton.expanded = false; root.session.bt.currentAdapter = adapter.modelData; } + + disabled: !adapterPickerButton.expanded } RowLayout { @@ -224,12 +223,12 @@ ColumnLayout { anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - anchors.margins: Appearance.padding.normal - spacing: Appearance.spacing.normal + anchors.margins: Tokens.padding.normal + spacing: Tokens.spacing.normal StyledText { Layout.fillWidth: true - Layout.leftMargin: Appearance.padding.small + Layout.leftMargin: Tokens.padding.small text: adapter.modelData.name color: Colours.palette.m3onSecondaryContainer } @@ -250,15 +249,13 @@ ColumnLayout { Behavior on scale { Anim { - duration: Appearance.anim.durations.expressiveFastSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + type: Anim.FastSpatial } } Behavior on implicitHeight { Anim { - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + type: Anim.DefaultSpatial } } } @@ -267,7 +264,7 @@ ColumnLayout { RowLayout { Layout.fillWidth: true - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal StyledText { Layout.fillWidth: true @@ -287,13 +284,13 @@ ColumnLayout { RowLayout { Layout.fillWidth: true - spacing: Appearance.spacing.small + spacing: Tokens.spacing.small Item { id: renameAdapter Layout.fillWidth: true - Layout.rightMargin: Appearance.spacing.small + Layout.rightMargin: Tokens.spacing.small implicitHeight: renameLabel.implicitHeight + adapterNameEdit.implicitHeight @@ -308,15 +305,13 @@ ColumnLayout { PropertyChanges { renameAdapter.implicitHeight: adapterNameEdit.implicitHeight renameLabel.opacity: 0 - adapterNameEdit.padding: Appearance.padding.normal + adapterNameEdit.padding: Tokens.padding.normal } } transitions: Transition { - AnchorAnimation { - duration: Appearance.anim.durations.normal - easing.type: Easing.BezierSpline - easing.bezierCurve: Appearance.anim.curves.standard + AnchorAnim { + type: AnchorAnim.Standard } Anim { properties: "implicitHeight,opacity,padding" @@ -330,7 +325,7 @@ ColumnLayout { text: qsTr("Rename adapter (currently does not work)") color: Colours.palette.m3outline - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small } StyledTextField { @@ -339,7 +334,7 @@ ColumnLayout { anchors.left: parent.left anchors.right: parent.right anchors.top: renameLabel.bottom - anchors.leftMargin: root.session.bt.editingAdapterName ? 0 : -Appearance.padding.normal + anchors.leftMargin: root.session.bt.editingAdapterName ? 0 : -Tokens.padding.normal text: root.session.bt.currentAdapter?.name ?? "" readOnly: !root.session.bt.editingAdapterName @@ -347,11 +342,11 @@ ColumnLayout { root.session.bt.editingAdapterName = false; } - leftPadding: Appearance.padding.normal - rightPadding: Appearance.padding.normal + leftPadding: Tokens.padding.normal + rightPadding: Tokens.padding.normal background: StyledRect { - radius: Appearance.rounding.small + radius: Tokens.rounding.small border.width: 2 border.color: Colours.palette.m3primary opacity: root.session.bt.editingAdapterName ? 1 : 0 @@ -373,21 +368,21 @@ ColumnLayout { StyledRect { implicitWidth: implicitHeight - implicitHeight: cancelEditIcon.implicitHeight + Appearance.padding.smaller * 2 + implicitHeight: cancelEditIcon.implicitHeight + Tokens.padding.smaller * 2 - radius: Appearance.rounding.small + radius: Tokens.rounding.small color: Colours.palette.m3secondaryContainer opacity: root.session.bt.editingAdapterName ? 1 : 0 scale: root.session.bt.editingAdapterName ? 1 : 0.5 StateLayer { - color: Colours.palette.m3onSecondaryContainer - disabled: !root.session.bt.editingAdapterName - - function onClicked(): void { + onClicked: { root.session.bt.editingAdapterName = false; adapterNameEdit.text = Qt.binding(() => root.session.bt.currentAdapter?.name ?? ""); } + + color: Colours.palette.m3onSecondaryContainer + disabled: !root.session.bt.editingAdapterName } MaterialIcon { @@ -405,29 +400,28 @@ ColumnLayout { Behavior on scale { Anim { - duration: Appearance.anim.durations.expressiveFastSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + type: Anim.FastSpatial } } } StyledRect { implicitWidth: implicitHeight - implicitHeight: editIcon.implicitHeight + Appearance.padding.smaller * 2 + implicitHeight: editIcon.implicitHeight + Tokens.padding.smaller * 2 - radius: root.session.bt.editingAdapterName ? Appearance.rounding.small : implicitHeight / 2 * Math.min(1, Appearance.rounding.scale) + radius: root.session.bt.editingAdapterName ? Tokens.rounding.small : implicitHeight / 2 * Math.min(1, Tokens.rounding.scale) color: Qt.alpha(Colours.palette.m3primary, root.session.bt.editingAdapterName ? 1 : 0) StateLayer { - color: root.session.bt.editingAdapterName ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface - - function onClicked(): void { + onClicked: { 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 { @@ -448,9 +442,9 @@ ColumnLayout { } StyledText { - Layout.topMargin: Appearance.spacing.large + Layout.topMargin: Tokens.spacing.large text: qsTr("Adapter information") - font.pointSize: Appearance.font.size.larger + font.pointSize: Tokens.font.size.larger font.weight: 500 } @@ -461,9 +455,9 @@ ColumnLayout { StyledRect { Layout.fillWidth: true - implicitHeight: adapterInfo.implicitHeight + Appearance.padding.large * 2 + implicitHeight: adapterInfo.implicitHeight + Tokens.padding.large * 2 - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal color: Colours.tPalette.m3surfaceContainer ColumnLayout { @@ -472,9 +466,9 @@ ColumnLayout { anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - anchors.margins: Appearance.padding.large + anchors.margins: Tokens.padding.large - spacing: Appearance.spacing.small / 2 + spacing: Tokens.spacing.small / 2 StyledText { text: qsTr("Adapter state") @@ -483,29 +477,29 @@ ColumnLayout { StyledText { text: Bluetooth.defaultAdapter ? BluetoothAdapterState.toString(Bluetooth.defaultAdapter.state) : qsTr("Unknown") color: Colours.palette.m3outline - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small } StyledText { - Layout.topMargin: Appearance.spacing.normal + Layout.topMargin: Tokens.spacing.normal text: qsTr("Dbus path") } StyledText { text: Bluetooth.defaultAdapter?.dbusPath ?? "" color: Colours.palette.m3outline - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small } StyledText { - Layout.topMargin: Appearance.spacing.normal + Layout.topMargin: Tokens.spacing.normal text: qsTr("Adapter id") } StyledText { text: Bluetooth.defaultAdapter?.adapterId ?? "" color: Colours.palette.m3outline - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small } } } @@ -516,7 +510,7 @@ ColumnLayout { property alias toggle: toggle Layout.fillWidth: true - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal StyledText { Layout.fillWidth: true diff --git a/modules/controlcenter/taskbar/ConnectedButtonGroup.qml b/modules/controlcenter/components/ConnectedButtonGroup.qml similarity index 71% rename from modules/controlcenter/taskbar/ConnectedButtonGroup.qml rename to modules/controlcenter/components/ConnectedButtonGroup.qml index 01cd612c9..f782838d6 100644 --- a/modules/controlcenter/taskbar/ConnectedButtonGroup.qml +++ b/modules/controlcenter/components/ConnectedButtonGroup.qml @@ -1,22 +1,23 @@ import ".." +import QtQuick +import QtQuick.Layouts +import Caelestia.Config import qs.components import qs.components.controls import qs.components.effects import qs.services -import qs.config -import QtQuick -import QtQuick.Layouts StyledRect { id: root - property var options: [] // Array of {label: string, propertyName: string, onToggled: function} + property var 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 + implicitHeight: layout.implicitHeight + Tokens.padding.large * 2 + radius: Tokens.rounding.normal color: Colours.layer(Colours.palette.m3surfaceContainer, 2) clip: true @@ -28,41 +29,48 @@ StyledRect { id: layout anchors.fill: parent - anchors.margins: Appearance.padding.large - spacing: Appearance.spacing.normal + anchors.margins: Tokens.padding.large + spacing: Tokens.spacing.normal StyledText { visible: root.title !== "" text: root.title - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.font.size.normal } - RowLayout { - id: buttonRow + GridLayout { + id: buttonGrid + Layout.alignment: Qt.AlignHCenter - spacing: Appearance.spacing.small + rowSpacing: Tokens.spacing.small + columnSpacing: Tokens.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 - Layout.fillWidth: true - text: modelData.label - 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 (root.rootItem && modelData.propertyName) { + if (modelData.state !== undefined && modelData.state) { + _checked = modelData.state; + } else if (root.rootItem && modelData.propertyName) { const propName = modelData.propertyName; const rootItem = root.rootItem; _checked = Qt.binding(function () { @@ -73,13 +81,13 @@ StyledRect { // 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 + radius: stateLayer.pressed ? Tokens.rounding.small / 2 : internalChecked ? Tokens.rounding.small : Tokens.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) + Layout.preferredWidth: implicitWidth + (stateLayer.pressed ? Tokens.padding.large : internalChecked ? Tokens.padding.smaller : 0) onClicked: { if (modelData.onToggled && root.rootItem && modelData.propertyName) { @@ -90,15 +98,13 @@ StyledRect { Behavior on Layout.preferredWidth { Anim { - duration: Appearance.anim.durations.expressiveFastSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + type: Anim.FastSpatial } } Behavior on radius { Anim { - duration: Appearance.anim.durations.expressiveFastSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + type: Anim.FastSpatial } } } diff --git a/modules/controlcenter/components/DeviceDetails.qml b/modules/controlcenter/components/DeviceDetails.qml index a5d06471c..075024f6c 100644 --- a/modules/controlcenter/components/DeviceDetails.qml +++ b/modules/controlcenter/components/DeviceDetails.qml @@ -1,13 +1,13 @@ pragma ComponentBehavior: Bound import ".." +import QtQuick +import QtQuick.Layouts +import Caelestia.Config import qs.components +import qs.components.containers import qs.components.controls import qs.components.effects -import qs.components.containers -import qs.config -import QtQuick -import QtQuick.Layouts Item { id: root @@ -30,12 +30,13 @@ Item { anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal Loader { id: headerLoader Layout.fillWidth: true + asynchronous: true sourceComponent: root.headerComponent visible: root.headerComponent !== null } @@ -44,6 +45,7 @@ Item { id: topContentLoader Layout.fillWidth: true + asynchronous: true sourceComponent: root.topContent visible: root.topContent !== null } @@ -55,6 +57,7 @@ Item { required property Component modelData Layout.fillWidth: true + asynchronous: true sourceComponent: modelData } } @@ -63,6 +66,7 @@ Item { id: bottomContentLoader Layout.fillWidth: true + asynchronous: true sourceComponent: root.bottomContent visible: root.bottomContent !== null } diff --git a/modules/controlcenter/components/DeviceList.qml b/modules/controlcenter/components/DeviceList.qml index 722f9a16e..f02cc3c08 100644 --- a/modules/controlcenter/components/DeviceList.qml +++ b/modules/controlcenter/components/DeviceList.qml @@ -1,14 +1,14 @@ pragma ComponentBehavior: Bound import ".." +import QtQuick +import QtQuick.Layouts +import Quickshell +import Caelestia.Config import qs.components -import qs.components.controls import qs.components.containers +import qs.components.controls import qs.services -import qs.config -import Quickshell -import QtQuick -import QtQuick.Layouts ColumnLayout { id: root @@ -23,15 +23,17 @@ ColumnLayout { property Component headerComponent: null property Component titleSuffix: null property bool showHeader: true + property alias view: view signal itemSelected(var item) - spacing: Appearance.spacing.small + spacing: Tokens.spacing.small Loader { id: headerLoader Layout.fillWidth: true + asynchronous: true sourceComponent: root.headerComponent visible: root.headerComponent !== null && root.showHeader } @@ -39,17 +41,18 @@ ColumnLayout { RowLayout { Layout.fillWidth: true Layout.topMargin: root.headerComponent ? 0 : 0 - spacing: Appearance.spacing.small + spacing: Tokens.spacing.small visible: root.title !== "" || root.description !== "" StyledText { visible: root.title !== "" text: root.title - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large font.weight: 500 } Loader { + asynchronous: true sourceComponent: root.titleSuffix visible: root.titleSuffix !== null } @@ -59,8 +62,6 @@ ColumnLayout { } } - property alias view: view - StyledText { visible: root.description !== "" Layout.fillWidth: true @@ -77,7 +78,7 @@ ColumnLayout { model: root.model delegate: root.delegate - spacing: Appearance.spacing.small / 2 + spacing: Tokens.spacing.small / 2 interactive: false clip: false } diff --git a/modules/controlcenter/components/PaneTransition.qml b/modules/controlcenter/components/PaneTransition.qml index 5d80dbec2..489d3c784 100644 --- a/modules/controlcenter/components/PaneTransition.qml +++ b/modules/controlcenter/components/PaneTransition.qml @@ -1,7 +1,7 @@ pragma ComponentBehavior: Bound -import qs.config import QtQuick +import Caelestia.Config SequentialAnimation { id: root @@ -20,9 +20,8 @@ SequentialAnimation { property: "opacity" from: root.opacityFrom to: root.opacityTo - duration: Appearance.anim.durations.normal / 2 - easing.type: Easing.BezierSpline - easing.bezierCurve: Appearance.anim.curves.standardAccel + duration: Tokens.anim.durations.normal / 2 + easing: Tokens.anim.standardAccel } NumberAnimation { @@ -30,9 +29,8 @@ SequentialAnimation { property: "scale" from: root.scaleFrom to: root.scaleTo - duration: Appearance.anim.durations.normal / 2 - easing.type: Easing.BezierSpline - easing.bezierCurve: Appearance.anim.curves.standardAccel + duration: Tokens.anim.durations.normal / 2 + easing: Tokens.anim.standardAccel } } @@ -53,9 +51,8 @@ SequentialAnimation { property: "opacity" from: root.opacityTo to: root.opacityFrom - duration: Appearance.anim.durations.normal / 2 - easing.type: Easing.BezierSpline - easing.bezierCurve: Appearance.anim.curves.standardDecel + duration: Tokens.anim.durations.normal / 2 + easing: Tokens.anim.standardDecel } NumberAnimation { @@ -63,9 +60,8 @@ SequentialAnimation { property: "scale" from: root.scaleTo to: root.scaleFrom - duration: Appearance.anim.durations.normal / 2 - easing.type: Easing.BezierSpline - easing.bezierCurve: Appearance.anim.curves.standardDecel + duration: Tokens.anim.durations.normal / 2 + easing: Tokens.anim.standardDecel } } } diff --git a/modules/controlcenter/components/ReadonlySlider.qml b/modules/controlcenter/components/ReadonlySlider.qml new file mode 100644 index 000000000..8cd493f40 --- /dev/null +++ b/modules/controlcenter/components/ReadonlySlider.qml @@ -0,0 +1,67 @@ +import ".." +import "../components" +import QtQuick +import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.components.controls +import qs.services + +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: Tokens.spacing.small + + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.normal + + StyledText { + visible: root.label !== "" + text: root.label + font.pointSize: Tokens.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: Tokens.font.size.small + } + + StyledText { + text: Math.round(root.value) + (root.suffix !== "" ? " " + root.suffix : "") + font.pointSize: Tokens.font.size.normal + color: root.readonly ? Colours.palette.m3outline : Colours.palette.m3onSurface + } + } + + StyledRect { + Layout.fillWidth: true + implicitHeight: Tokens.padding.normal + radius: Tokens.rounding.full + color: Colours.layer(Colours.palette.m3surfaceContainerHighest, 1) + opacity: root.readonly ? 0.5 : 1.0 + + StyledRect { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: parent.width * ((root.value - root.from) / (root.to - root.from)) + radius: parent.radius + color: root.readonly ? Colours.palette.m3outline : Colours.palette.m3primary + } + } +} diff --git a/modules/controlcenter/components/SettingsHeader.qml b/modules/controlcenter/components/SettingsHeader.qml index 0dc190c05..c6ccbb67a 100644 --- a/modules/controlcenter/components/SettingsHeader.qml +++ b/modules/controlcenter/components/SettingsHeader.qml @@ -1,9 +1,9 @@ pragma ComponentBehavior: Bound -import qs.components -import qs.config import QtQuick import QtQuick.Layouts +import Caelestia.Config +import qs.components Item { id: root @@ -18,19 +18,19 @@ Item { id: column anchors.centerIn: parent - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal MaterialIcon { Layout.alignment: Qt.AlignHCenter text: root.icon - font.pointSize: Appearance.font.size.extraLarge * 3 + font.pointSize: Tokens.font.size.extraLarge * 3 font.bold: true } StyledText { Layout.alignment: Qt.AlignHCenter text: root.title - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large font.bold: true } } diff --git a/modules/controlcenter/components/SliderInput.qml b/modules/controlcenter/components/SliderInput.qml index 11b3f70dd..73fd00ae6 100644 --- a/modules/controlcenter/components/SliderInput.qml +++ b/modules/controlcenter/components/SliderInput.qml @@ -1,12 +1,12 @@ pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Caelestia.Config import qs.components import qs.components.controls import qs.components.effects import qs.services -import qs.config -import QtQuick -import QtQuick.Layouts ColumnLayout { id: root @@ -21,6 +21,9 @@ ColumnLayout { 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) { @@ -49,11 +52,7 @@ ColumnLayout { return parseFloat(text); } - signal valueModified(real newValue) - - property bool _initialized: false - - spacing: Appearance.spacing.small + spacing: Tokens.spacing.small Component.onCompleted: { // Set initialized flag after a brief delay to allow component to fully load @@ -62,14 +61,22 @@ ColumnLayout { }); } + // 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 + spacing: Tokens.spacing.normal StyledText { visible: root.label !== "" text: root.label - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.font.size.normal } Item { @@ -78,6 +85,7 @@ ColumnLayout { StyledInputField { id: inputField + Layout.preferredWidth: 70 validator: root.validator @@ -130,7 +138,7 @@ ColumnLayout { visible: root.suffix !== "" text: root.suffix color: Colours.palette.m3outline - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.font.size.normal } } @@ -138,20 +146,12 @@ ColumnLayout { id: slider Layout.fillWidth: true - implicitHeight: Appearance.padding.normal * 3 + implicitHeight: Tokens.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) @@ -168,13 +168,13 @@ ColumnLayout { inputField.text = root.formatValue(newValue); } } - } - // Update input field when value changes externally (slider is already bound) - onValueChanged: { - // Only update if component is initialized to avoid issues during creation - if (root._initialized && !inputField.hasFocus) { - inputField.text = root.formatValue(root.value); + // Use Binding to allow slider to move freely during dragging + Binding { + target: slider + property: "value" + value: root.value + when: !slider.pressed } } } diff --git a/modules/controlcenter/components/SplitPaneLayout.qml b/modules/controlcenter/components/SplitPaneLayout.qml index 89504a0b8..64c7c9fc8 100644 --- a/modules/controlcenter/components/SplitPaneLayout.qml +++ b/modules/controlcenter/components/SplitPaneLayout.qml @@ -1,28 +1,26 @@ pragma ComponentBehavior: Bound -import qs.components -import qs.components.effects -import qs.config -import Quickshell.Widgets import QtQuick import QtQuick.Layouts +import Quickshell.Widgets +import Caelestia.Config +import qs.components +import qs.components.effects RowLayout { id: root - spacing: 0 - property Component leftContent: null property Component rightContent: null - property real leftWidthRatio: 0.4 property int leftMinimumWidth: 420 property var leftLoaderProperties: ({}) property var rightLoaderProperties: ({}) - property alias leftLoader: leftLoader property alias rightLoader: rightLoader + spacing: 0 + Item { id: leftPane @@ -34,9 +32,9 @@ RowLayout { id: leftClippingRect anchors.fill: parent - anchors.margins: Appearance.padding.normal + anchors.margins: Tokens.padding.normal anchors.leftMargin: 0 - anchors.rightMargin: Appearance.padding.normal / 2 + anchors.rightMargin: Tokens.padding.normal / 2 radius: leftBorder.innerRadius color: "transparent" @@ -45,10 +43,11 @@ RowLayout { 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 + anchors.margins: Tokens.padding.large + Tokens.padding.normal + anchors.leftMargin: Tokens.padding.large + anchors.rightMargin: Tokens.padding.large + Tokens.padding.normal / 2 + asynchronous: true sourceComponent: root.leftContent Component.onCompleted: { @@ -63,7 +62,7 @@ RowLayout { id: leftBorder leftThickness: 0 - rightThickness: Appearance.padding.normal / 2 + rightThickness: Tokens.padding.normal / 2 } } @@ -77,9 +76,9 @@ RowLayout { id: rightClippingRect anchors.fill: parent - anchors.margins: Appearance.padding.normal + anchors.margins: Tokens.padding.normal anchors.leftMargin: 0 - anchors.rightMargin: Appearance.padding.normal / 2 + anchors.rightMargin: Tokens.padding.normal / 2 radius: rightBorder.innerRadius color: "transparent" @@ -88,8 +87,9 @@ RowLayout { id: rightLoader anchors.fill: parent - anchors.margins: Appearance.padding.large * 2 + anchors.margins: Tokens.padding.large * 2 + asynchronous: true sourceComponent: root.rightContent Component.onCompleted: { @@ -103,7 +103,7 @@ RowLayout { InnerBorder { id: rightBorder - leftThickness: Appearance.padding.normal / 2 + leftThickness: Tokens.padding.normal / 2 } } } diff --git a/modules/controlcenter/components/SplitPaneWithDetails.qml b/modules/controlcenter/components/SplitPaneWithDetails.qml index ce8c9d07d..ad1c444d4 100644 --- a/modules/controlcenter/components/SplitPaneWithDetails.qml +++ b/modules/controlcenter/components/SplitPaneWithDetails.qml @@ -1,13 +1,13 @@ pragma ComponentBehavior: Bound import ".." -import qs.components -import qs.components.effects -import qs.components.containers -import qs.config -import Quickshell.Widgets import QtQuick import QtQuick.Layouts +import Quickshell.Widgets +import Caelestia.Config +import qs.components +import qs.components.containers +import qs.components.effects Item { id: root @@ -48,11 +48,17 @@ Item { nextComponent = targetComponent; } + onPaneChanged: { + nextComponent = getComponentForPane(); + paneId = root.paneIdGenerator(pane); + } + Loader { id: rightLoader anchors.fill: parent + asynchronous: true opacity: 1 scale: 1 transformOrigin: Item.Center @@ -73,11 +79,6 @@ Item { ] } } - - onPaneChanged: { - nextComponent = getComponentForPane(); - paneId = root.paneIdGenerator(pane); - } } } } @@ -86,6 +87,7 @@ Item { id: overlayLoader anchors.fill: parent + asynchronous: true z: 1000 sourceComponent: root.overlayComponent active: root.overlayComponent !== null diff --git a/modules/controlcenter/components/WallpaperGrid.qml b/modules/controlcenter/components/WallpaperGrid.qml index ed6bb40a8..44a615c6b 100644 --- a/modules/controlcenter/components/WallpaperGrid.qml +++ b/modules/controlcenter/components/WallpaperGrid.qml @@ -1,25 +1,25 @@ pragma ComponentBehavior: Bound import ".." +import QtQuick +import Caelestia.Config +import Caelestia.Models import qs.components import qs.components.controls import qs.components.effects import qs.components.images import qs.services -import qs.config -import Caelestia.Models -import QtQuick GridView { id: root required property Session session - readonly property int minCellWidth: 200 + Appearance.spacing.normal + readonly property int minCellWidth: 200 + Tokens.spacing.normal readonly property int columnsCount: Math.max(1, Math.floor(width / minCellWidth)) cellWidth: width / columnsCount - cellHeight: 140 + Appearance.spacing.normal + cellHeight: 140 + Tokens.spacing.normal model: Wallpapers.list @@ -32,25 +32,24 @@ GridView { delegate: Item { required property var modelData required property int index + readonly property bool isCurrent: modelData && modelData.path === Wallpapers.actualCurrent + readonly property real itemMargin: Tokens.spacing.normal / 2 + readonly property real itemRadius: Tokens.rounding.normal width: root.cellWidth height: root.cellHeight - readonly property bool isCurrent: modelData && modelData.path === Wallpapers.actualCurrent - readonly property real itemMargin: Appearance.spacing.normal / 2 - readonly property real itemRadius: Appearance.rounding.normal - StateLayer { + onClicked: { + Wallpapers.setWallpaper(modelData.path); + } + anchors.fill: parent anchors.leftMargin: itemMargin anchors.rightMargin: itemMargin anchors.topMargin: itemMargin anchors.bottomMargin: itemMargin radius: itemRadius - - function onClicked(): void { - Wallpapers.setWallpaper(modelData.path); - } } StyledClippingRect { @@ -117,6 +116,7 @@ GridView { id: fallbackTimer property bool triggered: false + interval: 800 running: cachingImage.status === Image.Loading || cachingImage.status === Image.Null onTriggered: triggered = true @@ -130,7 +130,7 @@ GridView { anchors.right: parent.right anchors.bottom: parent.bottom - implicitHeight: filenameText.implicitHeight + Appearance.padding.normal * 1.5 + implicitHeight: filenameText.implicitHeight + Tokens.padding.normal * 1.5 radius: 0 gradient: Gradient { @@ -154,16 +154,16 @@ GridView { opacity: 0 + Component.onCompleted: { + opacity = 1; + } + Behavior on opacity { NumberAnimation { duration: 1000 easing.type: Easing.OutCubic } } - - Component.onCompleted: { - opacity = 1; - } } } @@ -190,26 +190,27 @@ GridView { MaterialIcon { anchors.right: parent.right anchors.top: parent.top - anchors.margins: Appearance.padding.small + anchors.margins: Tokens.padding.small visible: isCurrent text: "check_circle" color: Colours.palette.m3primary - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.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 + anchors.leftMargin: Tokens.padding.normal + Tokens.spacing.normal / 2 + anchors.rightMargin: Tokens.padding.normal + Tokens.spacing.normal / 2 + anchors.bottomMargin: Tokens.padding.normal text: modelData.name - font.pointSize: Appearance.font.size.smaller + font.pointSize: Tokens.font.size.smaller font.weight: 500 color: isCurrent ? Colours.palette.m3primary : Colours.palette.m3onSurface elide: Text.ElideMiddle @@ -218,16 +219,16 @@ GridView { opacity: 0 + Component.onCompleted: { + opacity = 1; + } + Behavior on opacity { NumberAnimation { duration: 1000 easing.type: Easing.OutCubic } } - - Component.onCompleted: { - opacity = 1; - } } } } diff --git a/modules/controlcenter/dashboard/DashboardPane.qml b/modules/controlcenter/dashboard/DashboardPane.qml new file mode 100644 index 000000000..a2be84821 --- /dev/null +++ b/modules/controlcenter/dashboard/DashboardPane.qml @@ -0,0 +1,305 @@ +pragma ComponentBehavior: Bound + +import ".." +import "../components" +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Caelestia.Config +import qs.components +import qs.components.containers +import qs.components.controls +import qs.components.effects +import qs.services +import qs.utils + +Item { + id: root + + required property Session session + property string activeSection: "general" + + // General Settings + property bool enabled: Config.dashboard.enabled ?? true + property bool showOnHover: Config.dashboard.showOnHover ?? true + property int mediaUpdateInterval: GlobalConfig.dashboard.mediaUpdateInterval ?? 1000 + property int resourceUpdateInterval: GlobalConfig.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 + + readonly property var sections: [ + { + id: "general", + title: qsTr("General Settings"), + description: qsTr("Tabs and behavior"), + icon: "settings" + }, + { + id: "performance", + title: qsTr("Performance Resources"), + description: qsTr("Resource monitoring"), + icon: "monitoring" + } + ] + + function componentForSection(sectionId) { + switch (sectionId) { + case "performance": + return performanceComponent; + case "general": + default: + return generalComponent; + } + } + + function saveConfig() { + GlobalConfig.dashboard.enabled = root.enabled; + GlobalConfig.dashboard.showOnHover = root.showOnHover; + GlobalConfig.dashboard.mediaUpdateInterval = root.mediaUpdateInterval; + GlobalConfig.dashboard.resourceUpdateInterval = root.resourceUpdateInterval; + GlobalConfig.dashboard.dragThreshold = root.dragThreshold; + GlobalConfig.dashboard.showDashboard = root.showDashboard; + GlobalConfig.dashboard.showMedia = root.showMedia; + GlobalConfig.dashboard.showPerformance = root.showPerformance; + GlobalConfig.dashboard.showWeather = root.showWeather; + GlobalConfig.dashboard.performance.showBattery = root.showBattery; + GlobalConfig.dashboard.performance.showGpu = root.showGpu; + GlobalConfig.dashboard.performance.showCpu = root.showCpu; + GlobalConfig.dashboard.performance.showMemory = root.showMemory; + GlobalConfig.dashboard.performance.showStorage = root.showStorage; + GlobalConfig.dashboard.performance.showNetwork = root.showNetwork; + } + + anchors.fill: parent + + SplitPaneLayout { + anchors.fill: parent + leftWidthRatio: 0.32 + leftMinimumWidth: 300 + + leftContent: Component { + StyledFlickable { + id: leftFlickable + + flickableDirection: Flickable.VerticalFlick + contentHeight: leftContentLayout.height + + StyledScrollBar.vertical: StyledScrollBar { + flickable: leftFlickable + } + + ColumnLayout { + id: leftContentLayout + + anchors.left: parent.left + anchors.right: parent.right + spacing: Tokens.spacing.normal + + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.smaller + + StyledText { + text: qsTr("Dashboard") + font.pointSize: Tokens.font.size.large + font.weight: 500 + } + + Item { + Layout.fillWidth: true + } + } + + Repeater { + model: root.sections + + delegate: SectionNavButton { + required property var modelData + + Layout.fillWidth: true + section: modelData + active: root.activeSection === modelData.id + onClicked: root.activeSection = modelData.id + } + } + } + } + } + + rightContent: Component { + Item { + id: rightPaneItem + + property string paneId: root.activeSection + property Component targetComponent: root.componentForSection(root.activeSection) + property Component nextComponent: root.componentForSection(root.activeSection) + + onPaneIdChanged: { + nextComponent = root.componentForSection(root.activeSection); + } + + Loader { + id: rightLoader + + anchors.fill: parent + asynchronous: true + opacity: 1 + scale: 1 + transformOrigin: Item.Center + sourceComponent: rightPaneItem.targetComponent + } + + Behavior on paneId { + PaneTransition { + target: rightLoader + propertyActions: [ + PropertyAction { + target: rightPaneItem + property: "targetComponent" + value: rightPaneItem.nextComponent + } + ] + } + } + } + } + } + + Component { + id: generalComponent + + StyledFlickable { + id: generalFlickable + + flickableDirection: Flickable.VerticalFlick + contentHeight: generalLayout.height + + StyledScrollBar.vertical: StyledScrollBar { + flickable: generalFlickable + } + + ColumnLayout { + id: generalLayout + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + spacing: Tokens.spacing.normal + + GeneralSection { + rootItem: root + } + } + } + } + + Component { + id: performanceComponent + + StyledFlickable { + id: performanceFlickable + + flickableDirection: Flickable.VerticalFlick + contentHeight: performanceLayout.height + + StyledScrollBar.vertical: StyledScrollBar { + flickable: performanceFlickable + } + + ColumnLayout { + id: performanceLayout + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + spacing: Tokens.spacing.normal + + PerformanceSection { + rootItem: root + } + } + } + } + + component SectionNavButton: StyledRect { + id: navButton + + required property var section + property bool active: false + + signal clicked + + implicitHeight: navRow.implicitHeight + Tokens.padding.normal * 2 + color: active ? Colours.layer(Colours.palette.m3surfaceContainer, 2) : "transparent" + radius: Tokens.rounding.normal + + Behavior on color { + CAnim {} + } + + StateLayer { + onClicked: navButton.clicked() + } + + RowLayout { + id: navRow + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Tokens.padding.normal + spacing: Tokens.spacing.normal + + MaterialIcon { + Layout.alignment: Qt.AlignVCenter + text: navButton.section.icon + fill: navButton.active ? 1 : 0 + color: navButton.active ? Colours.palette.m3primary : Colours.palette.m3onSurfaceVariant + font.pointSize: Tokens.font.size.large + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + StyledText { + Layout.fillWidth: true + text: navButton.section.title + font.weight: navButton.active ? 500 : 400 + elide: Text.ElideRight + maximumLineCount: 1 + } + + StyledText { + Layout.fillWidth: true + text: navButton.section.description + color: Colours.palette.m3outline + font.pointSize: Tokens.font.size.small + elide: Text.ElideRight + maximumLineCount: 1 + } + } + + MaterialIcon { + Layout.alignment: Qt.AlignVCenter + text: "chevron_right" + opacity: navButton.active ? 1 : 0 + color: Colours.palette.m3primary + } + } + } +} diff --git a/modules/controlcenter/dashboard/GeneralSection.qml b/modules/controlcenter/dashboard/GeneralSection.qml new file mode 100644 index 000000000..be67eab27 --- /dev/null +++ b/modules/controlcenter/dashboard/GeneralSection.qml @@ -0,0 +1,128 @@ +import ".." +import "../components" +import QtQuick +import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.components.controls +import qs.services + +SectionContainer { + id: root + + required property var rootItem + + Layout.fillWidth: true + alignTop: true + + StyledText { + text: qsTr("General Settings") + font.pointSize: Tokens.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: Tokens.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(); + } + } +} diff --git a/modules/controlcenter/dashboard/PerformanceSection.qml b/modules/controlcenter/dashboard/PerformanceSection.qml new file mode 100644 index 000000000..ea6c68b3e --- /dev/null +++ b/modules/controlcenter/dashboard/PerformanceSection.qml @@ -0,0 +1,106 @@ +import ".." +import "../components" +import QtQuick +import QtQuick.Layouts +import Quickshell.Services.UPower +import Caelestia.Config +import qs.components +import qs.components.controls +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: Tokens.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(); + } + } +} diff --git a/modules/controlcenter/launcher/LauncherPane.qml b/modules/controlcenter/launcher/LauncherPane.qml index 0dd464f16..f2372dbe1 100644 --- a/modules/controlcenter/launcher/LauncherPane.qml +++ b/modules/controlcenter/launcher/LauncherPane.qml @@ -3,19 +3,19 @@ pragma ComponentBehavior: Bound import ".." import "../components" import "../../launcher/services" +import "../../../utils/scripts/fuzzysort.js" as Fuzzy +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Caelestia +import Caelestia.Config import qs.components +import qs.components.containers import qs.components.controls import qs.components.effects -import qs.components.containers import qs.services -import qs.config import qs.utils -import Caelestia -import Quickshell -import Quickshell.Widgets -import QtQuick -import QtQuick.Layouts -import "../../../utils/scripts/fuzzysort.js" as Fuzzy Item { id: root @@ -24,35 +24,21 @@ Item { property var selectedApp: root.session.launcher.active property bool hideFromLauncherChecked: false - - anchors.fill: parent - - onSelectedAppChanged: { - root.session.launcher.active = root.selectedApp; - updateToggleState(); - } - - Connections { - target: root.session.launcher - function onActiveChanged() { - root.selectedApp = root.session.launcher.active; - updateToggleState(); - } - } + 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; - if (Config.launcher.hiddenApps && Config.launcher.hiddenApps.length > 0) { - root.hideFromLauncherChecked = Config.launcher.hiddenApps.includes(appId); - } else { - root.hideFromLauncherChecked = false; - } + root.hideFromLauncherChecked = GlobalConfig.launcher.hiddenApps && GlobalConfig.launcher.hiddenApps.length > 0 && Strings.testRegexList(GlobalConfig.launcher.hiddenApps, appId); + root.favouriteChecked = GlobalConfig.launcher.favouriteApps && GlobalConfig.launcher.favouriteApps.length > 0 && Strings.testRegexList(GlobalConfig.launcher.favouriteApps, appId); } function saveHiddenApps(isHidden) { @@ -62,7 +48,7 @@ Item { const appId = root.selectedApp.id || root.selectedApp.entry?.id; - const hiddenApps = Config.launcher.hiddenApps ? [...Config.launcher.hiddenApps] : []; + const hiddenApps = GlobalConfig.launcher.hiddenApps ? [...GlobalConfig.launcher.hiddenApps] : []; if (isHidden) { if (!hiddenApps.includes(appId)) { @@ -75,19 +61,9 @@ Item { } } - Config.launcher.hiddenApps = hiddenApps; - Config.save(); - } - - AppDb { - id: allAppsDb - - path: `${Paths.state}/apps.sqlite` - entries: DesktopEntries.applications.values + GlobalConfig.launcher.hiddenApps = hiddenApps; } - property string searchText: "" - function filterApps(search: string): list { if (!search || search.trim() === "") { const apps = []; @@ -120,12 +96,17 @@ Item { return results.sort((a, b) => b._score - a._score).map(r => r.obj._item); } - property list filteredApps: [] - function updateFilteredApps() { filteredApps = filterApps(searchText); } + anchors.fill: parent + + onSelectedAppChanged: { + root.session.launcher.active = root.selectedApp; + updateToggleState(); + } + onSearchTextChanged: { updateFilteredApps(); } @@ -135,29 +116,47 @@ Item { } Connections { - target: allAppsDb + function onActiveChanged() { + root.selectedApp = root.session.launcher.active; + updateToggleState(); + } + + target: root.session.launcher + } + + AppDb { + id: allAppsDb + + path: `${Paths.state}/apps.sqlite` + favouriteApps: GlobalConfig.launcher.favouriteApps + entries: DesktopEntries.applications.values + } + + Connections { function onAppsChanged() { updateFilteredApps(); } + + target: allAppsDb } SplitPaneLayout { anchors.fill: parent leftContent: Component { - ColumnLayout { id: leftLauncherLayout + anchors.fill: parent - spacing: Appearance.spacing.small + spacing: Tokens.spacing.small RowLayout { - spacing: Appearance.spacing.smaller + spacing: Tokens.spacing.smaller StyledText { text: qsTr("Launcher") - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large font.weight: 500 } @@ -169,9 +168,9 @@ Item { toggled: !root.session.launcher.active icon: "settings" accent: "Primary" - iconSize: Appearance.font.size.normal - horizontalPadding: Appearance.padding.normal - verticalPadding: Appearance.padding.smaller + iconSize: Tokens.font.size.normal + horizontalPadding: Tokens.padding.normal + verticalPadding: Tokens.padding.smaller tooltip: qsTr("Launcher settings") onClicked: { @@ -187,9 +186,9 @@ Item { } StyledText { - Layout.topMargin: Appearance.spacing.large + Layout.topMargin: Tokens.spacing.large text: qsTr("Applications (%1)").arg(root.searchText ? root.filteredApps.length : allAppsDb.apps.length) - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.font.size.normal font.weight: 500 } @@ -200,11 +199,11 @@ Item { StyledRect { Layout.fillWidth: true - Layout.topMargin: Appearance.spacing.normal - Layout.bottomMargin: Appearance.spacing.small + Layout.topMargin: Tokens.spacing.normal + Layout.bottomMargin: Tokens.spacing.small color: Colours.layer(Colours.palette.m3surfaceContainer, 2) - radius: Appearance.rounding.full + radius: Tokens.rounding.full implicitHeight: Math.max(searchIcon.implicitHeight, searchField.implicitHeight, clearIcon.implicitHeight) @@ -213,7 +212,7 @@ Item { anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left - anchors.leftMargin: Appearance.padding.normal + anchors.leftMargin: Tokens.padding.normal text: "search" color: Colours.palette.m3onSurfaceVariant @@ -224,11 +223,11 @@ Item { anchors.left: searchIcon.right anchors.right: clearIcon.left - anchors.leftMargin: Appearance.spacing.small - anchors.rightMargin: Appearance.spacing.small + anchors.leftMargin: Tokens.spacing.small + anchors.rightMargin: Tokens.spacing.small - topPadding: Appearance.padding.normal - bottomPadding: Appearance.padding.normal + topPadding: Tokens.padding.normal + bottomPadding: Tokens.padding.normal placeholderText: qsTr("Search applications...") @@ -242,7 +241,7 @@ Item { anchors.verticalCenter: parent.verticalCenter anchors.right: parent.right - anchors.rightMargin: Appearance.padding.normal + anchors.rightMargin: Tokens.padding.normal width: searchField.text ? implicitWidth : implicitWidth / 2 opacity: { @@ -270,13 +269,13 @@ Item { Behavior on width { Anim { - duration: Appearance.anim.durations.small + type: Anim.StandardSmall } } Behavior on opacity { Anim { - duration: Appearance.anim.durations.small + type: Anim.StandardSmall } } } @@ -284,8 +283,10 @@ Item { Loader { id: appsListLoader + Layout.fillWidth: true Layout.fillHeight: true + asynchronous: true active: true sourceComponent: StyledListView { @@ -295,7 +296,7 @@ Item { Layout.fillHeight: true model: root.filteredApps - spacing: Appearance.spacing.small / 2 + spacing: Tokens.spacing.small / 2 clip: true StyledScrollBar.vertical: StyledScrollBar { @@ -305,15 +306,20 @@ Item { delegate: StyledRect { required property var modelData - width: parent ? parent.width : 0 - 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 + radius: Tokens.rounding.normal opacity: 0 + Component.onCompleted: { + opacity = 1; + } + Behavior on opacity { NumberAnimation { duration: 1000 @@ -321,12 +327,8 @@ Item { } } - Component.onCompleted: { - opacity = 1; - } - StateLayer { - function onClicked(): void { + onClicked: { root.session.launcher.active = modelData; } } @@ -335,11 +337,12 @@ Item { anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - anchors.margins: Appearance.padding.normal + anchors.margins: Tokens.padding.normal - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal IconImage { + asynchronous: true Layout.alignment: Qt.AlignVCenter implicitSize: 32 source: { @@ -351,11 +354,40 @@ Item { StyledText { Layout.fillWidth: true text: modelData.name || modelData.entry?.name || qsTr("Unknown") - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.font.size.normal } - } - implicitHeight: 40 + Loader { + readonly property bool isHidden: modelData ? Strings.testRegexList(GlobalConfig.launcher.hiddenApps, modelData.id) : false + readonly property bool isFav: modelData ? Strings.testRegexList(GlobalConfig.launcher.favouriteApps, 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 + } + } + } } } } @@ -382,11 +414,30 @@ Item { nextComponent = targetComponent; } + onPaneChanged: { + nextComponent = getComponentForPane(); + paneId = pane ? (pane.id || pane.entry?.id || "") : ""; + } + + onDisplayedAppChanged: { + if (displayedApp) { + const appId = displayedApp.id || displayedApp.entry?.id; + root.hideFromLauncherChecked = GlobalConfig.launcher.hiddenApps && GlobalConfig.launcher.hiddenApps.length > 0 && Strings.testRegexList(GlobalConfig.launcher.hiddenApps, appId); + root.favouriteChecked = GlobalConfig.launcher.favouriteApps && GlobalConfig.launcher.favouriteApps.length > 0 && Strings.testRegexList(GlobalConfig.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 @@ -395,8 +446,6 @@ Item { sourceComponent: rightLauncherPane.targetComponent active: true - property var displayedApp: rightLauncherPane.displayedApp - onItemChanged: { if (item && rightLauncherPane.pane && rightLauncherPane.displayedApp !== rightLauncherPane.pane) { rightLauncherPane.displayedApp = rightLauncherPane.pane; @@ -431,24 +480,6 @@ Item { ] } } - - onPaneChanged: { - nextComponent = getComponentForPane(); - paneId = pane ? (pane.id || pane.entry?.id || "") : ""; - } - - onDisplayedAppChanged: { - if (displayedApp) { - const appId = displayedApp.id || displayedApp.entry?.id; - if (Config.launcher.hiddenApps && Config.launcher.hiddenApps.length > 0) { - root.hideFromLauncherChecked = Config.launcher.hiddenApps.includes(appId); - } else { - root.hideFromLauncherChecked = false; - } - } else { - root.hideFromLauncherChecked = false; - } - } } } } @@ -458,6 +489,7 @@ Item { StyledFlickable { id: settingsFlickable + flickableDirection: Flickable.VerticalFlick contentHeight: settingsInner.height @@ -481,16 +513,16 @@ Item { ColumnLayout { id: appDetailsLayout - anchors.fill: parent readonly property var displayedApp: parent && parent.displayedApp !== undefined ? parent.displayedApp : null - spacing: Appearance.spacing.normal + anchors.fill: parent + spacing: Tokens.spacing.normal SettingsHeader { - Layout.leftMargin: Appearance.padding.large * 2 - Layout.rightMargin: Appearance.padding.large * 2 - Layout.topMargin: Appearance.padding.large * 2 + Layout.leftMargin: Tokens.padding.large * 2 + Layout.rightMargin: Tokens.padding.large * 2 + Layout.topMargin: Tokens.padding.large * 2 visible: displayedApp === null icon: "apps" title: qsTr("Launcher Applications") @@ -498,21 +530,23 @@ Item { Item { Layout.alignment: Qt.AlignHCenter - Layout.leftMargin: Appearance.padding.large * 2 - Layout.rightMargin: Appearance.padding.large * 2 - Layout.topMargin: Appearance.padding.large * 2 + Layout.leftMargin: Tokens.padding.large * 2 + Layout.rightMargin: Tokens.padding.large * 2 + Layout.topMargin: Tokens.padding.large * 2 visible: displayedApp !== null implicitWidth: Math.max(appIconImage.implicitWidth, appTitleText.implicitWidth) - implicitHeight: appIconImage.implicitHeight + Appearance.spacing.normal + appTitleText.implicitHeight + implicitHeight: appIconImage.implicitHeight + Tokens.spacing.normal + appTitleText.implicitHeight ColumnLayout { anchors.centerIn: parent - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal IconImage { id: appIconImage + + asynchronous: true Layout.alignment: Qt.AlignHCenter - implicitSize: Appearance.font.size.extraLarge * 3 * 2 + implicitSize: Tokens.font.size.extraLarge * 3 * 2 source: { const app = appDetailsLayout.displayedApp; if (!app) @@ -527,9 +561,10 @@ Item { StyledText { id: appTitleText + Layout.alignment: Qt.AlignHCenter text: displayedApp ? (displayedApp.name || displayedApp.entry?.name || qsTr("Application Details")) : "" - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large font.bold: true } } @@ -538,12 +573,13 @@ Item { Item { Layout.fillWidth: true Layout.fillHeight: true - Layout.topMargin: Appearance.spacing.large - Layout.leftMargin: Appearance.padding.large * 2 - Layout.rightMargin: Appearance.padding.large * 2 + Layout.topMargin: Tokens.spacing.large + Layout.leftMargin: Tokens.padding.large * 2 + Layout.rightMargin: Tokens.padding.large * 2 StyledFlickable { id: detailsFlickable + anchors.fill: parent flickableDirection: Flickable.VerticalFlick contentHeight: debugLayout.height @@ -554,23 +590,62 @@ Item { ColumnLayout { id: debugLayout + anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal SwitchRow { - Layout.topMargin: Appearance.spacing.normal + Layout.topMargin: Tokens.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 && (GlobalConfig.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 = GlobalConfig.launcher.favouriteApps ? [...GlobalConfig.launcher.favouriteApps] : []; + if (checked) { + if (!favouriteApps.includes(appId)) { + favouriteApps.push(appId); + } + } else { + const index = favouriteApps.indexOf(appId); + if (index !== -1) { + favouriteApps.splice(index, 1); + } + } + GlobalConfig.launcher.favouriteApps = favouriteApps; + } + } + } + SwitchRow { + Layout.topMargin: Tokens.spacing.normal visible: appDetailsLayout.displayedApp !== null label: qsTr("Hide from launcher") checked: root.hideFromLauncherChecked - enabled: appDetailsLayout.displayedApp !== null + // 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 && (GlobalConfig.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] : []; + const hiddenApps = GlobalConfig.launcher.hiddenApps ? [...GlobalConfig.launcher.hiddenApps] : []; if (checked) { if (!hiddenApps.includes(appId)) { hiddenApps.push(appId); @@ -581,8 +656,7 @@ Item { hiddenApps.splice(index, 1); } } - Config.launcher.hiddenApps = hiddenApps; - Config.save(); + GlobalConfig.launcher.hiddenApps = hiddenApps; } } } diff --git a/modules/controlcenter/launcher/Settings.qml b/modules/controlcenter/launcher/Settings.qml index 5eaf6e0e0..f01812308 100644 --- a/modules/controlcenter/launcher/Settings.qml +++ b/modules/controlcenter/launcher/Settings.qml @@ -2,20 +2,20 @@ pragma ComponentBehavior: Bound import ".." import "../components" +import QtQuick +import QtQuick.Layouts +import Caelestia.Config import qs.components import qs.components.controls import qs.components.effects import qs.services -import qs.config -import QtQuick -import QtQuick.Layouts ColumnLayout { id: root required property Session session - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal SettingsHeader { icon: "apps" @@ -23,7 +23,7 @@ ColumnLayout { } SectionHeader { - Layout.topMargin: Appearance.spacing.large + Layout.topMargin: Tokens.spacing.large title: qsTr("General") description: qsTr("General launcher settings") } @@ -33,8 +33,7 @@ ColumnLayout { label: qsTr("Enabled") checked: Config.launcher.enabled toggle.onToggled: { - Config.launcher.enabled = checked; - Config.save(); + GlobalConfig.launcher.enabled = checked; } } @@ -42,38 +41,35 @@ ColumnLayout { label: qsTr("Show on hover") checked: Config.launcher.showOnHover toggle.onToggled: { - Config.launcher.showOnHover = checked; - Config.save(); + GlobalConfig.launcher.showOnHover = checked; } } ToggleRow { label: qsTr("Vim keybinds") - checked: Config.launcher.vimKeybinds + checked: GlobalConfig.launcher.vimKeybinds toggle.onToggled: { - Config.launcher.vimKeybinds = checked; - Config.save(); + GlobalConfig.launcher.vimKeybinds = checked; } } ToggleRow { label: qsTr("Enable dangerous actions") - checked: Config.launcher.enableDangerousActions + checked: GlobalConfig.launcher.enableDangerousActions toggle.onToggled: { - Config.launcher.enableDangerousActions = checked; - Config.save(); + GlobalConfig.launcher.enableDangerousActions = checked; } } } SectionHeader { - Layout.topMargin: Appearance.spacing.large + Layout.topMargin: Tokens.spacing.large title: qsTr("Display") description: qsTr("Display and appearance settings") } SectionContainer { - contentSpacing: Appearance.spacing.small / 2 + contentSpacing: Tokens.spacing.small / 2 PropertyRow { label: qsTr("Max shown items") @@ -94,28 +90,28 @@ ColumnLayout { } SectionHeader { - Layout.topMargin: Appearance.spacing.large + Layout.topMargin: Tokens.spacing.large title: qsTr("Prefixes") description: qsTr("Command prefix settings") } SectionContainer { - contentSpacing: Appearance.spacing.small / 2 + contentSpacing: Tokens.spacing.small / 2 PropertyRow { label: qsTr("Special prefix") - value: Config.launcher.specialPrefix || qsTr("None") + value: GlobalConfig.launcher.specialPrefix || qsTr("None") } PropertyRow { showTopMargin: true label: qsTr("Action prefix") - value: Config.launcher.actionPrefix || qsTr("None") + value: GlobalConfig.launcher.actionPrefix || qsTr("None") } } SectionHeader { - Layout.topMargin: Appearance.spacing.large + Layout.topMargin: Tokens.spacing.large title: qsTr("Fuzzy search") description: qsTr("Fuzzy search settings") } @@ -123,95 +119,90 @@ ColumnLayout { SectionContainer { ToggleRow { label: qsTr("Apps") - checked: Config.launcher.useFuzzy.apps + checked: GlobalConfig.launcher.useFuzzy.apps toggle.onToggled: { - Config.launcher.useFuzzy.apps = checked; - Config.save(); + GlobalConfig.launcher.useFuzzy.apps = checked; } } ToggleRow { label: qsTr("Actions") - checked: Config.launcher.useFuzzy.actions + checked: GlobalConfig.launcher.useFuzzy.actions toggle.onToggled: { - Config.launcher.useFuzzy.actions = checked; - Config.save(); + GlobalConfig.launcher.useFuzzy.actions = checked; } } ToggleRow { label: qsTr("Schemes") - checked: Config.launcher.useFuzzy.schemes + checked: GlobalConfig.launcher.useFuzzy.schemes toggle.onToggled: { - Config.launcher.useFuzzy.schemes = checked; - Config.save(); + GlobalConfig.launcher.useFuzzy.schemes = checked; } } ToggleRow { label: qsTr("Variants") - checked: Config.launcher.useFuzzy.variants + checked: GlobalConfig.launcher.useFuzzy.variants toggle.onToggled: { - Config.launcher.useFuzzy.variants = checked; - Config.save(); + GlobalConfig.launcher.useFuzzy.variants = checked; } } ToggleRow { label: qsTr("Wallpapers") - checked: Config.launcher.useFuzzy.wallpapers + checked: GlobalConfig.launcher.useFuzzy.wallpapers toggle.onToggled: { - Config.launcher.useFuzzy.wallpapers = checked; - Config.save(); + GlobalConfig.launcher.useFuzzy.wallpapers = checked; } } } SectionHeader { - Layout.topMargin: Appearance.spacing.large + Layout.topMargin: Tokens.spacing.large title: qsTr("Sizes") description: qsTr("Size settings for launcher items") } SectionContainer { - contentSpacing: Appearance.spacing.small / 2 + contentSpacing: Tokens.spacing.small / 2 PropertyRow { label: qsTr("Item width") - value: qsTr("%1 px").arg(Config.launcher.sizes.itemWidth) + value: qsTr("%1 px").arg(Tokens.sizes.launcher.itemWidth) } PropertyRow { showTopMargin: true label: qsTr("Item height") - value: qsTr("%1 px").arg(Config.launcher.sizes.itemHeight) + value: qsTr("%1 px").arg(Tokens.sizes.launcher.itemHeight) } PropertyRow { showTopMargin: true label: qsTr("Wallpaper width") - value: qsTr("%1 px").arg(Config.launcher.sizes.wallpaperWidth) + value: qsTr("%1 px").arg(Tokens.sizes.launcher.wallpaperWidth) } PropertyRow { showTopMargin: true label: qsTr("Wallpaper height") - value: qsTr("%1 px").arg(Config.launcher.sizes.wallpaperHeight) + value: qsTr("%1 px").arg(Tokens.sizes.launcher.wallpaperHeight) } } SectionHeader { - Layout.topMargin: Appearance.spacing.large + Layout.topMargin: Tokens.spacing.large title: qsTr("Hidden apps") description: qsTr("Applications hidden from launcher") } SectionContainer { - contentSpacing: Appearance.spacing.small / 2 + contentSpacing: Tokens.spacing.small / 2 PropertyRow { label: qsTr("Total hidden") - value: qsTr("%1").arg(Config.launcher.hiddenApps ? Config.launcher.hiddenApps.length : 0) + value: qsTr("%1").arg(GlobalConfig.launcher.hiddenApps ? GlobalConfig.launcher.hiddenApps.length : 0) } } } diff --git a/modules/controlcenter/monitors/MonitorsPane.qml b/modules/controlcenter/monitors/MonitorsPane.qml new file mode 100644 index 000000000..82809fd9f --- /dev/null +++ b/modules/controlcenter/monitors/MonitorsPane.qml @@ -0,0 +1,973 @@ +pragma ComponentBehavior: Bound + +import ".." +import "../components" +import "." +import qs.components +import qs.components.controls +import qs.components.effects +import qs.components.containers +import qs.services +import Caelestia.Config +import Quickshell +import Quickshell.Hyprland +import QtQuick +import QtQuick.Layouts + +Item { + id: root + + required property Session session + readonly property var monitorModel: Hyprctl.monitors + + function selectMonitor(monitor: var): void { + if (!monitor) + return; + root.session.monitor.active = { + id: monitor.id, + name: monitor.name + }; + } + + function selectedMonitor(): var { + const active = root.session.monitor.active; + if (!active) + return null; + + for (const monitor of root.monitorModel) { + if (monitor.name === active.name || monitor.id === active.id) + return monitor; + } + + return null; + } + + function ensureSingleMonitorSelected(): void { + if (!root.session.monitor.active && (root.monitorModel?.length ?? 0) === 1) + root.selectMonitor(root.monitorModel[0]); + } + + anchors.fill: parent + + Component.onCompleted: Qt.callLater(root.ensureSingleMonitorSelected) + + Connections { + target: Hyprctl + + function onMonitorsChanged(): void { + root.ensureSingleMonitorSelected(); + } + } + + // ── Two-column split (mirrors NetworkingPane) ────────────────────── + SplitPaneLayout { + id: splitLayout + + anchors.fill: parent + + // ── LEFT: monitor list ───────────────────────────────────────── + leftContent: Component { + StyledFlickable { + id: leftFlickable + + flickableDirection: Flickable.VerticalFlick + contentHeight: leftContent.implicitHeight + + StyledScrollBar.vertical: StyledScrollBar { + flickable: leftFlickable + } + + ColumnLayout { + id: leftContent + + anchors.left: parent.left + anchors.right: parent.right + spacing: Tokens.spacing.normal + + // Header row + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.smaller + + StyledText { + text: qsTr("Monitors") + font.pointSize: Tokens.font.size.large + font.weight: 500 + } + + Item { Layout.fillWidth: true } + + // Identify toggle — only button in the header + ToggleButton { + toggled: Monitors.identifying + icon: "tv_signin" + accent: "Secondary" + iconSize: Tokens.font.size.normal + horizontalPadding: Tokens.padding.normal + verticalPadding: Tokens.padding.smaller + tooltip: qsTr("Identify monitors") + + onClicked: Monitors.toggleIdentification() + } + } + + // Subtitle + StyledText { + Layout.fillWidth: true + text: qsTr("%1 display(s) connected").arg(root.monitorModel.length) + color: Colours.palette.m3outline + font.pointSize: Tokens.font.size.small + } + + // Monitor list — use hyprctl data so refresh rate and modes are available + Repeater { + model: root.monitorModel + + delegate: MonitorListItem { + required property var modelData + required property int index + + Layout.fillWidth: true + + monitor: modelData + active: root.session.monitor.active !== null + && root.session.monitor.active !== undefined + && (root.session.monitor.active.id === modelData.id + || root.session.monitor.active.name === modelData.name) + + onClicked: root.selectMonitor(modelData) + } + } + } + } + } + + // ── RIGHT: detail / overview ─────────────────────────────────── + rightContent: Component { + Item { + id: rightPaneItem + + property var selectedMonitor: root.selectedMonitor() + property string paneId: selectedMonitor + ? ("mon:" + (selectedMonitor.name ?? "")) + : "overview" + property Component targetComponent: overviewComponent + property Component nextComponent: overviewComponent + + function resolveComponent(): Component { + return selectedMonitor ? monitorDetailComponent : overviewComponent; + } + + Component.onCompleted: { + targetComponent = resolveComponent(); + nextComponent = targetComponent; + } + + Connections { + target: root.session.monitor + function onActiveChanged(): void { + rightPaneItem.selectedMonitor = root.selectedMonitor(); + rightPaneItem.nextComponent = rightPaneItem.resolveComponent(); + } + } + + Connections { + target: Hyprctl + function onMonitorsChanged(): void { + rightPaneItem.selectedMonitor = root.selectedMonitor(); + rightPaneItem.nextComponent = rightPaneItem.resolveComponent(); + } + } + + Loader { + id: rightLoader + anchors.fill: parent + opacity: 1 + scale: 1 + transformOrigin: Item.Center + asynchronous: true + sourceComponent: rightPaneItem.targetComponent + } + + Behavior on paneId { + PaneTransition { + target: rightLoader + propertyActions: [ + PropertyAction { + target: rightPaneItem + property: "targetComponent" + value: rightPaneItem.nextComponent + } + ] + } + } + } + } + } + + // ── List item component ─────────────────────────────────────────── + component MonitorListItem: Item { + id: listItem + + required property var monitor + required property bool active + + signal clicked() + + implicitHeight: itemRow.implicitHeight + Tokens.padding.normal * 2 + + StyledRect { + anchors.fill: parent + radius: Tokens.rounding.normal + color: Qt.alpha( + Colours.tPalette.m3surfaceContainer, + listItem.active ? Colours.tPalette.m3surfaceContainer.a : 0 + ) + + StateLayer { + function onClicked(): void { listItem.clicked(); } + } + + RowLayout { + id: itemRow + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Tokens.padding.normal + spacing: Tokens.spacing.normal + + // Monitor icon badge + StyledRect { + implicitWidth: implicitHeight + implicitHeight: monIcon.implicitHeight + Tokens.padding.normal * 2 + radius: Tokens.rounding.normal + color: listItem.active + ? Colours.palette.m3primaryContainer + : Colours.tPalette.m3surfaceContainerHigh + + MaterialIcon { + id: monIcon + anchors.centerIn: parent + text: "monitor" + font.pointSize: Tokens.font.size.large + fill: listItem.active ? 1 : 0 + color: listItem.active + ? Colours.palette.m3onPrimaryContainer + : Colours.palette.m3onSurface + } + } + + // Name + resolution + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + StyledText { + Layout.fillWidth: true + elide: Text.ElideRight + maximumLineCount: 1 + text: listItem.monitor?.name ?? "" + font.weight: listItem.active ? 600 : 400 + } + + StyledText { + Layout.fillWidth: true + elide: Text.ElideRight + font.pointSize: Tokens.font.size.small + color: Colours.palette.m3outline + text: { + const m = listItem.monitor; + if (!m || !m.width || !m.height) return qsTr("Unavailable"); + const rr = m.refreshRate ?? 0; + return qsTr("%1×%2 @ %3 Hz").arg(m.width).arg(m.height).arg(rr.toFixed(0)); + } + } + } + + // Focused badge + StyledRect { + visible: listItem.monitor?.focused ?? false + implicitWidth: focusedLabel.implicitWidth + Tokens.padding.normal * 2 + implicitHeight: focusedLabel.implicitHeight + Tokens.padding.small * 2 + radius: Tokens.rounding.full + color: Qt.alpha(Colours.palette.m3primaryContainer, 0.9) + + StyledText { + id: focusedLabel + anchors.centerIn: parent + text: qsTr("Active") + font.pointSize: Tokens.font.size.small + color: Colours.palette.m3onPrimaryContainer + } + } + + // Chevron + MaterialIcon { + text: "chevron_right" + color: Colours.palette.m3outline + opacity: listItem.active ? 1 : 0.4 + } + } + + Behavior on color { CAnim {} } + } + } + + // ── Overview component (no monitor selected) ────────────────────── + Component { + id: overviewComponent + + StyledFlickable { + id: overviewFlickable + flickableDirection: Flickable.VerticalFlick + contentHeight: overviewInner.implicitHeight + + StyledScrollBar.vertical: StyledScrollBar { + flickable: overviewFlickable + } + + ColumnLayout { + id: overviewInner + + anchors.left: parent.left + anchors.right: parent.right + spacing: Tokens.spacing.normal + + SettingsHeader { + icon: "monitor" + title: qsTr("Monitors") + } + + SectionHeader { + title: qsTr("Display layout") + description: qsTr("Summary of connected displays") + } + + SectionContainer { + contentSpacing: Tokens.spacing.small + + Repeater { + model: root.monitorModel + + delegate: PropertyRow { + required property var modelData + required property int index + + label: qsTr("Monitor %1 – %2") + .arg(modelData.id ?? index) + .arg(modelData.name ?? "") + value: { + const m = modelData; + if (!m || !m.width || !m.height) return qsTr("No data"); + return qsTr("%1×%2 @ %3 Hz · pos %4,%5 · ×%6 scale") + .arg(m.width).arg(m.height) + .arg((m.refreshRate ?? 0).toFixed(0)) + .arg(m.x ?? 0).arg(m.y ?? 0) + .arg((m.scale ?? 1).toFixed(2)); + } + showTopMargin: index > 0 + } + } + } + + SectionHeader { + title: qsTr("Quick controls") + description: qsTr("Select a monitor on the left to configure it") + } + + SectionContainer { + contentSpacing: Tokens.spacing.normal + + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.normal + + MaterialIcon { + text: "tv_signin" + font.pointSize: Tokens.font.size.large + color: Colours.palette.m3onSurfaceVariant + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + StyledText { + text: qsTr("Identify displays") + font.pointSize: Tokens.font.size.normal + } + StyledText { + text: qsTr("Show monitor IDs on each screen") + color: Colours.palette.m3outline + font.pointSize: Tokens.font.size.small + } + } + + ToggleButton { + toggled: Monitors.identifying + icon: Monitors.identifying ? "visibility_off" : "visibility" + accent: "Secondary" + onClicked: Monitors.toggleIdentification() + } + } + } + } + } + } + + // ── Per-monitor detail component ────────────────────────────────── + Component { + id: monitorDetailComponent + + StyledFlickable { + id: detailFlickable + + flickableDirection: Flickable.VerticalFlick + contentHeight: detailInner.implicitHeight + + StyledScrollBar.vertical: StyledScrollBar { + flickable: detailFlickable + } + + readonly property var mon: root.selectedMonitor() + readonly property var brightnessMon: mon ? Brightness.getMonitor(mon.name) : null + + ColumnLayout { + id: detailInner + + anchors.left: parent.left + anchors.right: parent.right + spacing: Tokens.spacing.normal + + // ── Header ────────────────────────────────────────── + ConnectionHeader { + icon: "monitor" + title: detailFlickable.mon?.name ?? qsTr("Monitor") + } + + // ── Brightness ─────────────────────────────────────── + ColumnLayout { + Layout.fillWidth: true + visible: detailFlickable.brightnessMon !== null + && detailFlickable.brightnessMon !== undefined + spacing: Tokens.spacing.normal + + SectionHeader { + title: qsTr("Brightness") + description: qsTr("Adjust display brightness") + } + + SectionContainer { + contentSpacing: Tokens.spacing.normal + + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.normal + + MaterialIcon { + text: (detailFlickable.brightnessMon?.brightness ?? 0) > 0.5 + ? "brightness_high" : "brightness_low" + font.pointSize: Tokens.font.size.normal + color: Colours.palette.m3onSurfaceVariant + } + + StyledSlider { + Layout.fillWidth: true + implicitHeight: Tokens.padding.normal * 3 + from: 0; to: 1; stepSize: 0.01 + value: detailFlickable.brightnessMon?.brightness ?? 0 + onMoved: detailFlickable.brightnessMon?.setBrightness(value) + } + + StyledText { + text: qsTr("%1%").arg( + Math.round((detailFlickable.brightnessMon?.brightness ?? 0) * 100)) + Layout.preferredWidth: 38 + font.pointSize: Tokens.font.size.small + color: Colours.palette.m3outline + } + } + } + } + + // ── Refresh rate ───────────────────────────────────── + ColumnLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.normal + + SectionHeader { + title: qsTr("Refresh rate") + description: qsTr("Set the display refresh rate") + } + + SectionContainer { + contentSpacing: Tokens.spacing.normal + + SpinBoxRow { + Layout.fillWidth: true + label: qsTr("Refresh rate") + value: detailFlickable.mon?.refreshRate ?? 60 + min: 10 + max: 1000 + step: 0.01 + onValueModified: value => { + if (detailFlickable.mon) + Monitors.setRefreshRate(detailFlickable.mon.name, value); + } + } + + RowLayout { + Layout.fillWidth: true + visible: (detailFlickable.mon?.availableModes?.length ?? 0) > 0 + spacing: Tokens.spacing.small + + Repeater { + model: detailFlickable.mon?.availableModes ?? [] + + delegate: StyledRect { + required property string modelData + required property int index + + Layout.fillWidth: true + implicitHeight: modeLabel.implicitHeight + Tokens.padding.normal * 2 + radius: Tokens.rounding.full + + readonly property real modeRate: { + const match = modelData.match(/@(\d+(?:\.\d+)?)Hz/); + return match ? parseFloat(match[1]) : 0; + } + readonly property bool isActive: Math.abs((detailFlickable.mon?.refreshRate ?? 0) - modeRate) < 0.1 + + color: isActive + ? Colours.palette.m3secondaryContainer + : Qt.alpha(Colours.palette.m3surfaceVariant, 0.5) + + StateLayer { + color: parent.isActive + ? Colours.palette.m3onSecondaryContainer + : Colours.palette.m3onSurfaceVariant + onClicked: { + if (detailFlickable.mon && parent.modeRate > 0) + Monitors.setRefreshRate(detailFlickable.mon.name, parent.modeRate); + } + } + + StyledText { + id: modeLabel + anchors.centerIn: parent + text: modelData + font.pointSize: Tokens.font.size.small + color: parent.isActive + ? Colours.palette.m3onSecondaryContainer + : Colours.palette.m3onSurfaceVariant + } + + Behavior on color { CAnim {} } + } + } + } + } + } + + // ── Rotation ───────────────────────────────────────── + ColumnLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.normal + + SectionHeader { + title: qsTr("Rotation") + description: qsTr("Rotate this display") + } + + SectionContainer { + contentSpacing: Tokens.spacing.small + + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.small + + Repeater { + model: [ + { label: qsTr("0°"), transform: 0, angle: 0 }, + { label: qsTr("90°"), transform: 1, angle: 90 }, + { label: qsTr("180°"), transform: 2, angle: 180 }, + { label: qsTr("270°"), transform: 3, angle: 270 } + ] + + delegate: RotationChip { + required property var modelData + required property int index + + Layout.fillWidth: true + chipLabel: modelData.label + chipAngle: modelData.angle + isActive: (detailFlickable.mon?.transform ?? 0) === modelData.transform + onClicked: { + if (detailFlickable.mon) + Monitors.rotate(detailFlickable.mon.name, modelData.angle); + } + } + } + } + } + } + + // ── Scale ──────────────────────────────────────────── + ColumnLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.normal + + SectionHeader { + title: qsTr("Scale") + description: qsTr("DPI scaling factor for this display") + } + + SectionContainer { + contentSpacing: Tokens.spacing.normal + + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.normal + + MaterialIcon { + text: "zoom_in" + font.pointSize: Tokens.font.size.normal + color: Colours.palette.m3onSurfaceVariant + } + + StyledSlider { + id: scaleSlider + Layout.fillWidth: true + implicitHeight: Tokens.padding.normal * 3 + from: 0.5; to: 3.0; stepSize: 0.25 + value: detailFlickable.mon?.scale ?? 1 + + onMoved: { + if (detailFlickable.mon) + Monitors.setScale(detailFlickable.mon.name, value); + } + } + + StyledText { + text: qsTr("×%1").arg((detailFlickable.mon?.scale ?? 1).toFixed(2)) + Layout.preferredWidth: 42 + font.pointSize: Tokens.font.size.small + color: Colours.palette.m3outline + } + } + + // Quick-pick chips: 1×, 1.25×, 1.5×, 2× + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.small + + Repeater { + model: [1.0, 1.25, 1.5, 2.0] + + delegate: StyledRect { + required property var modelData + required property int index + + Layout.fillWidth: true + implicitHeight: scaleChipLabel.implicitHeight + Tokens.padding.normal * 2 + radius: Tokens.rounding.full + + readonly property bool isActive: + Math.abs((detailFlickable.mon?.scale ?? 1) - modelData) < 0.01 + + color: isActive + ? Colours.palette.m3secondaryContainer + : Qt.alpha(Colours.palette.m3surfaceVariant, 0.5) + + StateLayer { + color: parent.isActive + ? Colours.palette.m3onSecondaryContainer + : Colours.palette.m3onSurfaceVariant + function onClicked(): void { + if (detailFlickable.mon) + Monitors.setScale(detailFlickable.mon.name, modelData); + } + } + + StyledText { + id: scaleChipLabel + anchors.centerIn: parent + text: qsTr("×%1").arg(modelData.toFixed(2)) + font.pointSize: Tokens.font.size.small + color: parent.isActive + ? Colours.palette.m3onSecondaryContainer + : Colours.palette.m3onSurfaceVariant + } + + Behavior on color { CAnim {} } + } + } + } + } + } + + // ── Arrangement ────────────────────────────────────── + ColumnLayout { + Layout.fillWidth: true + visible: root.monitorModel.length > 1 + spacing: Tokens.spacing.normal + + SectionHeader { + title: qsTr("Arrangement") + description: qsTr("Position this display relative to another") + } + + // One card per OTHER monitor — use visible to skip self + Repeater { + model: root.monitorModel + + delegate: SectionContainer { + id: targetSection + + required property var modelData + required property int index + + Layout.fillWidth: true + contentSpacing: Tokens.spacing.small + + // Hide the current monitor's own entry without JS filter + visible: detailFlickable.mon !== null + && detailFlickable.mon !== undefined + && modelData.id !== detailFlickable.mon.id + height: visible ? implicitHeight : 0 + + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.small + + MaterialIcon { + text: "tv" + font.pointSize: Tokens.font.size.normal + color: Colours.palette.m3onSurfaceVariant + } + + StyledText { + Layout.fillWidth: true + text: qsTr("Relative to Monitor %1 (%2)") + .arg(modelData.id ?? 0) + .arg(modelData.name ?? "") + font.pointSize: Tokens.font.size.normal + } + } + + GridLayout { + Layout.fillWidth: true + columns: 4 + columnSpacing: Tokens.spacing.small + rowSpacing: Tokens.spacing.small + + Repeater { + model: [ + { label: qsTr("Left"), pos: "left", icon: "arrow_back" }, + { label: qsTr("Right"), pos: "right", icon: "arrow_forward" }, + { label: qsTr("Above"), pos: "top", icon: "arrow_upward" }, + { label: qsTr("Below"), pos: "bottom", icon: "arrow_downward" } + ] + + delegate: ArrangeButton { + required property var modelData + required property int index + + Layout.fillWidth: true + btnIcon: modelData.icon + btnLabel: modelData.label + onClicked: { + if (detailFlickable.mon) + Monitors.arrange( + detailFlickable.mon.name, + modelData.pos, + targetSection.modelData.id + ); + } + } + } + } + } + } + } + + // ── Display information ─────────────────────────────── + ColumnLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.normal + + SectionHeader { + title: qsTr("Display information") + description: qsTr("Hardware and layout details") + } + + SectionContainer { + contentSpacing: Tokens.spacing.small / 2 + + PropertyRow { + label: qsTr("Name") + value: detailFlickable.mon?.name ?? qsTr("Unknown") + } + PropertyRow { + showTopMargin: true + label: qsTr("Monitor ID") + value: detailFlickable.mon != null ? String(detailFlickable.mon.id ?? "—") : "—" + } + PropertyRow { + showTopMargin: true + label: qsTr("Resolution") + value: detailFlickable.mon?.width && detailFlickable.mon?.height + ? qsTr("%1 × %2 px").arg(detailFlickable.mon.width).arg(detailFlickable.mon.height) + : qsTr("N/A") + } + PropertyRow { + showTopMargin: true + label: qsTr("Refresh rate") + value: detailFlickable.mon?.refreshRate != null + ? qsTr("%1 Hz").arg((detailFlickable.mon.refreshRate).toFixed(3)) + : qsTr("N/A") + } + PropertyRow { + showTopMargin: true + label: qsTr("Position") + value: detailFlickable.mon != null + ? qsTr("x: %1, y: %2").arg(detailFlickable.mon.x ?? 0).arg(detailFlickable.mon.y ?? 0) + : qsTr("N/A") + } + PropertyRow { + showTopMargin: true + label: qsTr("Scale") + value: detailFlickable.mon?.scale != null + ? qsTr("×%1").arg((detailFlickable.mon.scale).toFixed(2)) + : qsTr("N/A") + } + PropertyRow { + showTopMargin: true + label: qsTr("Transform") + value: { + const t = detailFlickable.mon?.transform ?? 0; + return ["Normal (0°)", "90°", "180°", "270°", + "Flipped", "Flipped 90°", "Flipped 180°", "Flipped 270°"][t] + ?? qsTr("Unknown"); + } + } + PropertyRow { + showTopMargin: true + label: qsTr("Make / Model") + value: { + const parts = [detailFlickable.mon?.make, detailFlickable.mon?.model] + .filter(v => v && v.length > 0); + return parts.length > 0 ? parts.join(" ") : qsTr("Unknown"); + } + } + PropertyRow { + showTopMargin: true + label: qsTr("Serial") + value: detailFlickable.mon?.serial || qsTr("Unknown") + } + PropertyRow { + showTopMargin: true + label: qsTr("Focused") + value: (detailFlickable.mon?.focused ?? false) ? qsTr("Yes") : qsTr("No") + } + } + } + } + } + } + + // ── Reusable sub-components ─────────────────────────────────────── + + component RotationChip: Item { + id: chip + required property string chipLabel + required property int chipAngle + required property bool isActive + signal clicked() + + implicitHeight: chipContent.implicitHeight + Tokens.padding.normal * 2 + + StyledRect { + anchors.fill: parent + radius: Tokens.rounding.full + color: chip.isActive + ? Colours.palette.m3secondaryContainer + : Qt.alpha(Colours.palette.m3surfaceVariant, 0.5) + + StateLayer { + color: chip.isActive + ? Colours.palette.m3onSecondaryContainer + : Colours.palette.m3onSurfaceVariant + function onClicked(): void { chip.clicked(); } + } + + ColumnLayout { + id: chipContent + anchors.centerIn: parent + spacing: 2 + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + text: "screen_rotation" + rotation: chip.chipAngle + font.pointSize: Tokens.font.size.normal + color: chip.isActive + ? Colours.palette.m3onSecondaryContainer + : Colours.palette.m3onSurfaceVariant + Behavior on rotation { Anim {} } + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: chip.chipLabel + font.pointSize: Tokens.font.size.small + color: chip.isActive + ? Colours.palette.m3onSecondaryContainer + : Colours.palette.m3onSurfaceVariant + } + } + + Behavior on color { CAnim {} } + } + } + + component ArrangeButton: Item { + id: arrangeBtn + required property string btnIcon + required property string btnLabel + signal clicked() + + implicitHeight: btnContent.implicitHeight + Tokens.padding.normal * 2 + + StyledRect { + anchors.fill: parent + radius: Tokens.rounding.normal + color: Qt.alpha(Colours.palette.m3surfaceVariant, 0.5) + + StateLayer { + color: Colours.palette.m3onSurfaceVariant + function onClicked(): void { arrangeBtn.clicked(); } + } + + ColumnLayout { + id: btnContent + anchors.centerIn: parent + spacing: 2 + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + text: arrangeBtn.btnIcon + font.pointSize: Tokens.font.size.normal + color: Colours.palette.m3onSurfaceVariant + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: arrangeBtn.btnLabel + font.pointSize: Tokens.font.size.small + color: Colours.palette.m3onSurfaceVariant + } + } + } + } +} diff --git a/modules/controlcenter/network/EthernetDetails.qml b/modules/controlcenter/network/EthernetDetails.qml index 4e60b3d48..1daeb4a29 100644 --- a/modules/controlcenter/network/EthernetDetails.qml +++ b/modules/controlcenter/network/EthernetDetails.qml @@ -2,14 +2,14 @@ pragma ComponentBehavior: Bound import ".." import "../components" +import QtQuick +import QtQuick.Layouts +import Caelestia.Config import qs.components +import qs.components.containers import qs.components.controls import qs.components.effects -import qs.components.containers import qs.services -import qs.config -import QtQuick -import QtQuick.Layouts DeviceDetails { id: root @@ -43,7 +43,7 @@ DeviceDetails { sections: [ Component { ColumnLayout { - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal SectionHeader { title: qsTr("Connection status") @@ -69,7 +69,7 @@ DeviceDetails { }, Component { ColumnLayout { - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal SectionHeader { title: qsTr("Device properties") @@ -77,7 +77,7 @@ DeviceDetails { } SectionContainer { - contentSpacing: Appearance.spacing.small / 2 + contentSpacing: Tokens.spacing.small / 2 PropertyRow { label: qsTr("Interface") @@ -100,7 +100,7 @@ DeviceDetails { }, Component { ColumnLayout { - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal SectionHeader { title: qsTr("Connection information") diff --git a/modules/controlcenter/network/EthernetList.qml b/modules/controlcenter/network/EthernetList.qml index d1eb95798..3a947c866 100644 --- a/modules/controlcenter/network/EthernetList.qml +++ b/modules/controlcenter/network/EthernetList.qml @@ -2,13 +2,13 @@ pragma ComponentBehavior: Bound import ".." import "../components" +import QtQuick +import QtQuick.Layouts +import Caelestia.Config import qs.components -import qs.components.controls import qs.components.containers +import qs.components.controls import qs.services -import qs.config -import QtQuick -import QtQuick.Layouts DeviceList { id: root @@ -23,11 +23,11 @@ DeviceList { headerComponent: Component { RowLayout { - spacing: Appearance.spacing.smaller + spacing: Tokens.spacing.smaller StyledText { text: qsTr("Settings") - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large font.weight: 500 } @@ -39,9 +39,9 @@ DeviceList { toggled: !root.session.ethernet.active icon: "settings" accent: "Primary" - iconSize: Appearance.font.size.normal - horizontalPadding: Appearance.padding.normal - verticalPadding: Appearance.padding.smaller + iconSize: Tokens.font.size.normal + horizontalPadding: Tokens.padding.normal + verticalPadding: Tokens.padding.smaller onClicked: { if (root.session.ethernet.active) @@ -62,15 +62,15 @@ DeviceList { 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 + implicitHeight: rowLayout.implicitHeight + Tokens.padding.normal * 2 color: Qt.alpha(Colours.tPalette.m3surfaceContainer, ethernetItem.isActive ? Colours.tPalette.m3surfaceContainer.a : 0) - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal StateLayer { id: stateLayer - function onClicked(): void { + onClicked: { root.session.ethernet.active = modelData; } } @@ -79,15 +79,15 @@ DeviceList { id: rowLayout anchors.fill: parent - anchors.margins: Appearance.padding.normal + anchors.margins: Tokens.padding.normal - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal StyledRect { implicitWidth: implicitHeight - implicitHeight: icon.implicitHeight + Appearance.padding.normal * 2 + implicitHeight: icon.implicitHeight + Tokens.padding.normal * 2 - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal color: modelData.connected ? Colours.palette.m3primaryContainer : Colours.tPalette.m3surfaceContainerHigh StyledRect { @@ -101,7 +101,7 @@ DeviceList { anchors.centerIn: parent text: "cable" - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large fill: modelData.connected ? 1 : 0 color: modelData.connected ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface @@ -124,13 +124,13 @@ DeviceList { RowLayout { Layout.fillWidth: true - spacing: Appearance.spacing.smaller + spacing: Tokens.spacing.smaller StyledText { Layout.fillWidth: true text: modelData.connected ? qsTr("Connected") : qsTr("Disconnected") color: modelData.connected ? Colours.palette.m3primary : Colours.palette.m3outline - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small font.weight: modelData.connected ? 500 : 400 elide: Text.ElideRight } @@ -141,21 +141,21 @@ DeviceList { id: connectBtn implicitWidth: implicitHeight - implicitHeight: connectIcon.implicitHeight + Appearance.padding.smaller * 2 + implicitHeight: connectIcon.implicitHeight + Tokens.padding.smaller * 2 - radius: Appearance.rounding.full + radius: Tokens.rounding.full color: Qt.alpha(Colours.palette.m3primaryContainer, modelData.connected ? 1 : 0) StateLayer { - color: modelData.connected ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface - - function onClicked(): void { + onClicked: { if (modelData.connected && modelData.connection) { Nmcli.disconnectEthernet(modelData.connection, () => {}); } else { Nmcli.connectEthernet(modelData.connection || "", modelData.interface || "", () => {}); } } + + color: modelData.connected ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface } MaterialIcon { diff --git a/modules/controlcenter/network/EthernetPane.qml b/modules/controlcenter/network/EthernetPane.qml index 59d82bb08..d66620f1f 100644 --- a/modules/controlcenter/network/EthernetPane.qml +++ b/modules/controlcenter/network/EthernetPane.qml @@ -2,11 +2,11 @@ pragma ComponentBehavior: Bound import ".." import "../components" +import QtQuick +import Quickshell.Widgets +import Caelestia.Config import qs.components import qs.components.containers -import qs.config -import Quickshell.Widgets -import QtQuick SplitPaneWithDetails { id: root diff --git a/modules/controlcenter/network/EthernetSettings.qml b/modules/controlcenter/network/EthernetSettings.qml index 90bfcf46a..f13b6fca8 100644 --- a/modules/controlcenter/network/EthernetSettings.qml +++ b/modules/controlcenter/network/EthernetSettings.qml @@ -2,20 +2,20 @@ pragma ComponentBehavior: Bound import ".." import "../components" +import QtQuick +import QtQuick.Layouts +import Caelestia.Config import qs.components import qs.components.controls import qs.components.effects import qs.services -import qs.config -import QtQuick -import QtQuick.Layouts ColumnLayout { id: root required property Session session - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal SettingsHeader { icon: "cable" @@ -23,9 +23,9 @@ ColumnLayout { } StyledText { - Layout.topMargin: Appearance.spacing.large + Layout.topMargin: Tokens.spacing.large text: qsTr("Ethernet devices") - font.pointSize: Appearance.font.size.larger + font.pointSize: Tokens.font.size.larger font.weight: 500 } @@ -36,9 +36,9 @@ ColumnLayout { StyledRect { Layout.fillWidth: true - implicitHeight: ethernetInfo.implicitHeight + Appearance.padding.large * 2 + implicitHeight: ethernetInfo.implicitHeight + Tokens.padding.large * 2 - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal color: Colours.tPalette.m3surfaceContainer ColumnLayout { @@ -47,9 +47,9 @@ ColumnLayout { anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - anchors.margins: Appearance.padding.large + anchors.margins: Tokens.padding.large - spacing: Appearance.spacing.small / 2 + spacing: Tokens.spacing.small / 2 StyledText { text: qsTr("Total devices") @@ -58,18 +58,18 @@ ColumnLayout { StyledText { text: qsTr("%1").arg(Nmcli.ethernetDevices.length) color: Colours.palette.m3outline - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small } StyledText { - Layout.topMargin: Appearance.spacing.normal + Layout.topMargin: Tokens.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 + font.pointSize: Tokens.font.size.small } } } diff --git a/modules/controlcenter/network/NetworkSettings.qml b/modules/controlcenter/network/NetworkSettings.qml index bda7cb18a..d8700ecd9 100644 --- a/modules/controlcenter/network/NetworkSettings.qml +++ b/modules/controlcenter/network/NetworkSettings.qml @@ -2,22 +2,22 @@ pragma ComponentBehavior: Bound import ".." import "../components" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Caelestia.Config import qs.components -import qs.components.controls import qs.components.containers +import qs.components.controls import qs.components.effects import qs.services -import qs.config -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts ColumnLayout { id: root required property Session session - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal SettingsHeader { icon: "router" @@ -25,13 +25,13 @@ ColumnLayout { } SectionHeader { - Layout.topMargin: Appearance.spacing.large + Layout.topMargin: Tokens.spacing.large title: qsTr("Ethernet") description: qsTr("Ethernet device information") } SectionContainer { - contentSpacing: Appearance.spacing.small / 2 + contentSpacing: Tokens.spacing.small / 2 PropertyRow { label: qsTr("Total devices") @@ -46,7 +46,7 @@ ColumnLayout { } SectionHeader { - Layout.topMargin: Appearance.spacing.large + Layout.topMargin: Tokens.spacing.large title: qsTr("Wireless") description: qsTr("WiFi network settings") } @@ -62,34 +62,33 @@ ColumnLayout { } SectionHeader { - Layout.topMargin: Appearance.spacing.large + Layout.topMargin: Tokens.spacing.large title: qsTr("VPN") description: qsTr("VPN provider settings") - visible: Config.utilities.vpn.enabled || Config.utilities.vpn.provider.length > 0 + visible: GlobalConfig.utilities.vpn.enabled || GlobalConfig.utilities.vpn.provider.length > 0 } SectionContainer { - visible: Config.utilities.vpn.enabled || Config.utilities.vpn.provider.length > 0 + visible: GlobalConfig.utilities.vpn.enabled || GlobalConfig.utilities.vpn.provider.length > 0 ToggleRow { label: qsTr("VPN enabled") - checked: Config.utilities.vpn.enabled + checked: GlobalConfig.utilities.vpn.enabled toggle.onToggled: { - Config.utilities.vpn.enabled = checked; - Config.save(); + GlobalConfig.utilities.vpn.enabled = checked; } } PropertyRow { showTopMargin: true label: qsTr("Providers") - value: qsTr("%1").arg(Config.utilities.vpn.provider.length) + value: qsTr("%1").arg(GlobalConfig.utilities.vpn.provider.length) } TextButton { Layout.fillWidth: true - Layout.topMargin: Appearance.spacing.normal - Layout.minimumHeight: Appearance.font.size.normal + Appearance.padding.normal * 2 + Layout.topMargin: Tokens.spacing.normal + Layout.minimumHeight: Tokens.font.size.normal + Tokens.padding.normal * 2 text: qsTr("⚙ Manage VPN Providers") inactiveColour: Colours.palette.m3secondaryContainer inactiveOnColour: Colours.palette.m3onSecondaryContainer @@ -101,13 +100,13 @@ ColumnLayout { } SectionHeader { - Layout.topMargin: Appearance.spacing.large + Layout.topMargin: Tokens.spacing.large title: qsTr("Current connection") description: qsTr("Active network connection information") } SectionContainer { - contentSpacing: Appearance.spacing.small / 2 + contentSpacing: Tokens.spacing.small / 2 PropertyRow { label: qsTr("Network") @@ -141,20 +140,20 @@ ColumnLayout { 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) + width: Math.min(600, parent.width - Tokens.padding.large * 2) + height: Math.min(700, parent.height - Tokens.padding.large * 2) modal: true closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside background: StyledRect { color: Colours.palette.m3surface - radius: Appearance.rounding.large + radius: Tokens.rounding.large } StyledFlickable { anchors.fill: parent - anchors.margins: Appearance.padding.large * 1.5 + anchors.margins: Tokens.padding.large * 1.5 flickableDirection: Flickable.VerticalFlick contentHeight: vpnSettingsContent.height clip: true diff --git a/modules/controlcenter/network/NetworkingPane.qml b/modules/controlcenter/network/NetworkingPane.qml index 26cdbfacd..e02bfafbc 100644 --- a/modules/controlcenter/network/NetworkingPane.qml +++ b/modules/controlcenter/network/NetworkingPane.qml @@ -3,17 +3,17 @@ pragma ComponentBehavior: Bound import ".." import "../components" import "." +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Caelestia.Config import qs.components +import qs.components.containers import qs.components.controls import qs.components.effects -import qs.components.containers import qs.services -import qs.config import qs.utils -import Quickshell -import Quickshell.Widgets -import QtQuick -import QtQuick.Layouts Item { id: root @@ -43,15 +43,15 @@ Item { anchors.left: parent.left anchors.right: parent.right - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal RowLayout { Layout.fillWidth: true - spacing: Appearance.spacing.smaller + spacing: Tokens.spacing.smaller StyledText { text: qsTr("Network") - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large font.weight: 500 } @@ -63,9 +63,9 @@ Item { toggled: Nmcli.wifiEnabled icon: "wifi" accent: "Tertiary" - iconSize: Appearance.font.size.normal - horizontalPadding: Appearance.padding.normal - verticalPadding: Appearance.padding.smaller + iconSize: Tokens.font.size.normal + horizontalPadding: Tokens.padding.normal + verticalPadding: Tokens.padding.smaller tooltip: qsTr("Toggle WiFi") onClicked: { @@ -77,9 +77,9 @@ Item { toggled: Nmcli.scanning icon: "wifi_find" accent: "Secondary" - iconSize: Appearance.font.size.normal - horizontalPadding: Appearance.padding.normal - verticalPadding: Appearance.padding.smaller + iconSize: Tokens.font.size.normal + horizontalPadding: Tokens.padding.normal + verticalPadding: Tokens.padding.smaller tooltip: qsTr("Scan for networks") onClicked: { @@ -91,9 +91,9 @@ Item { 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 + iconSize: Tokens.font.size.normal + horizontalPadding: Tokens.padding.normal + verticalPadding: Tokens.padding.smaller tooltip: qsTr("Network settings") onClicked: { @@ -120,6 +120,7 @@ Item { Loader { Layout.fillWidth: true + asynchronous: true sourceComponent: Component { VpnList { session: root.session @@ -138,6 +139,7 @@ Item { Loader { Layout.fillWidth: true + asynchronous: true sourceComponent: Component { EthernetList { session: root.session @@ -156,6 +158,7 @@ Item { Loader { Layout.fillWidth: true + asynchronous: true sourceComponent: Component { WirelessList { session: root.session @@ -196,9 +199,6 @@ Item { } Connections { - target: root.session && root.session.vpn ? root.session.vpn : null - enabled: target !== null - function onActiveChanged() { // Clear others when VPN is selected if (root.session && root.session.vpn && root.session.vpn.active) { @@ -209,12 +209,12 @@ Item { } rightPaneItem.nextComponent = rightPaneItem.getComponentForPane(); } - } - Connections { - target: root.session && root.session.ethernet ? root.session.ethernet : null + 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) { @@ -225,12 +225,12 @@ Item { } rightPaneItem.nextComponent = rightPaneItem.getComponentForPane(); } - } - Connections { - target: root.session && root.session.network ? root.session.network : null + 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) { @@ -241,6 +241,9 @@ Item { } rightPaneItem.nextComponent = rightPaneItem.getComponentForPane(); } + + target: root.session && root.session.network ? root.session.network : null + enabled: target !== null } Loader { @@ -278,6 +281,7 @@ Item { StyledFlickable { id: settingsFlickable + flickableDirection: Flickable.VerticalFlick contentHeight: settingsInner.height @@ -301,6 +305,7 @@ Item { StyledFlickable { id: ethernetFlickable + flickableDirection: Flickable.VerticalFlick contentHeight: ethernetDetailsInner.height @@ -324,6 +329,7 @@ Item { StyledFlickable { id: wirelessFlickable + flickableDirection: Flickable.VerticalFlick contentHeight: wirelessDetailsInner.height @@ -347,6 +353,7 @@ Item { StyledFlickable { id: vpnFlickable + flickableDirection: Flickable.VerticalFlick contentHeight: vpnDetailsInner.height diff --git a/modules/controlcenter/network/VpnDetails.qml b/modules/controlcenter/network/VpnDetails.qml index 1c71cd716..7ac2dc50f 100644 --- a/modules/controlcenter/network/VpnDetails.qml +++ b/modules/controlcenter/network/VpnDetails.qml @@ -2,16 +2,16 @@ pragma ComponentBehavior: Bound import ".." import "../components" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Caelestia.Config import qs.components +import qs.components.containers import qs.components.controls import qs.components.effects -import qs.components.containers import qs.services -import qs.config import qs.utils -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts DeviceDetails { id: root @@ -21,7 +21,7 @@ DeviceDetails { readonly property bool providerEnabled: { if (!vpnProvider || vpnProvider.index === undefined) return false; - const provider = Config.utilities.vpn.provider[vpnProvider.index]; + const provider = GlobalConfig.utilities.vpn.provider[vpnProvider.index]; return provider && typeof provider === "object" && provider.enabled === true; } @@ -37,7 +37,7 @@ DeviceDetails { sections: [ Component { ColumnLayout { - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal SectionHeader { title: qsTr("Connection status") @@ -55,8 +55,8 @@ DeviceDetails { 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]; + for (let i = 0; i < GlobalConfig.utilities.vpn.provider.length; i++) { + const p = GlobalConfig.utilities.vpn.provider[i]; if (typeof p === "object") { const newProvider = { name: p.name, @@ -72,25 +72,31 @@ DeviceDetails { newProvider.enabled = (i === index) ? false : (p.enabled !== false); } + if (p.connectCmd && p.connectCmd.length > 0) { + newProvider.connectCmd = p.connectCmd; + } + if (p.disconnectCmd && p.disconnectCmd.length > 0) { + newProvider.disconnectCmd = p.disconnectCmd; + } + providers.push(newProvider); } else { providers.push(p); } } - Config.utilities.vpn.provider = providers; - Config.save(); + GlobalConfig.utilities.vpn.provider = providers; } } RowLayout { Layout.fillWidth: true - Layout.topMargin: Appearance.spacing.normal - spacing: Appearance.spacing.normal + Layout.topMargin: Tokens.spacing.normal + spacing: Tokens.spacing.normal TextButton { Layout.fillWidth: true - Layout.minimumHeight: Appearance.font.size.normal + Appearance.padding.normal * 2 + Layout.minimumHeight: Tokens.font.size.normal + Tokens.padding.normal * 2 visible: root.providerEnabled enabled: !VPN.connecting inactiveColour: Colours.palette.m3primaryContainer @@ -109,10 +115,13 @@ DeviceDetails { inactiveOnColour: Colours.palette.m3onSecondaryContainer onClicked: { + const provider = GlobalConfig.utilities.vpn.provider[root.vpnProvider.index]; editVpnDialog.editIndex = root.vpnProvider.index; editVpnDialog.providerName = root.vpnProvider.name; editVpnDialog.displayName = root.vpnProvider.displayName; editVpnDialog.interfaceName = root.vpnProvider.interface; + editVpnDialog.connectCmd = (provider && provider.connectCmd) ? provider.connectCmd.join(" ") : ""; + editVpnDialog.disconnectCmd = (provider && provider.disconnectCmd) ? provider.disconnectCmd.join(" ") : ""; editVpnDialog.open(); } } @@ -125,23 +134,46 @@ DeviceDetails { onClicked: { const providers = []; - for (let i = 0; i < Config.utilities.vpn.provider.length; i++) { + for (let i = 0; i < GlobalConfig.utilities.vpn.provider.length; i++) { if (i !== root.vpnProvider.index) { - providers.push(Config.utilities.vpn.provider[i]); + providers.push(GlobalConfig.utilities.vpn.provider[i]); } } - Config.utilities.vpn.provider = providers; - Config.save(); + GlobalConfig.utilities.vpn.provider = providers; root.session.vpn.active = null; } } } + + TextButton { + Layout.fillWidth: true + Layout.topMargin: Tokens.spacing.normal + visible: root.providerEnabled && VPN.status.state === "needs-auth" && VPN.status.authUrl !== "" + text: qsTr("Open Login Page") + inactiveColour: Colours.palette.m3tertiaryContainer + inactiveOnColour: Colours.palette.m3onTertiaryContainer + + onClicked: { + Qt.openUrlExternally(VPN.status.authUrl); + } + } + + StyledText { + Layout.fillWidth: true + Layout.topMargin: Tokens.spacing.normal + visible: root.providerEnabled && VPN.status.state === "needs-auth" && VPN.status.authUrl === "" + text: qsTr("Click 'Connect' to generate authentication URL") + font.pointSize: Tokens.font.size.small + color: Colours.palette.m3onSurfaceVariant + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + } } } }, Component { ColumnLayout { - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal SectionHeader { title: qsTr("Provider details") @@ -149,7 +181,7 @@ DeviceDetails { } SectionContainer { - contentSpacing: Appearance.spacing.small / 2 + contentSpacing: Tokens.spacing.small / 2 PropertyRow { label: qsTr("Provider") @@ -176,12 +208,31 @@ DeviceDetails { return qsTr("Disabled"); if (VPN.connecting) return qsTr("Connecting..."); - if (VPN.connected) + + switch (VPN.status.state) { + case "connected": return qsTr("Connected"); - return qsTr("Enabled (Not connected)"); + case "disconnected": + return qsTr("Disconnected"); + case "connecting": + return qsTr("Connecting..."); + case "needs-auth": + return qsTr("Authentication required"); + case "error": + return qsTr("Error"); + default: + return qsTr("Unknown"); + } } } + PropertyRow { + visible: VPN.status.reason !== "" + showTopMargin: true + label: qsTr("Details") + value: VPN.status.reason + } + PropertyRow { showTopMargin: true label: qsTr("Enabled") @@ -200,11 +251,17 @@ DeviceDetails { property string providerName: "" property string displayName: "" property string interfaceName: "" + property string connectCmd: "" + property string disconnectCmd: "" + + 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 + width: Math.min(400, parent.width - Tokens.padding.large * 2) + padding: Tokens.padding.large * 1.5 modal: true closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside @@ -217,15 +274,13 @@ DeviceDetails { property: "opacity" from: 0 to: 1 - duration: Appearance.anim.durations.expressiveFastSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + type: Anim.FastSpatial } Anim { property: "scale" from: 0.7 to: 1 - duration: Appearance.anim.durations.expressiveFastSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + type: Anim.FastSpatial } } @@ -234,29 +289,23 @@ DeviceDetails { property: "opacity" from: 1 to: 0 - duration: Appearance.anim.durations.expressiveFastSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + type: Anim.FastSpatial } Anim { property: "scale" from: 1 to: 0.7 - duration: Appearance.anim.durations.expressiveFastSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + type: Anim.FastSpatial } } - function closeWithAnimation(): void { - close(); - } - Overlay.modal: Rectangle { color: Qt.rgba(0, 0, 0, 0.4 * editVpnDialog.opacity) } background: StyledRect { color: Colours.palette.m3surfaceContainerHigh - radius: Appearance.rounding.large + radius: Tokens.rounding.large Elevation { anchors.fill: parent @@ -267,21 +316,21 @@ DeviceDetails { } contentItem: ColumnLayout { - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal StyledText { text: qsTr("Edit VPN Provider") - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large font.weight: 500 } ColumnLayout { Layout.fillWidth: true - spacing: Appearance.spacing.smaller / 2 + spacing: Tokens.spacing.smaller / 2 StyledText { text: qsTr("Display Name") - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small color: Colours.palette.m3onSurfaceVariant } @@ -289,7 +338,7 @@ DeviceDetails { Layout.fillWidth: true implicitHeight: 40 color: displayNameField.activeFocus ? Colours.layer(Colours.palette.m3surfaceContainer, 3) : Colours.layer(Colours.palette.m3surfaceContainer, 2) - radius: Appearance.rounding.small + radius: Tokens.rounding.small border.width: 1 border.color: displayNameField.activeFocus ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3outline, 0.3) @@ -302,8 +351,9 @@ DeviceDetails { StyledTextField { id: displayNameField + anchors.centerIn: parent - width: parent.width - Appearance.padding.normal + width: parent.width - Tokens.padding.normal horizontalAlignment: TextInput.AlignLeft text: editVpnDialog.displayName onTextChanged: editVpnDialog.displayName = text @@ -313,11 +363,11 @@ DeviceDetails { ColumnLayout { Layout.fillWidth: true - spacing: Appearance.spacing.smaller / 2 + spacing: Tokens.spacing.smaller / 2 StyledText { text: qsTr("Interface (e.g., wg0, torguard)") - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small color: Colours.palette.m3onSurfaceVariant } @@ -325,7 +375,7 @@ DeviceDetails { Layout.fillWidth: true implicitHeight: 40 color: interfaceNameField.activeFocus ? Colours.layer(Colours.palette.m3surfaceContainer, 3) : Colours.layer(Colours.palette.m3surfaceContainer, 2) - radius: Appearance.rounding.small + radius: Tokens.rounding.small border.width: 1 border.color: interfaceNameField.activeFocus ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3outline, 0.3) @@ -338,8 +388,9 @@ DeviceDetails { StyledTextField { id: interfaceNameField + anchors.centerIn: parent - width: parent.width - Appearance.padding.normal + width: parent.width - Tokens.padding.normal horizontalAlignment: TextInput.AlignLeft text: editVpnDialog.interfaceName onTextChanged: editVpnDialog.interfaceName = text @@ -347,10 +398,86 @@ DeviceDetails { } } + ColumnLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.smaller / 2 + visible: editVpnDialog.connectCmd.length > 0 + + StyledText { + text: qsTr("Connect Command") + font.pointSize: Tokens.font.size.small + color: Colours.palette.m3onSurfaceVariant + } + + StyledRect { + Layout.fillWidth: true + implicitHeight: 40 + color: connectCmdFieldEdit.activeFocus ? Colours.layer(Colours.palette.m3surfaceContainer, 3) : Colours.layer(Colours.palette.m3surfaceContainer, 2) + radius: Tokens.rounding.small + border.width: 1 + border.color: connectCmdFieldEdit.activeFocus ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3outline, 0.3) + + Behavior on color { + CAnim {} + } + Behavior on border.color { + CAnim {} + } + + StyledTextField { + id: connectCmdFieldEdit + + anchors.centerIn: parent + width: parent.width - Tokens.padding.normal + horizontalAlignment: TextInput.AlignLeft + text: editVpnDialog.connectCmd + onTextChanged: editVpnDialog.connectCmd = text + } + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.smaller / 2 + visible: editVpnDialog.disconnectCmd.length > 0 + + StyledText { + text: qsTr("Disconnect Command") + font.pointSize: Tokens.font.size.small + color: Colours.palette.m3onSurfaceVariant + } + + StyledRect { + Layout.fillWidth: true + implicitHeight: 40 + color: disconnectCmdFieldEdit.activeFocus ? Colours.layer(Colours.palette.m3surfaceContainer, 3) : Colours.layer(Colours.palette.m3surfaceContainer, 2) + radius: Tokens.rounding.small + border.width: 1 + border.color: disconnectCmdFieldEdit.activeFocus ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3outline, 0.3) + + Behavior on color { + CAnim {} + } + Behavior on border.color { + CAnim {} + } + + StyledTextField { + id: disconnectCmdFieldEdit + + anchors.centerIn: parent + width: parent.width - Tokens.padding.normal + horizontalAlignment: TextInput.AlignLeft + text: editVpnDialog.disconnectCmd + onTextChanged: editVpnDialog.disconnectCmd = text + } + } + } + RowLayout { - Layout.topMargin: Appearance.spacing.normal + Layout.topMargin: Tokens.spacing.normal Layout.fillWidth: true - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal TextButton { Layout.fillWidth: true @@ -369,24 +496,44 @@ DeviceDetails { onClicked: { const providers = []; - const oldProvider = Config.utilities.vpn.provider[editVpnDialog.editIndex]; + const oldProvider = GlobalConfig.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++) { + for (let i = 0; i < GlobalConfig.utilities.vpn.provider.length; i++) { if (i === editVpnDialog.editIndex) { - providers.push({ - name: editVpnDialog.providerName, + const hasCommands = editVpnDialog.connectCmd.length > 0 && editVpnDialog.disconnectCmd.length > 0; + const newProvider = { displayName: editVpnDialog.displayName || editVpnDialog.interfaceName, + enabled: wasEnabled, interface: editVpnDialog.interfaceName, - enabled: wasEnabled - }); + name: editVpnDialog.providerName + }; + + if (hasCommands) { + newProvider.connectCmd = editVpnDialog.connectCmd.split(" ").filter(s => s.length > 0); + newProvider.disconnectCmd = editVpnDialog.disconnectCmd.split(" ").filter(s => s.length > 0); + } + + providers.push(newProvider); } else { - providers.push(Config.utilities.vpn.provider[i]); + const p = GlobalConfig.utilities.vpn.provider[i]; + const reconstructed = { + displayName: p.displayName, + enabled: p.enabled, + interface: p.interface, + name: p.name + }; + if (p.connectCmd && p.connectCmd.length > 0) { + reconstructed.connectCmd = p.connectCmd; + } + if (p.disconnectCmd && p.disconnectCmd.length > 0) { + reconstructed.disconnectCmd = p.disconnectCmd; + } + providers.push(reconstructed); } } - Config.utilities.vpn.provider = providers; - Config.save(); + GlobalConfig.utilities.vpn.provider = providers; editVpnDialog.closeWithAnimation(); } } diff --git a/modules/controlcenter/network/VpnList.qml b/modules/controlcenter/network/VpnList.qml index 81f4a45a3..d9266b34e 100644 --- a/modules/controlcenter/network/VpnList.qml +++ b/modules/controlcenter/network/VpnList.qml @@ -1,15 +1,15 @@ pragma ComponentBehavior: Bound import ".." +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Caelestia.Config 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 @@ -18,18 +18,17 @@ ColumnLayout { property bool showHeader: true property int pendingSwitchIndex: -1 - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal Connections { - target: VPN function onConnectedChanged() { if (!VPN.connected && root.pendingSwitchIndex >= 0) { const targetIndex = root.pendingSwitchIndex; root.pendingSwitchIndex = -1; const providers = []; - for (let i = 0; i < Config.utilities.vpn.provider.length; i++) { - const p = Config.utilities.vpn.provider[i]; + for (let i = 0; i < GlobalConfig.utilities.vpn.provider.length; i++) { + const p = GlobalConfig.utilities.vpn.provider[i]; if (typeof p === "object") { const newProvider = { name: p.name, @@ -37,19 +36,26 @@ ColumnLayout { interface: p.interface, enabled: (i === targetIndex) }; + if (p.connectCmd && p.connectCmd.length > 0) { + newProvider.connectCmd = p.connectCmd; + } + if (p.disconnectCmd && p.disconnectCmd.length > 0) { + newProvider.disconnectCmd = p.disconnectCmd; + } providers.push(newProvider); } else { providers.push(p); } } - Config.utilities.vpn.provider = providers; - Config.save(); + GlobalConfig.utilities.vpn.provider = providers; Qt.callLater(function () { VPN.toggle(); }); } } + + target: VPN } TextButton { @@ -70,10 +76,10 @@ ColumnLayout { Layout.preferredHeight: contentHeight interactive: false - spacing: Appearance.spacing.smaller + spacing: Tokens.spacing.smaller model: ScriptModel { - values: Config.utilities.vpn.provider.map((provider, index) => { + values: GlobalConfig.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; @@ -97,12 +103,13 @@ ColumnLayout { required property int index width: ListView.view ? ListView.view.width : undefined + implicitHeight: rowLayout.implicitHeight + Tokens.padding.normal * 2 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 + radius: Tokens.rounding.normal StateLayer { - function onClicked(): void { + onClicked: { if (root.session && root.session.vpn) { root.session.vpn.active = modelData; } @@ -115,15 +122,15 @@ ColumnLayout { anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - anchors.margins: Appearance.padding.normal + anchors.margins: Tokens.padding.normal - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal StyledRect { implicitWidth: implicitHeight - implicitHeight: icon.implicitHeight + Appearance.padding.normal * 2 + implicitHeight: icon.implicitHeight + Tokens.padding.normal * 2 - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal color: modelData.enabled && VPN.connected ? Colours.palette.m3primaryContainer : Colours.tPalette.m3surfaceContainerHigh MaterialIcon { @@ -131,7 +138,7 @@ ColumnLayout { anchors.centerIn: parent text: modelData.enabled && VPN.connected ? "vpn_key" : "vpn_key_off" - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large fill: modelData.enabled && VPN.connected ? 1 : 0 color: modelData.enabled && VPN.connected ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface } @@ -152,21 +159,44 @@ ColumnLayout { RowLayout { Layout.fillWidth: true - spacing: Appearance.spacing.smaller + spacing: Tokens.spacing.smaller StyledText { Layout.fillWidth: true text: { - if (modelData.enabled && VPN.connected) + if (!modelData.enabled) + return qsTr("Disabled"); + + if (VPN.connecting) + return qsTr("Connecting..."); + + switch (VPN.status.state) { + case "connected": return qsTr("Connected"); - if (modelData.enabled && VPN.connecting) + case "disconnected": + return qsTr("Enabled"); + case "connecting": return qsTr("Connecting..."); - if (modelData.enabled) + case "needs-auth": + return qsTr("Auth required"); + case "error": + return qsTr("Error"); + default: return qsTr("Enabled"); - return qsTr("Disabled"); + } + } + color: { + if (!modelData.enabled) + return Colours.palette.m3outline; + if (VPN.status.state === "connected") + return Colours.palette.m3primary; + if (VPN.status.state === "error") + return Colours.palette.m3error; + if (VPN.status.state === "needs-auth") + return Colours.palette.m3tertiary; + return Colours.palette.m3onSurface; } - color: modelData.enabled ? (VPN.connected ? Colours.palette.m3primary : Colours.palette.m3onSurface) : Colours.palette.m3outline - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small font.weight: modelData.enabled && VPN.connected ? 500 : 400 elide: Text.ElideRight } @@ -175,14 +205,13 @@ ColumnLayout { StyledRect { implicitWidth: implicitHeight - implicitHeight: connectIcon.implicitHeight + Appearance.padding.smaller * 2 + implicitHeight: connectIcon.implicitHeight + Tokens.padding.smaller * 2 - radius: Appearance.rounding.full + radius: Tokens.rounding.full color: Qt.alpha(Colours.palette.m3primaryContainer, VPN.connected && modelData.enabled ? 1 : 0) StateLayer { - enabled: !VPN.connecting - function onClicked(): void { + onClicked: { const clickedIndex = modelData.index; if (modelData.enabled) { @@ -193,8 +222,8 @@ ColumnLayout { VPN.toggle(); } else { const providers = []; - for (let i = 0; i < Config.utilities.vpn.provider.length; i++) { - const p = Config.utilities.vpn.provider[i]; + for (let i = 0; i < GlobalConfig.utilities.vpn.provider.length; i++) { + const p = GlobalConfig.utilities.vpn.provider[i]; if (typeof p === "object") { const newProvider = { name: p.name, @@ -202,13 +231,18 @@ ColumnLayout { interface: p.interface, enabled: (i === clickedIndex) }; + if (p.connectCmd && p.connectCmd.length > 0) { + newProvider.connectCmd = p.connectCmd; + } + if (p.disconnectCmd && p.disconnectCmd.length > 0) { + newProvider.disconnectCmd = p.disconnectCmd; + } providers.push(newProvider); } else { providers.push(p); } } - Config.utilities.vpn.provider = providers; - Config.save(); + GlobalConfig.utilities.vpn.provider = providers; Qt.callLater(function () { VPN.toggle(); @@ -216,6 +250,8 @@ ColumnLayout { } } } + + enabled: !VPN.connecting } MaterialIcon { @@ -229,21 +265,33 @@ ColumnLayout { StyledRect { implicitWidth: implicitHeight - implicitHeight: deleteIcon.implicitHeight + Appearance.padding.smaller * 2 + implicitHeight: deleteIcon.implicitHeight + Tokens.padding.smaller * 2 - radius: Appearance.rounding.full + radius: Tokens.rounding.full color: "transparent" StateLayer { - function onClicked(): void { + onClicked: { const providers = []; - for (let i = 0; i < Config.utilities.vpn.provider.length; i++) { + for (let i = 0; i < GlobalConfig.utilities.vpn.provider.length; i++) { if (i !== modelData.index) { - providers.push(Config.utilities.vpn.provider[i]); + const p = GlobalConfig.utilities.vpn.provider[i]; + const reconstructed = { + name: p.name, + displayName: p.displayName, + interface: p.interface, + enabled: p.enabled + }; + if (p.connectCmd && p.connectCmd.length > 0) { + reconstructed.connectCmd = p.connectCmd; + } + if (p.disconnectCmd && p.disconnectCmd.length > 0) { + reconstructed.disconnectCmd = p.disconnectCmd; + } + providers.push(reconstructed); } } - Config.utilities.vpn.provider = providers; - Config.save(); + GlobalConfig.utilities.vpn.provider = providers; } } @@ -256,8 +304,6 @@ ColumnLayout { } } } - - implicitHeight: rowLayout.implicitHeight + Appearance.padding.normal * 2 } } } @@ -270,56 +316,8 @@ ColumnLayout { property string providerName: "" property string displayName: "" property string interfaceName: "" - - parent: Overlay.overlay - x: Math.round((parent.width - width) / 2) - y: Math.round((parent.height - height) / 2) - implicitWidth: Math.min(400, parent.width - Appearance.padding.large * 2) - padding: Appearance.padding.large * 1.5 - - modal: true - closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside - - opacity: 0 - scale: 0.7 - - enter: Transition { - ParallelAnimation { - Anim { - property: "opacity" - from: 0 - to: 1 - duration: Appearance.anim.durations.normal - easing.bezierCurve: Appearance.anim.curves.emphasized - } - Anim { - property: "scale" - from: 0.7 - to: 1 - duration: Appearance.anim.durations.normal - easing.bezierCurve: Appearance.anim.curves.emphasized - } - } - } - - exit: Transition { - ParallelAnimation { - Anim { - property: "opacity" - from: 1 - to: 0 - duration: Appearance.anim.durations.small - easing.bezierCurve: Appearance.anim.curves.emphasized - } - Anim { - property: "scale" - from: 1 - to: 0.7 - duration: Appearance.anim.durations.small - easing.bezierCurve: Appearance.anim.curves.emphasized - } - } - } + property string connectCmd: "" + property string disconnectCmd: "" function showProviderSelection(): void { currentState = "selection"; @@ -335,6 +333,8 @@ ColumnLayout { providerName = providerType; displayName = defaultDisplayName; interfaceName = ""; + connectCmd = ""; + disconnectCmd = ""; if (currentState === "selection") { transitionToForm.start(); @@ -346,59 +346,77 @@ ColumnLayout { } function showEditForm(index: int): void { - const provider = Config.utilities.vpn.provider[index]; + const provider = GlobalConfig.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 || "") : ""; + connectCmd = isObject && provider.connectCmd ? provider.connectCmd.join(" ") : ""; + disconnectCmd = isObject && provider.disconnectCmd ? provider.disconnectCmd.join(" ") : ""; currentState = "form"; open(); } - Overlay.modal: Rectangle { - color: Qt.rgba(0, 0, 0, 0.4 * vpnDialog.opacity) - } + parent: Overlay.overlay + x: Math.round((parent.width - width) / 2) + y: Math.round((parent.height - height) / 2) + implicitWidth: Math.min(400, parent.width - Tokens.padding.large * 2) + padding: Tokens.padding.large * 1.5 - onClosed: { - currentState = "selection"; - } + modal: true + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside - SequentialAnimation { - id: transitionToForm + opacity: 0 + scale: 0.7 + enter: Transition { ParallelAnimation { Anim { - target: selectionContent property: "opacity" - to: 0 - duration: Appearance.anim.durations.small - easing.bezierCurve: Appearance.anim.curves.emphasized + from: 0 + to: 1 + type: Anim.Emphasized } - } - - ScriptAction { - script: { - vpnDialog.currentState = "form"; + Anim { + property: "scale" + from: 0.7 + to: 1 + type: Anim.Emphasized } } + } + exit: Transition { ParallelAnimation { Anim { - target: formContent property: "opacity" - to: 1 - duration: Appearance.anim.durations.small - easing.bezierCurve: Appearance.anim.curves.emphasized + from: 1 + to: 0 + type: Anim.EmphasizedSmall + } + Anim { + property: "scale" + from: 1 + to: 0.7 + type: Anim.EmphasizedSmall } } } + 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 + radius: Tokens.rounding.large Elevation { anchors.fill: parent @@ -409,8 +427,7 @@ ColumnLayout { Behavior on implicitHeight { Anim { - duration: Appearance.anim.durations.normal - easing.bezierCurve: Appearance.anim.curves.emphasized + type: Anim.Emphasized } } } @@ -420,8 +437,7 @@ ColumnLayout { Behavior on implicitHeight { Anim { - duration: Appearance.anim.durations.normal - easing.bezierCurve: Appearance.anim.curves.emphasized + type: Anim.Emphasized } } @@ -429,20 +445,19 @@ ColumnLayout { id: selectionContent anchors.fill: parent - spacing: Appearance.spacing.normal + spacing: Tokens.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 + type: Anim.EmphasizedSmall } } StyledText { text: qsTr("Add VPN Provider") - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large font.weight: 500 } @@ -451,27 +466,26 @@ ColumnLayout { text: qsTr("Choose a provider to add") wrapMode: Text.WordWrap color: Colours.palette.m3outline - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small } TextButton { - Layout.topMargin: Appearance.spacing.normal + Layout.topMargin: Tokens.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]); + for (let i = 0; i < GlobalConfig.utilities.vpn.provider.length; i++) { + providers.push(GlobalConfig.utilities.vpn.provider[i]); } providers.push({ name: "netbird", displayName: "NetBird", interface: "wt0" }); - Config.utilities.vpn.provider = providers; - Config.save(); + GlobalConfig.utilities.vpn.provider = providers; vpnDialog.closeWithAnimation(); } } @@ -483,16 +497,15 @@ ColumnLayout { 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]); + for (let i = 0; i < GlobalConfig.utilities.vpn.provider.length; i++) { + providers.push(GlobalConfig.utilities.vpn.provider[i]); } providers.push({ name: "tailscale", displayName: "Tailscale", interface: "tailscale0" }); - Config.utilities.vpn.provider = providers; - Config.save(); + GlobalConfig.utilities.vpn.provider = providers; vpnDialog.closeWithAnimation(); } } @@ -504,23 +517,22 @@ ColumnLayout { 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]); + for (let i = 0; i < GlobalConfig.utilities.vpn.provider.length; i++) { + providers.push(GlobalConfig.utilities.vpn.provider[i]); } providers.push({ name: "warp", displayName: "Cloudflare WARP", interface: "CloudflareWARP" }); - Config.utilities.vpn.provider = providers; - Config.save(); + GlobalConfig.utilities.vpn.provider = providers; vpnDialog.closeWithAnimation(); } } TextButton { Layout.fillWidth: true - text: qsTr("WireGuard (Custom)") + text: qsTr("WireGuard") inactiveColour: Colours.tPalette.m3surfaceContainerHigh inactiveOnColour: Colours.palette.m3onSurface onClicked: { @@ -529,7 +541,17 @@ ColumnLayout { } TextButton { - Layout.topMargin: Appearance.spacing.normal + Layout.fillWidth: true + text: qsTr("Custom") + inactiveColour: Colours.tPalette.m3surfaceContainerHigh + inactiveOnColour: Colours.palette.m3onSurface + onClicked: { + vpnDialog.showAddForm("custom", "Custom VPN"); + } + } + + TextButton { + Layout.topMargin: Tokens.spacing.normal Layout.fillWidth: true text: qsTr("Cancel") inactiveColour: Colours.palette.m3secondaryContainer @@ -542,30 +564,29 @@ ColumnLayout { id: formContent anchors.fill: parent - spacing: Appearance.spacing.normal + spacing: Tokens.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 + type: Anim.EmphasizedSmall } } StyledText { text: vpnDialog.editIndex >= 0 ? qsTr("Edit VPN Provider") : qsTr("Add %1 VPN").arg(vpnDialog.displayName) - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large font.weight: 500 } ColumnLayout { Layout.fillWidth: true - spacing: Appearance.spacing.smaller / 2 + spacing: Tokens.spacing.smaller / 2 StyledText { text: qsTr("Display Name") - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small color: Colours.palette.m3onSurfaceVariant } @@ -573,7 +594,7 @@ ColumnLayout { Layout.fillWidth: true implicitHeight: 40 color: displayNameField.activeFocus ? Colours.layer(Colours.palette.m3surfaceContainer, 3) : Colours.layer(Colours.palette.m3surfaceContainer, 2) - radius: Appearance.rounding.small + radius: Tokens.rounding.small border.width: 1 border.color: displayNameField.activeFocus ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3outline, 0.3) @@ -586,8 +607,9 @@ ColumnLayout { StyledTextField { id: displayNameField + anchors.centerIn: parent - width: parent.width - Appearance.padding.normal + width: parent.width - Tokens.padding.normal horizontalAlignment: TextInput.AlignLeft text: vpnDialog.displayName onTextChanged: vpnDialog.displayName = text @@ -597,11 +619,11 @@ ColumnLayout { ColumnLayout { Layout.fillWidth: true - spacing: Appearance.spacing.smaller / 2 + spacing: Tokens.spacing.smaller / 2 StyledText { text: qsTr("Interface (e.g., wg0, torguard)") - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small color: Colours.palette.m3onSurfaceVariant } @@ -609,7 +631,7 @@ ColumnLayout { Layout.fillWidth: true implicitHeight: 40 color: interfaceNameField.activeFocus ? Colours.layer(Colours.palette.m3surfaceContainer, 3) : Colours.layer(Colours.palette.m3surfaceContainer, 2) - radius: Appearance.rounding.small + radius: Tokens.rounding.small border.width: 1 border.color: interfaceNameField.activeFocus ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3outline, 0.3) @@ -622,8 +644,9 @@ ColumnLayout { StyledTextField { id: interfaceNameField + anchors.centerIn: parent - width: parent.width - Appearance.padding.normal + width: parent.width - Tokens.padding.normal horizontalAlignment: TextInput.AlignLeft text: vpnDialog.interfaceName onTextChanged: vpnDialog.interfaceName = text @@ -631,10 +654,86 @@ ColumnLayout { } } + ColumnLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.smaller / 2 + visible: vpnDialog.editIndex >= 0 ? (vpnDialog.connectCmd.length > 0) : (vpnDialog.providerName === "custom") + + StyledText { + text: qsTr("Connect Command (e.g., wg-quick up wg0)") + font.pointSize: Tokens.font.size.small + color: Colours.palette.m3onSurfaceVariant + } + + StyledRect { + Layout.fillWidth: true + implicitHeight: 40 + color: connectCmdField.activeFocus ? Colours.layer(Colours.palette.m3surfaceContainer, 3) : Colours.layer(Colours.palette.m3surfaceContainer, 2) + radius: Tokens.rounding.small + border.width: 1 + border.color: connectCmdField.activeFocus ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3outline, 0.3) + + Behavior on color { + CAnim {} + } + Behavior on border.color { + CAnim {} + } + + StyledTextField { + id: connectCmdField + + anchors.centerIn: parent + width: parent.width - Tokens.padding.normal + horizontalAlignment: TextInput.AlignLeft + text: vpnDialog.connectCmd + onTextChanged: vpnDialog.connectCmd = text + } + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.smaller / 2 + visible: vpnDialog.editIndex >= 0 ? (vpnDialog.connectCmd.length > 0) : (vpnDialog.providerName === "custom") + + StyledText { + text: qsTr("Disconnect Command (e.g., wg-quick down wg0)") + font.pointSize: Tokens.font.size.small + color: Colours.palette.m3onSurfaceVariant + } + + StyledRect { + Layout.fillWidth: true + implicitHeight: 40 + color: disconnectCmdField.activeFocus ? Colours.layer(Colours.palette.m3surfaceContainer, 3) : Colours.layer(Colours.palette.m3surfaceContainer, 2) + radius: Tokens.rounding.small + border.width: 1 + border.color: disconnectCmdField.activeFocus ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3outline, 0.3) + + Behavior on color { + CAnim {} + } + Behavior on border.color { + CAnim {} + } + + StyledTextField { + id: disconnectCmdField + + anchors.centerIn: parent + width: parent.width - Tokens.padding.normal + horizontalAlignment: TextInput.AlignLeft + text: vpnDialog.disconnectCmd + onTextChanged: vpnDialog.disconnectCmd = text + } + } + } + RowLayout { - Layout.topMargin: Appearance.spacing.normal + Layout.topMargin: Tokens.spacing.normal Layout.fillWidth: true - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal TextButton { Layout.fillWidth: true @@ -647,40 +746,85 @@ ColumnLayout { TextButton { Layout.fillWidth: true text: qsTr("Save") - enabled: vpnDialog.interfaceName.length > 0 + enabled: { + const hasCommands = vpnDialog.connectCmd.length > 0 || vpnDialog.disconnectCmd.length > 0; + if (hasCommands) { + return vpnDialog.interfaceName.length > 0 && vpnDialog.connectCmd.length > 0 && vpnDialog.disconnectCmd.length > 0; + } + return vpnDialog.interfaceName.length > 0; + } inactiveColour: Colours.palette.m3primaryContainer inactiveOnColour: Colours.palette.m3onPrimaryContainer onClicked: { const providers = []; + const hasCommands = vpnDialog.connectCmd.length > 0 && vpnDialog.disconnectCmd.length > 0; const newProvider = { - name: vpnDialog.providerName, displayName: vpnDialog.displayName || vpnDialog.interfaceName, - interface: vpnDialog.interfaceName + enabled: false, + interface: vpnDialog.interfaceName, + name: vpnDialog.providerName }; + if (hasCommands) { + newProvider.connectCmd = vpnDialog.connectCmd.split(" ").filter(s => s.length > 0); + newProvider.disconnectCmd = vpnDialog.disconnectCmd.split(" ").filter(s => s.length > 0); + } + if (vpnDialog.editIndex >= 0) { - for (let i = 0; i < Config.utilities.vpn.provider.length; i++) { + const oldProvider = GlobalConfig.utilities.vpn.provider[vpnDialog.editIndex]; + if (typeof oldProvider === "object" && oldProvider.enabled !== undefined) { + newProvider.enabled = oldProvider.enabled; + } + + for (let i = 0; i < GlobalConfig.utilities.vpn.provider.length; i++) { if (i === vpnDialog.editIndex) { providers.push(newProvider); } else { - providers.push(Config.utilities.vpn.provider[i]); + providers.push(GlobalConfig.utilities.vpn.provider[i]); } } } else { - for (let i = 0; i < Config.utilities.vpn.provider.length; i++) { - providers.push(Config.utilities.vpn.provider[i]); + for (let i = 0; i < GlobalConfig.utilities.vpn.provider.length; i++) { + providers.push(GlobalConfig.utilities.vpn.provider[i]); } providers.push(newProvider); } - Config.utilities.vpn.provider = providers; - Config.save(); + GlobalConfig.utilities.vpn.provider = providers; vpnDialog.closeWithAnimation(); } } } } } + + SequentialAnimation { + id: transitionToForm + + ParallelAnimation { + Anim { + target: selectionContent + property: "opacity" + to: 0 + type: Anim.EmphasizedSmall + } + } + + ScriptAction { + script: { + vpnDialog.currentState = "form"; + } + } + + ParallelAnimation { + Anim { + target: formContent + property: "opacity" + to: 1 + type: Anim.EmphasizedSmall + } + } + } } } diff --git a/modules/controlcenter/network/VpnSettings.qml b/modules/controlcenter/network/VpnSettings.qml index 49d801d9a..064f32a12 100644 --- a/modules/controlcenter/network/VpnSettings.qml +++ b/modules/controlcenter/network/VpnSettings.qml @@ -2,23 +2,23 @@ pragma ComponentBehavior: Bound import ".." import "../components" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Caelestia.Config import qs.components -import qs.components.controls import qs.components.containers +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 - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal SettingsHeader { icon: "vpn_key" @@ -26,7 +26,7 @@ ColumnLayout { } SectionHeader { - Layout.topMargin: Appearance.spacing.large + Layout.topMargin: Tokens.spacing.large title: qsTr("General") description: qsTr("VPN configuration") } @@ -34,32 +34,31 @@ ColumnLayout { SectionContainer { ToggleRow { label: qsTr("VPN enabled") - checked: Config.utilities.vpn.enabled + checked: GlobalConfig.utilities.vpn.enabled toggle.onToggled: { - Config.utilities.vpn.enabled = checked; - Config.save(); + GlobalConfig.utilities.vpn.enabled = checked; } } } SectionHeader { - Layout.topMargin: Appearance.spacing.large + Layout.topMargin: Tokens.spacing.large title: qsTr("Providers") description: qsTr("Manage VPN providers") } SectionContainer { - contentSpacing: Appearance.spacing.normal + contentSpacing: Tokens.spacing.normal ListView { Layout.fillWidth: true Layout.preferredHeight: contentHeight interactive: false - spacing: Appearance.spacing.smaller + spacing: Tokens.spacing.smaller model: ScriptModel { - values: Config.utilities.vpn.provider.map((provider, index) => { + values: GlobalConfig.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; @@ -82,19 +81,20 @@ ColumnLayout { required property int index width: ListView.view ? ListView.view.width : undefined + implicitHeight: 60 color: Colours.tPalette.m3surfaceContainerHigh - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal RowLayout { anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - anchors.margins: Appearance.padding.normal - spacing: Appearance.spacing.normal + anchors.margins: Tokens.padding.normal + spacing: Tokens.spacing.normal MaterialIcon { text: modelData.isActive ? "vpn_key" : "vpn_key_off" - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large color: modelData.isActive ? Colours.palette.m3primary : Colours.palette.m3outline } @@ -109,53 +109,81 @@ ColumnLayout { StyledText { text: qsTr("%1 • %2").arg(modelData.name).arg(modelData.interface || qsTr("No interface")) - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small color: Colours.palette.m3outline } } IconButton { icon: modelData.isActive ? "arrow_downward" : "arrow_upward" - visible: !modelData.isActive || Config.utilities.vpn.provider.length > 1 + visible: !modelData.isActive || GlobalConfig.utilities.vpn.provider.length > 1 onClicked: { - if (modelData.isActive && index < Config.utilities.vpn.provider.length - 1) { + const providers = []; + for (let i = 0; i < GlobalConfig.utilities.vpn.provider.length; i++) { + const p = GlobalConfig.utilities.vpn.provider[i]; + const reconstructed = { + name: p.name, + displayName: p.displayName, + interface: p.interface, + enabled: p.enabled + }; + if (p.connectCmd && p.connectCmd.length > 0) { + reconstructed.connectCmd = p.connectCmd; + } + if (p.disconnectCmd && p.disconnectCmd.length > 0) { + reconstructed.disconnectCmd = p.disconnectCmd; + } + providers.push(reconstructed); + } + + if (modelData.isActive && index < providers.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(); } + + GlobalConfig.utilities.vpn.provider = providers; } } IconButton { icon: "delete" onClicked: { - const providers = [...Config.utilities.vpn.provider]; - providers.splice(index, 1); - Config.utilities.vpn.provider = providers; - Config.save(); + const providers = []; + for (let i = 0; i < GlobalConfig.utilities.vpn.provider.length; i++) { + if (i !== index) { + const p = GlobalConfig.utilities.vpn.provider[i]; + const reconstructed = { + name: p.name, + displayName: p.displayName, + interface: p.interface, + enabled: p.enabled + }; + if (p.connectCmd && p.connectCmd.length > 0) { + reconstructed.connectCmd = p.connectCmd; + } + if (p.disconnectCmd && p.disconnectCmd.length > 0) { + reconstructed.disconnectCmd = p.disconnectCmd; + } + providers.push(reconstructed); + } + } + GlobalConfig.utilities.vpn.provider = providers; } } } - - implicitHeight: 60 } } } TextButton { Layout.fillWidth: true - Layout.topMargin: Appearance.spacing.normal + Layout.topMargin: Tokens.spacing.normal text: qsTr("+ Add Provider") inactiveColour: Colours.palette.m3primaryContainer inactiveOnColour: Colours.palette.m3onPrimaryContainer @@ -167,13 +195,13 @@ ColumnLayout { } SectionHeader { - Layout.topMargin: Appearance.spacing.large + Layout.topMargin: Tokens.spacing.large title: qsTr("Quick Add") description: qsTr("Add common VPN providers") } SectionContainer { - contentSpacing: Appearance.spacing.smaller + contentSpacing: Tokens.spacing.smaller TextButton { Layout.fillWidth: true @@ -182,14 +210,13 @@ ColumnLayout { inactiveOnColour: Colours.palette.m3onSurface onClicked: { - const providers = [...Config.utilities.vpn.provider]; + const providers = [...GlobalConfig.utilities.vpn.provider]; providers.push({ name: "netbird", displayName: "NetBird", interface: "wt0" }); - Config.utilities.vpn.provider = providers; - Config.save(); + GlobalConfig.utilities.vpn.provider = providers; } } @@ -200,14 +227,13 @@ ColumnLayout { inactiveOnColour: Colours.palette.m3onSurface onClicked: { - const providers = [...Config.utilities.vpn.provider]; + const providers = [...GlobalConfig.utilities.vpn.provider]; providers.push({ name: "tailscale", displayName: "Tailscale", interface: "tailscale0" }); - Config.utilities.vpn.provider = providers; - Config.save(); + GlobalConfig.utilities.vpn.provider = providers; } } @@ -218,14 +244,13 @@ ColumnLayout { inactiveOnColour: Colours.palette.m3onSurface onClicked: { - const providers = [...Config.utilities.vpn.provider]; + const providers = [...GlobalConfig.utilities.vpn.provider]; providers.push({ name: "warp", displayName: "Cloudflare WARP", interface: "CloudflareWARP" }); - Config.utilities.vpn.provider = providers; - Config.save(); + GlobalConfig.utilities.vpn.provider = providers; } } } diff --git a/modules/controlcenter/network/WirelessDetails.qml b/modules/controlcenter/network/WirelessDetails.qml index e8777cdf7..e39744caa 100644 --- a/modules/controlcenter/network/WirelessDetails.qml +++ b/modules/controlcenter/network/WirelessDetails.qml @@ -3,15 +3,15 @@ pragma ComponentBehavior: Bound import ".." import "../components" import "." +import QtQuick +import QtQuick.Layouts +import Caelestia.Config import qs.components +import qs.components.containers import qs.components.controls import qs.components.effects -import qs.components.containers import qs.services -import qs.config import qs.utils -import QtQuick -import QtQuick.Layouts DeviceDetails { id: root @@ -19,66 +19,12 @@ DeviceDetails { required property Session session readonly property var network: root.session.network.active - device: network - - Component.onCompleted: { - updateDeviceDetails(); - checkSavedProfile(); - } - - onNetworkChanged: { - connectionUpdateTimer.stop(); - if (network && network.ssid) { - connectionUpdateTimer.start(); - } - updateDeviceDetails(); - checkSavedProfile(); - } - function checkSavedProfile(): void { if (network && network.ssid) { Nmcli.loadSavedConnections(() => {}); } } - Connections { - target: Nmcli - function onActiveChanged() { - updateDeviceDetails(); - } - function onWirelessDeviceDetailsChanged() { - if (network && network.ssid) { - const isActive = network.active || (Nmcli.active && Nmcli.active.ssid === network.ssid); - if (isActive && Nmcli.wirelessDeviceDetails && Nmcli.wirelessDeviceDetails !== null) { - connectionUpdateTimer.stop(); - } - } - } - } - - Timer { - id: connectionUpdateTimer - interval: 500 - repeat: true - running: network && network.ssid - onTriggered: { - if (network) { - const isActive = network.active || (Nmcli.active && Nmcli.active.ssid === network.ssid); - if (isActive) { - if (!Nmcli.wirelessDeviceDetails || Nmcli.wirelessDeviceDetails === null) { - Nmcli.getWirelessDeviceDetails("", () => {}); - } else { - connectionUpdateTimer.stop(); - } - } else { - if (Nmcli.wirelessDeviceDetails !== null) { - Nmcli.wirelessDeviceDetails = null; - } - } - } - } - } - function updateDeviceDetails(): void { if (network && network.ssid) { const isActive = network.active || (Nmcli.active && Nmcli.active.ssid === network.ssid); @@ -92,6 +38,22 @@ DeviceDetails { } } + 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" @@ -102,7 +64,7 @@ DeviceDetails { sections: [ Component { ColumnLayout { - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal SectionHeader { title: qsTr("Connection status") @@ -124,8 +86,8 @@ DeviceDetails { TextButton { Layout.fillWidth: true - Layout.topMargin: Appearance.spacing.normal - Layout.minimumHeight: Appearance.font.size.normal + Appearance.padding.normal * 2 + Layout.topMargin: Tokens.spacing.normal + Layout.minimumHeight: Tokens.font.size.normal + Tokens.padding.normal * 2 visible: { if (!root.network || !root.network.ssid) { return false; @@ -150,7 +112,7 @@ DeviceDetails { }, Component { ColumnLayout { - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal SectionHeader { title: qsTr("Network properties") @@ -158,7 +120,7 @@ DeviceDetails { } SectionContainer { - contentSpacing: Appearance.spacing.small / 2 + contentSpacing: Tokens.spacing.small / 2 PropertyRow { label: qsTr("SSID") @@ -193,7 +155,7 @@ DeviceDetails { }, Component { ColumnLayout { - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal SectionHeader { title: qsTr("Connection information") @@ -208,4 +170,44 @@ DeviceDetails { } } ] + + Connections { + function onActiveChanged() { + updateDeviceDetails(); + } + function onWirelessDeviceDetailsChanged() { + if (network && network.ssid) { + const isActive = network.active || (Nmcli.active && Nmcli.active.ssid === network.ssid); + if (isActive && Nmcli.wirelessDeviceDetails && Nmcli.wirelessDeviceDetails !== null) { + connectionUpdateTimer.stop(); + } + } + } + + target: Nmcli + } + + Timer { + id: connectionUpdateTimer + + interval: 500 + repeat: true + running: network && network.ssid + onTriggered: { + if (network) { + const isActive = network.active || (Nmcli.active && Nmcli.active.ssid === network.ssid); + if (isActive) { + if (!Nmcli.wirelessDeviceDetails || Nmcli.wirelessDeviceDetails === null) { + Nmcli.getWirelessDeviceDetails("", () => {}); + } else { + connectionUpdateTimer.stop(); + } + } else { + if (Nmcli.wirelessDeviceDetails !== null) { + Nmcli.wirelessDeviceDetails = null; + } + } + } + } + } } diff --git a/modules/controlcenter/network/WirelessList.qml b/modules/controlcenter/network/WirelessList.qml index 57a155fd7..b6acd0792 100644 --- a/modules/controlcenter/network/WirelessList.qml +++ b/modules/controlcenter/network/WirelessList.qml @@ -3,22 +3,28 @@ pragma ComponentBehavior: Bound import ".." import "../components" import "." +import QtQuick +import QtQuick.Layouts +import Quickshell +import Caelestia.Config import qs.components -import qs.components.controls import qs.components.containers +import qs.components.controls import qs.components.effects import qs.services -import qs.config import qs.utils -import Quickshell -import QtQuick -import QtQuick.Layouts DeviceList { id: root required property Session session + 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 @@ -28,7 +34,7 @@ DeviceList { visible: Nmcli.scanning text: qsTr("Scanning...") color: Colours.palette.m3primary - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small } } @@ -42,11 +48,11 @@ DeviceList { headerComponent: Component { RowLayout { - spacing: Appearance.spacing.smaller + spacing: Tokens.spacing.smaller StyledText { text: qsTr("Settings") - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large font.weight: 500 } @@ -58,9 +64,9 @@ DeviceList { toggled: Nmcli.wifiEnabled icon: "wifi" accent: "Tertiary" - iconSize: Appearance.font.size.normal - horizontalPadding: Appearance.padding.normal - verticalPadding: Appearance.padding.smaller + iconSize: Tokens.font.size.normal + horizontalPadding: Tokens.padding.normal + verticalPadding: Tokens.padding.smaller onClicked: { Nmcli.toggleWifi(null); @@ -71,9 +77,9 @@ DeviceList { toggled: Nmcli.scanning icon: "wifi_find" accent: "Secondary" - iconSize: Appearance.font.size.normal - horizontalPadding: Appearance.padding.normal - verticalPadding: Appearance.padding.smaller + iconSize: Tokens.font.size.normal + horizontalPadding: Tokens.padding.normal + verticalPadding: Tokens.padding.smaller onClicked: { Nmcli.rescanWifi(); @@ -84,9 +90,9 @@ DeviceList { toggled: !root.session.network.active icon: "settings" accent: "Primary" - iconSize: Appearance.font.size.normal - horizontalPadding: Appearance.padding.normal - verticalPadding: Appearance.padding.smaller + iconSize: Tokens.font.size.normal + horizontalPadding: Tokens.padding.normal + verticalPadding: Tokens.padding.smaller onClicked: { if (root.session.network.active) @@ -104,12 +110,13 @@ DeviceList { required property var modelData width: ListView.view ? ListView.view.width : undefined + implicitHeight: rowLayout.implicitHeight + Tokens.padding.normal * 2 color: Qt.alpha(Colours.tPalette.m3surfaceContainer, root.activeItem === modelData ? Colours.tPalette.m3surfaceContainer.a : 0) - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal StateLayer { - function onClicked(): void { + onClicked: { root.session.network.active = modelData; if (modelData && modelData.ssid) { root.checkSavedProfileForNetwork(modelData.ssid); @@ -123,15 +130,15 @@ DeviceList { anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - anchors.margins: Appearance.padding.normal + anchors.margins: Tokens.padding.normal - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal StyledRect { implicitWidth: implicitHeight - implicitHeight: icon.implicitHeight + Appearance.padding.normal * 2 + implicitHeight: icon.implicitHeight + Tokens.padding.normal * 2 - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal color: modelData.active ? Colours.palette.m3primaryContainer : Colours.tPalette.m3surfaceContainerHigh MaterialIcon { @@ -139,7 +146,7 @@ DeviceList { anchors.centerIn: parent text: Icons.getNetworkIcon(modelData.strength, modelData.isSecure) - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large fill: modelData.active ? 1 : 0 color: modelData.active ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface } @@ -160,7 +167,7 @@ DeviceList { RowLayout { Layout.fillWidth: true - spacing: Appearance.spacing.smaller + spacing: Tokens.spacing.smaller StyledText { Layout.fillWidth: true @@ -175,7 +182,7 @@ DeviceList { return qsTr("Open"); } color: modelData.active ? Colours.palette.m3primary : Colours.palette.m3outline - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small font.weight: modelData.active ? 500 : 400 elide: Text.ElideRight } @@ -184,13 +191,13 @@ DeviceList { StyledRect { implicitWidth: implicitHeight - implicitHeight: connectIcon.implicitHeight + Appearance.padding.smaller * 2 + implicitHeight: connectIcon.implicitHeight + Tokens.padding.smaller * 2 - radius: Appearance.rounding.full + radius: Tokens.rounding.full color: Qt.alpha(Colours.palette.m3primaryContainer, modelData.active ? 1 : 0) StateLayer { - function onClicked(): void { + onClicked: { if (modelData.active) { Nmcli.disconnectFromNetwork(); } else { @@ -208,8 +215,6 @@ DeviceList { } } } - - implicitHeight: rowLayout.implicitHeight + Appearance.padding.normal * 2 } } @@ -219,10 +224,4 @@ DeviceList { checkSavedProfileForNetwork(item.ssid); } } - - function checkSavedProfileForNetwork(ssid: string): void { - if (ssid && ssid.length > 0) { - Nmcli.loadSavedConnections(() => {}); - } - } } diff --git a/modules/controlcenter/network/WirelessPane.qml b/modules/controlcenter/network/WirelessPane.qml index 8150af9cf..436891765 100644 --- a/modules/controlcenter/network/WirelessPane.qml +++ b/modules/controlcenter/network/WirelessPane.qml @@ -2,11 +2,11 @@ pragma ComponentBehavior: Bound import ".." import "../components" +import QtQuick +import Quickshell.Widgets +import Caelestia.Config import qs.components import qs.components.containers -import qs.config -import Quickshell.Widgets -import QtQuick SplitPaneWithDetails { id: root diff --git a/modules/controlcenter/network/WirelessPasswordDialog.qml b/modules/controlcenter/network/WirelessPasswordDialog.qml index 7ad5204a4..ac88f9fca 100644 --- a/modules/controlcenter/network/WirelessPasswordDialog.qml +++ b/modules/controlcenter/network/WirelessPasswordDialog.qml @@ -2,16 +2,16 @@ pragma ComponentBehavior: Bound import ".." import "." +import QtQuick +import QtQuick.Layouts +import Quickshell +import Caelestia.Config import qs.components +import qs.components.containers import qs.components.controls import qs.components.effects -import qs.components.containers import qs.services -import qs.config import qs.utils -import Quickshell -import QtQuick -import QtQuick.Layouts Item { id: root @@ -29,6 +29,47 @@ Item { } 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 @@ -58,12 +99,13 @@ Item { anchors.centerIn: parent implicitWidth: 400 - implicitHeight: content.implicitHeight + Appearance.padding.large * 2 + implicitHeight: content.implicitHeight + Tokens.padding.large * 2 - radius: Appearance.rounding.normal + radius: Tokens.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 + Keys.onEscapePressed: closeDialog() Behavior on opacity { Anim {} @@ -94,28 +136,26 @@ Item { } } - Keys.onEscapePressed: closeDialog() - ColumnLayout { id: content anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - anchors.margins: Appearance.padding.large + anchors.margins: Tokens.padding.large - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal MaterialIcon { Layout.alignment: Qt.AlignHCenter text: "lock" - font.pointSize: Appearance.font.size.extraLarge * 2 + font.pointSize: Tokens.font.size.extraLarge * 2 } StyledText { Layout.alignment: Qt.AlignHCenter text: qsTr("Enter password") - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large font.weight: 500 } @@ -123,14 +163,14 @@ Item { Layout.alignment: Qt.AlignHCenter text: root.network ? qsTr("Network: %1").arg(root.network.ssid) : "" color: Colours.palette.m3outline - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small } StyledText { id: statusText Layout.alignment: Qt.AlignHCenter - Layout.topMargin: Appearance.spacing.small + Layout.topMargin: Tokens.spacing.small visible: connectButton.connecting || connectButton.hasError text: { if (connectButton.hasError) { @@ -142,24 +182,31 @@ Item { return ""; } color: connectButton.hasError ? Colours.palette.m3error : Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small font.weight: 400 wrapMode: Text.WordWrap - Layout.maximumWidth: parent.width - Appearance.padding.large * 2 + Layout.maximumWidth: parent.width - Tokens.padding.large * 2 } Item { id: passwordContainer - Layout.topMargin: Appearance.spacing.large - Layout.fillWidth: true - implicitHeight: Math.max(48, charList.implicitHeight + Appearance.padding.normal * 2) + property string passwordBuffer: "" + + Layout.topMargin: Tokens.spacing.large + Layout.fillWidth: true + implicitHeight: Math.max(48, charList.implicitHeight + Tokens.padding.normal * 2) focus: true Keys.onPressed: event => { if (!activeFocus) { forceActiveFocus(); } + if (event.key === Qt.Key_Escape) { + event.accepted = false; + closeDialog(); + } + if (connectButton.hasError && event.text && event.text.length > 0) { connectButton.hasError = false; } @@ -177,15 +224,16 @@ Item { } event.accepted = true; } else if (event.text && event.text.length > 0) { + if (event.key === Qt.Key_Tab) { + event.accepted = false; + return; + } passwordBuffer += event.text; event.accepted = true; } } - property string passwordBuffer: "" - Connections { - target: root.session.network function onShowPasswordDialogChanged(): void { if (root.session.network.showPasswordDialog) { Qt.callLater(() => { @@ -195,10 +243,11 @@ Item { }); } } + + target: root.session.network } Connections { - target: root function onVisibleChanged(): void { if (root.visible) { Qt.callLater(() => { @@ -206,11 +255,13 @@ Item { }); } } + + target: root } StyledRect { anchors.fill: parent - radius: Appearance.rounding.normal + radius: Tokens.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: { @@ -237,21 +288,22 @@ Item { } StateLayer { - hoverEnabled: false - cursorShape: Qt.IBeamCursor - - function onClicked(): void { + onClicked: { 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 + font.pointSize: Tokens.font.size.normal + font.family: Tokens.font.family.mono opacity: passwordContainer.passwordBuffer ? 0 : 1 Behavior on opacity { @@ -266,10 +318,10 @@ Item { anchors.centerIn: parent implicitWidth: fullWidth - implicitHeight: Appearance.font.size.normal + implicitHeight: Tokens.font.size.normal orientation: Qt.Horizontal - spacing: Appearance.spacing.small / 2 + spacing: Tokens.spacing.small / 2 interactive: false model: ScriptModel { @@ -283,7 +335,7 @@ Item { implicitHeight: charList.implicitHeight color: Colours.palette.m3onSurface - radius: Appearance.rounding.small / 2 + radius: Tokens.rounding.small / 2 opacity: 0 scale: 0 @@ -326,8 +378,7 @@ Item { Behavior on scale { Anim { - duration: Appearance.anim.durations.expressiveFastSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + type: Anim.FastSpatial } } } @@ -339,15 +390,15 @@ Item { } RowLayout { - Layout.topMargin: Appearance.spacing.normal + Layout.topMargin: Tokens.spacing.normal Layout.fillWidth: true - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal TextButton { id: cancelButton Layout.fillWidth: true - Layout.minimumHeight: Appearance.font.size.normal + Appearance.padding.normal * 2 + Layout.minimumHeight: Tokens.font.size.normal + Tokens.padding.normal * 2 inactiveColour: Colours.palette.m3secondaryContainer inactiveOnColour: Colours.palette.m3onSecondaryContainer text: qsTr("Cancel") @@ -362,7 +413,7 @@ Item { property bool hasError: false Layout.fillWidth: true - Layout.minimumHeight: Appearance.font.size.normal + Appearance.padding.normal * 2 + Layout.minimumHeight: Tokens.font.size.normal + Tokens.padding.normal * 2 inactiveColour: Colours.palette.m3primary inactiveOnColour: Colours.palette.m3onPrimary text: qsTr("Connect") @@ -414,40 +465,14 @@ Item { } } - function checkConnectionStatus(): void { - if (!root.visible || !connectButton.connecting) { - return; - } - - const isConnected = root.network && Nmcli.active && Nmcli.active.ssid && Nmcli.active.ssid.toLowerCase().trim() === root.network.ssid.toLowerCase().trim(); - - if (isConnected) { - connectionSuccessTimer.start(); - return; - } - - if (Nmcli.pendingConnection === null && connectButton.connecting) { - if (connectionMonitor.repeatCount > 10) { - connectionMonitor.stop(); - connectButton.connecting = false; - connectButton.hasError = true; - connectButton.enabled = true; - connectButton.text = qsTr("Connect"); - passwordContainer.passwordBuffer = ""; - if (root.network && root.network.ssid) { - Nmcli.forgetNetwork(root.network.ssid); - } - } - } - } - Timer { id: connectionMonitor + + property int repeatCount: 0 + interval: 1000 repeat: true triggeredOnStart: false - property int repeatCount: 0 - onTriggered: { repeatCount++; checkConnectionStatus(); @@ -462,6 +487,7 @@ Item { Timer { id: connectionSuccessTimer + interval: 500 onTriggered: { if (root.visible && Nmcli.active && Nmcli.active.ssid) { @@ -477,7 +503,6 @@ Item { } Connections { - target: Nmcli function onActiveChanged() { if (root.visible) { checkConnectionStatus(); @@ -494,18 +519,7 @@ Item { Nmcli.forgetNetwork(ssid); } } - } - - function closeDialog(): void { - if (isClosing) { - return; - } - isClosing = true; - passwordContainer.passwordBuffer = ""; - connectButton.connecting = false; - connectButton.hasError = false; - connectButton.text = qsTr("Connect"); - connectionMonitor.stop(); + target: Nmcli } } diff --git a/modules/controlcenter/network/WirelessSettings.qml b/modules/controlcenter/network/WirelessSettings.qml index b4eb391d4..f3f1fd4b7 100644 --- a/modules/controlcenter/network/WirelessSettings.qml +++ b/modules/controlcenter/network/WirelessSettings.qml @@ -2,20 +2,20 @@ pragma ComponentBehavior: Bound import ".." import "../components" +import QtQuick +import QtQuick.Layouts +import Caelestia.Config import qs.components import qs.components.controls import qs.components.effects import qs.services -import qs.config -import QtQuick -import QtQuick.Layouts ColumnLayout { id: root required property Session session - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal SettingsHeader { icon: "wifi" @@ -23,7 +23,7 @@ ColumnLayout { } SectionHeader { - Layout.topMargin: Appearance.spacing.large + Layout.topMargin: Tokens.spacing.large title: qsTr("WiFi status") description: qsTr("General WiFi settings") } @@ -39,13 +39,13 @@ ColumnLayout { } SectionHeader { - Layout.topMargin: Appearance.spacing.large + Layout.topMargin: Tokens.spacing.large title: qsTr("Network information") description: qsTr("Current network connection") } SectionContainer { - contentSpacing: Appearance.spacing.small / 2 + contentSpacing: Tokens.spacing.small / 2 PropertyRow { label: qsTr("Connected network") diff --git a/modules/controlcenter/notifications/NotificationsPane.qml b/modules/controlcenter/notifications/NotificationsPane.qml new file mode 100644 index 000000000..8f04bc3be --- /dev/null +++ b/modules/controlcenter/notifications/NotificationsPane.qml @@ -0,0 +1,607 @@ +pragma ComponentBehavior: Bound + +import ".." +import "../components" +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Caelestia.Config +import qs.components +import qs.components.containers +import qs.components.controls +import qs.components.effects +import qs.services + +Item { + id: root + + required property Session session + property string activeSection: "notifications" + + property bool notificationsExpire: GlobalConfig.notifs.expire ?? true + property string notificationsFullscreen: GlobalConfig.notifs.fullscreen ?? "on" + property bool notificationsOpenExpanded: Config.notifs.openExpanded ?? false + property int notificationsDefaultExpireTimeout: GlobalConfig.notifs.defaultExpireTimeout ?? 5000 + property int notificationsGroupPreviewNum: Config.notifs.groupPreviewNum ?? 3 + + property int maxToasts: Config.utilities.maxToasts ?? 4 + property string toastsFullscreen: Config.utilities.toasts.fullscreen ?? "off" + property bool chargingChanged: GlobalConfig.utilities.toasts.chargingChanged ?? true + property bool gameModeChanged: GlobalConfig.utilities.toasts.gameModeChanged ?? true + property bool dndChanged: GlobalConfig.utilities.toasts.dndChanged ?? true + property bool audioOutputChanged: GlobalConfig.utilities.toasts.audioOutputChanged ?? true + property bool audioInputChanged: GlobalConfig.utilities.toasts.audioInputChanged ?? true + property bool capsLockChanged: GlobalConfig.utilities.toasts.capsLockChanged ?? true + property bool numLockChanged: GlobalConfig.utilities.toasts.numLockChanged ?? true + property bool kbLayoutChanged: GlobalConfig.utilities.toasts.kbLayoutChanged ?? true + property bool vpnChanged: GlobalConfig.utilities.toasts.vpnChanged ?? true + property bool nowPlaying: GlobalConfig.utilities.toasts.nowPlaying ?? false + + readonly property var sections: [ + { + id: "notifications", + title: qsTr("Notifications"), + description: qsTr("Popup behavior"), + icon: "notifications" + }, + { + id: "toastSettings", + title: qsTr("Toast Settings"), + description: qsTr("Toast visibility"), + icon: "toast" + }, + { + id: "toastEvents", + title: qsTr("Toast Events"), + description: qsTr("Event triggers"), + icon: "rule" + } + ] + + function componentForSection(sectionId) { + switch (sectionId) { + case "toastSettings": + return toastSettingsComponent; + case "toastEvents": + return toastEventsComponent; + case "notifications": + default: + return notificationsComponent; + } + } + + function saveConfig(): void { + GlobalConfig.notifs.expire = root.notificationsExpire; + GlobalConfig.notifs.fullscreen = root.notificationsFullscreen; + GlobalConfig.notifs.openExpanded = root.notificationsOpenExpanded; + GlobalConfig.notifs.defaultExpireTimeout = root.notificationsDefaultExpireTimeout; + GlobalConfig.notifs.groupPreviewNum = root.notificationsGroupPreviewNum; + + GlobalConfig.utilities.maxToasts = root.maxToasts; + GlobalConfig.utilities.toasts.fullscreen = root.toastsFullscreen; + GlobalConfig.utilities.toasts.chargingChanged = root.chargingChanged; + GlobalConfig.utilities.toasts.gameModeChanged = root.gameModeChanged; + GlobalConfig.utilities.toasts.dndChanged = root.dndChanged; + GlobalConfig.utilities.toasts.audioOutputChanged = root.audioOutputChanged; + GlobalConfig.utilities.toasts.audioInputChanged = root.audioInputChanged; + GlobalConfig.utilities.toasts.capsLockChanged = root.capsLockChanged; + GlobalConfig.utilities.toasts.numLockChanged = root.numLockChanged; + GlobalConfig.utilities.toasts.kbLayoutChanged = root.kbLayoutChanged; + GlobalConfig.utilities.toasts.vpnChanged = root.vpnChanged; + GlobalConfig.utilities.toasts.nowPlaying = root.nowPlaying; + } + + anchors.fill: parent + + SplitPaneLayout { + anchors.fill: parent + leftWidthRatio: 0.32 + leftMinimumWidth: 300 + + leftContent: Component { + StyledFlickable { + id: leftFlickable + + flickableDirection: Flickable.VerticalFlick + contentHeight: leftContentLayout.height + + StyledScrollBar.vertical: StyledScrollBar { + flickable: leftFlickable + } + + ColumnLayout { + id: leftContentLayout + anchors.left: parent.left + anchors.right: parent.right + spacing: Tokens.spacing.normal + + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.smaller + + StyledText { + text: qsTr("Notifications") + font.pointSize: Tokens.font.size.large + font.weight: 500 + } + + Item { + Layout.fillWidth: true + } + } + + Repeater { + model: root.sections + + delegate: SectionNavButton { + required property var modelData + + Layout.fillWidth: true + section: modelData + active: root.activeSection === modelData.id + onClicked: root.activeSection = modelData.id + } + } + } + } + } + + rightContent: Component { + Item { + id: rightPaneItem + + property string paneId: root.activeSection + property Component targetComponent: root.componentForSection(root.activeSection) + property Component nextComponent: root.componentForSection(root.activeSection) + + onPaneIdChanged: { + nextComponent = root.componentForSection(root.activeSection); + } + + Loader { + id: rightLoader + anchors.fill: parent + asynchronous: true + opacity: 1 + scale: 1 + transformOrigin: Item.Center + sourceComponent: rightPaneItem.targetComponent + } + + Behavior on paneId { + PaneTransition { + target: rightLoader + propertyActions: [ + PropertyAction { + target: rightPaneItem + property: "targetComponent" + value: rightPaneItem.nextComponent + } + ] + } + } + } + } + } + + Component { + id: notificationsComponent + + SectionPage { + title: qsTr("Notifications") + subtitle: qsTr("Configure notification popup behavior.") + + SectionContainer { + Layout.fillWidth: true + alignTop: true + + SplitButtonRow { + id: notificationsFullscreenSelector + + function syncActiveItem(): void { + active = root.notificationsFullscreen === "off" ? notificationsFullscreenOffItem : notificationsFullscreenOnItem; + } + + label: qsTr("Show in fullscreen") + menuItems: [notificationsFullscreenOffItem, notificationsFullscreenOnItem] + + Component.onCompleted: syncActiveItem() + + Connections { + function onNotificationsFullscreenChanged(): void { + notificationsFullscreenSelector.syncActiveItem(); + } + + target: root + } + + MenuItem { + id: notificationsFullscreenOffItem + + text: qsTr("Off") + icon: "notifications_off" + activeText: qsTr("Off") + onClicked: { + root.notificationsFullscreen = "off"; + root.saveConfig(); + } + } + + MenuItem { + id: notificationsFullscreenOnItem + + text: qsTr("On") + icon: "notifications" + activeText: qsTr("On") + onClicked: { + root.notificationsFullscreen = "on"; + root.saveConfig(); + } + } + } + + SwitchRow { + label: qsTr("Expire automatically") + checked: root.notificationsExpire + onToggled: checked => { + root.notificationsExpire = checked; + root.saveConfig(); + } + } + + SwitchRow { + label: qsTr("Open expanded") + checked: root.notificationsOpenExpanded + onToggled: checked => { + root.notificationsOpenExpanded = checked; + root.saveConfig(); + } + } + + SpinBoxRow { + label: qsTr("Default timeout") + value: root.notificationsDefaultExpireTimeout + min: 1000 + max: 60000 + step: 500 + onValueModified: value => { + root.notificationsDefaultExpireTimeout = value; + root.saveConfig(); + } + } + + SpinBoxRow { + label: qsTr("Group preview count") + value: root.notificationsGroupPreviewNum + min: 1 + max: 10 + step: 1 + onValueModified: value => { + root.notificationsGroupPreviewNum = value; + root.saveConfig(); + } + } + } + } + } + + Component { + id: toastSettingsComponent + + SectionPage { + title: qsTr("Toast Settings") + subtitle: qsTr("Control when toast notifications are shown.") + + SectionContainer { + Layout.fillWidth: true + alignTop: true + + SplitButtonRow { + id: toastFullscreenSelector + + function syncActiveItem(): void { + if (root.toastsFullscreen === "all") { + active = toastFullscreenAllItem; + return; + } + + if (root.toastsFullscreen === "important") { + active = toastFullscreenImportantItem; + return; + } + + active = toastFullscreenOffItem; + } + + Layout.fillWidth: true + z: expanded ? 100 : 0 + label: qsTr("Show in fullscreen") + menuItems: [toastFullscreenOffItem, toastFullscreenImportantItem, toastFullscreenAllItem] + + Component.onCompleted: syncActiveItem() + Connections { + function onToastsFullscreenChanged(): void { + toastFullscreenSelector.syncActiveItem(); + } + + target: root + } + + MenuItem { + id: toastFullscreenOffItem + text: qsTr("Off") + icon: "notifications_off" + activeText: qsTr("Off") + onClicked: { + root.toastsFullscreen = "off"; + root.saveConfig(); + } + } + + MenuItem { + id: toastFullscreenImportantItem + text: qsTr("Important") + icon: "priority_high" + activeText: qsTr("Important") + onClicked: { + root.toastsFullscreen = "important"; + root.saveConfig(); + } + } + + MenuItem { + id: toastFullscreenAllItem + text: qsTr("On") + icon: "notifications" + activeText: qsTr("On") + onClicked: { + root.toastsFullscreen = "all"; + root.saveConfig(); + } + } + } + + SpinBoxRow { + Layout.fillWidth: true + label: qsTr("Visible toasts") + value: root.maxToasts + min: 1 + max: 10 + step: 1 + onValueModified: value => { + root.maxToasts = value; + root.saveConfig(); + } + } + } + } + } + + Component { + id: toastEventsComponent + + SectionPage { + title: qsTr("Toast Events") + subtitle: qsTr("Choose which system events create toasts.") + + SectionContainer { + Layout.fillWidth: true + alignTop: true + + GridLayout { + Layout.fillWidth: true + columns: 2 + columnSpacing: Tokens.spacing.normal + rowSpacing: Tokens.spacing.normal + + SwitchRow { + Layout.fillWidth: true + label: qsTr("Charging changes") + checked: root.chargingChanged + onToggled: checked => { + root.chargingChanged = checked; + root.saveConfig(); + } + } + + SwitchRow { + Layout.fillWidth: true + label: qsTr("Game mode changes") + checked: root.gameModeChanged + onToggled: checked => { + root.gameModeChanged = checked; + root.saveConfig(); + } + } + + SwitchRow { + Layout.fillWidth: true + label: qsTr("Do not disturb") + checked: root.dndChanged + onToggled: checked => { + root.dndChanged = checked; + root.saveConfig(); + } + } + + SwitchRow { + Layout.fillWidth: true + label: qsTr("Audio output changes") + checked: root.audioOutputChanged + onToggled: checked => { + root.audioOutputChanged = checked; + root.saveConfig(); + } + } + + SwitchRow { + Layout.fillWidth: true + label: qsTr("Audio input changes") + checked: root.audioInputChanged + onToggled: checked => { + root.audioInputChanged = checked; + root.saveConfig(); + } + } + + SwitchRow { + Layout.fillWidth: true + label: qsTr("Caps lock changes") + checked: root.capsLockChanged + onToggled: checked => { + root.capsLockChanged = checked; + root.saveConfig(); + } + } + + SwitchRow { + Layout.fillWidth: true + label: qsTr("Num lock changes") + checked: root.numLockChanged + onToggled: checked => { + root.numLockChanged = checked; + root.saveConfig(); + } + } + + SwitchRow { + Layout.fillWidth: true + label: qsTr("Keyboard layout changes") + checked: root.kbLayoutChanged + onToggled: checked => { + root.kbLayoutChanged = checked; + root.saveConfig(); + } + } + + SwitchRow { + Layout.fillWidth: true + label: qsTr("VPN changes") + checked: root.vpnChanged + onToggled: checked => { + root.vpnChanged = checked; + root.saveConfig(); + } + } + + SwitchRow { + Layout.fillWidth: true + label: qsTr("Now playing") + checked: root.nowPlaying + onToggled: checked => { + root.nowPlaying = checked; + root.saveConfig(); + } + } + } + } + } + } + + component SectionPage: StyledFlickable { + id: sectionPage + + required property string title + property string subtitle: "" + default property alias contentItems: contentLayout.data + + flickableDirection: Flickable.VerticalFlick + contentHeight: contentLayout.height + + StyledScrollBar.vertical: StyledScrollBar { + flickable: sectionPage + } + + ColumnLayout { + id: contentLayout + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + spacing: Tokens.spacing.normal + + StyledText { + Layout.fillWidth: true + text: sectionPage.title + font.pointSize: Tokens.font.size.extraLarge + font.weight: 600 + } + + StyledText { + Layout.fillWidth: true + Layout.bottomMargin: Tokens.spacing.small + text: sectionPage.subtitle + color: Colours.palette.m3outline + visible: text.length > 0 + wrapMode: Text.WordWrap + } + } + } + + component SectionNavButton: StyledRect { + id: navButton + + required property var section + property bool active: false + + signal clicked + + implicitHeight: navRow.implicitHeight + Tokens.padding.normal * 2 + color: active ? Colours.layer(Colours.palette.m3surfaceContainer, 2) : "transparent" + radius: Tokens.rounding.normal + + Behavior on color { + CAnim {} + } + + StateLayer { + onClicked: navButton.clicked() + } + + RowLayout { + id: navRow + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Tokens.padding.normal + spacing: Tokens.spacing.normal + + MaterialIcon { + Layout.alignment: Qt.AlignVCenter + text: navButton.section.icon + fill: navButton.active ? 1 : 0 + color: navButton.active ? Colours.palette.m3primary : Colours.palette.m3onSurfaceVariant + font.pointSize: Tokens.font.size.large + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + StyledText { + Layout.fillWidth: true + text: navButton.section.title + font.weight: navButton.active ? 500 : 400 + elide: Text.ElideRight + maximumLineCount: 1 + } + + StyledText { + Layout.fillWidth: true + text: navButton.section.description + color: Colours.palette.m3outline + font.pointSize: Tokens.font.size.small + elide: Text.ElideRight + maximumLineCount: 1 + } + } + + MaterialIcon { + Layout.alignment: Qt.AlignVCenter + text: "chevron_right" + opacity: navButton.active ? 1 : 0 + color: Colours.palette.m3primary + } + } + } +} diff --git a/modules/controlcenter/state/BluetoothState.qml b/modules/controlcenter/state/BluetoothState.qml index 8678672df..9b16bfe59 100644 --- a/modules/controlcenter/state/BluetoothState.qml +++ b/modules/controlcenter/state/BluetoothState.qml @@ -1,5 +1,5 @@ -import Quickshell.Bluetooth import QtQuick +import Quickshell.Bluetooth QtObject { id: root diff --git a/modules/controlcenter/state/MonitorState.qml b/modules/controlcenter/state/MonitorState.qml new file mode 100644 index 000000000..12e290b67 --- /dev/null +++ b/modules/controlcenter/state/MonitorState.qml @@ -0,0 +1,8 @@ +import QtQuick + +QtObject { + id: root + + // The currently selected HyprlandMonitor object (null = show settings overview) + property var active: null +} diff --git a/modules/controlcenter/taskbar/TaskbarPane.qml b/modules/controlcenter/taskbar/TaskbarPane.qml index d12d17449..6096ade77 100644 --- a/modules/controlcenter/taskbar/TaskbarPane.qml +++ b/modules/controlcenter/taskbar/TaskbarPane.qml @@ -2,24 +2,28 @@ pragma ComponentBehavior: Bound import ".." import "../components" +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Caelestia.Config import qs.components +import qs.components.containers import qs.components.controls import qs.components.effects -import qs.components.containers import qs.services -import qs.config import qs.utils -import Quickshell -import Quickshell.Widgets -import QtQuick -import QtQuick.Layouts Item { id: root required property Session session + property bool 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 @@ -38,56 +42,50 @@ Item { property bool workspacesActiveIndicator: Config.bar.workspaces.activeIndicator ?? true property bool workspacesOccupiedBg: Config.bar.workspaces.occupiedBg ?? false property bool workspacesShowWindows: Config.bar.workspaces.showWindows ?? false - property bool workspacesPerMonitor: Config.bar.workspaces.perMonitorWorkspaces ?? true + property int workspacesMaxWindowIcons: Config.bar.workspaces.maxWindowIcons ?? 0 + property bool workspacesPerMonitor: GlobalConfig.bar.workspaces.perMonitorWorkspaces ?? true property bool scrollWorkspaces: Config.bar.scrollActions.workspaces ?? true property bool scrollVolume: Config.bar.scrollActions.volume ?? true property bool scrollBrightness: Config.bar.scrollActions.brightness ?? true property bool popoutActiveWindow: Config.bar.popouts.activeWindow ?? true property bool popoutTray: Config.bar.popouts.tray ?? true property bool popoutStatusIcons: Config.bar.popouts.statusIcons ?? true - - anchors.fill: parent - - Component.onCompleted: { - if (Config.bar.entries) { - entriesModel.clear(); - for (let i = 0; i < Config.bar.entries.length; i++) { - const entry = Config.bar.entries[i]; - entriesModel.append({ - id: entry.id, - enabled: entry.enabled !== false - }); - } - } - } + property list monitorNames: Hypr.monitorNames() + property list excludedScreens: Config.bar.excludedScreens ?? [] function saveConfig(entryIndex, entryEnabled) { - Config.bar.clock.showIcon = root.clockShowIcon; - Config.bar.persistent = root.persistent; - Config.bar.showOnHover = root.showOnHover; - Config.bar.dragThreshold = root.dragThreshold; - Config.bar.status.showAudio = root.showAudio; - Config.bar.status.showMicrophone = root.showMicrophone; - Config.bar.status.showKbLayout = root.showKbLayout; - Config.bar.status.showNetwork = root.showNetwork; - Config.bar.status.showWifi = root.showWifi; - Config.bar.status.showBluetooth = root.showBluetooth; - Config.bar.status.showBattery = root.showBattery; - Config.bar.status.showLockStatus = root.showLockStatus; - Config.bar.tray.background = root.trayBackground; - Config.bar.tray.compact = root.trayCompact; - Config.bar.tray.recolour = root.trayRecolour; - Config.bar.workspaces.shown = root.workspacesShown; - Config.bar.workspaces.activeIndicator = root.workspacesActiveIndicator; - Config.bar.workspaces.occupiedBg = root.workspacesOccupiedBg; - Config.bar.workspaces.showWindows = root.workspacesShowWindows; - Config.bar.workspaces.perMonitorWorkspaces = root.workspacesPerMonitor; - Config.bar.scrollActions.workspaces = root.scrollWorkspaces; - Config.bar.scrollActions.volume = root.scrollVolume; - Config.bar.scrollActions.brightness = root.scrollBrightness; - Config.bar.popouts.activeWindow = root.popoutActiveWindow; - Config.bar.popouts.tray = root.popoutTray; - Config.bar.popouts.statusIcons = root.popoutStatusIcons; + GlobalConfig.bar.activeWindow.compact = root.activeWindowCompact; + GlobalConfig.bar.activeWindow.inverted = root.activeWindowInverted; + GlobalConfig.bar.clock.background = root.clockBackground; + GlobalConfig.bar.clock.showDate = root.clockShowDate; + GlobalConfig.bar.clock.showIcon = root.clockShowIcon; + GlobalConfig.bar.persistent = root.persistent; + GlobalConfig.bar.showOnHover = root.showOnHover; + GlobalConfig.bar.dragThreshold = root.dragThreshold; + GlobalConfig.bar.status.showAudio = root.showAudio; + GlobalConfig.bar.status.showMicrophone = root.showMicrophone; + GlobalConfig.bar.status.showKbLayout = root.showKbLayout; + GlobalConfig.bar.status.showNetwork = root.showNetwork; + GlobalConfig.bar.status.showWifi = root.showWifi; + GlobalConfig.bar.status.showBluetooth = root.showBluetooth; + GlobalConfig.bar.status.showBattery = root.showBattery; + GlobalConfig.bar.status.showLockStatus = root.showLockStatus; + GlobalConfig.bar.tray.background = root.trayBackground; + GlobalConfig.bar.tray.compact = root.trayCompact; + GlobalConfig.bar.tray.recolour = root.trayRecolour; + GlobalConfig.bar.workspaces.shown = root.workspacesShown; + GlobalConfig.bar.workspaces.activeIndicator = root.workspacesActiveIndicator; + GlobalConfig.bar.workspaces.occupiedBg = root.workspacesOccupiedBg; + GlobalConfig.bar.workspaces.showWindows = root.workspacesShowWindows; + GlobalConfig.bar.workspaces.maxWindowIcons = root.workspacesMaxWindowIcons; + GlobalConfig.bar.workspaces.perMonitorWorkspaces = root.workspacesPerMonitor; + GlobalConfig.bar.scrollActions.workspaces = root.scrollWorkspaces; + GlobalConfig.bar.scrollActions.volume = root.scrollVolume; + GlobalConfig.bar.scrollActions.brightness = root.scrollBrightness; + GlobalConfig.bar.popouts.activeWindow = root.popoutActiveWindow; + GlobalConfig.bar.popouts.tray = root.popoutTray; + GlobalConfig.bar.popouts.statusIcons = root.popoutStatusIcons; + GlobalConfig.bar.excludedScreens = root.excludedScreens; const entries = []; for (let i = 0; i < entriesModel.count; i++) { @@ -101,548 +99,606 @@ Item { enabled: enabled }); } - Config.bar.entries = entries; - Config.save(); + GlobalConfig.bar.entries = entries; + } + + 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 + readonly property var sections: [ + { id: "statusIcons", title: qsTr("Status Icons"), description: qsTr("Toggle bar status icons"), icon: "speaker" }, + { id: "workspaces", title: qsTr("Workspaces"), description: qsTr("Workspace display options"), icon: "grid_view" }, + { id: "scrollActions", title: qsTr("Scroll Actions"), description: qsTr("Mouse scroll bindings"), icon: "swap_vert" }, + { id: "clock", title: qsTr("Clock"), description: qsTr("Clock appearance"), icon: "schedule" }, + { id: "barBehavior", title: qsTr("Bar Behavior"), description: qsTr("Show/hide and drag"), icon: "view_agenda" }, + { id: "activeWindow", title: qsTr("Active Window"), description: qsTr("Active window style"), icon: "web_asset" }, + { id: "popouts", title: qsTr("Popouts"), description: qsTr("Floating popout visibility"), icon: "open_in_new" }, + { id: "traySettings", title: qsTr("Tray Settings"), description: qsTr("System tray appearance"), icon: "apps" }, + { id: "monitors", title: qsTr("Monitors"), description: qsTr("Per-monitor exclusions"), icon: "monitor" } + ] + + property string activeSection: "statusIcons" + + function componentForSection(sectionId) { + switch (sectionId) { + case "workspaces": return workspacesComponent; + case "scrollActions": return scrollActionsComponent; + case "clock": return clockComponent; + case "barBehavior": return barBehaviorComponent; + case "activeWindow": return activeWindowComponent; + case "popouts": return popoutsComponent; + case "traySettings": return traySettingsComponent; + case "monitors": return monitorsComponent; + case "statusIcons": + default: return statusIconsComponent; + } + } + + SplitPaneLayout { anchors.fill: parent - anchors.margins: Appearance.padding.normal - anchors.leftMargin: 0 - anchors.rightMargin: Appearance.padding.normal + leftWidthRatio: 0.32 + leftMinimumWidth: 300 - radius: taskbarBorder.innerRadius - color: "transparent" + leftContent: Component { + StyledFlickable { + id: leftFlickable - Loader { - id: taskbarLoader + flickableDirection: Flickable.VerticalFlick + contentHeight: leftContentLayout.height - anchors.fill: parent - anchors.margins: Appearance.padding.large + Appearance.padding.normal - anchors.leftMargin: Appearance.padding.large - anchors.rightMargin: Appearance.padding.large + StyledScrollBar.vertical: StyledScrollBar { + flickable: leftFlickable + } - sourceComponent: taskbarContentComponent - } - } + ColumnLayout { + id: leftContentLayout - InnerBorder { - id: taskbarBorder - leftThickness: 0 - rightThickness: Appearance.padding.normal - } + anchors.left: parent.left + anchors.right: parent.right + spacing: Tokens.spacing.normal - Component { - id: taskbarContentComponent + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.smaller + + StyledText { + text: qsTr("Taskbar") + font.pointSize: Tokens.font.size.large + font.weight: 500 + } + + Item { + Layout.fillWidth: true + } + } - StyledFlickable { - id: sidebarFlickable - flickableDirection: Flickable.VerticalFlick - contentHeight: sidebarLayout.height + Repeater { + model: root.sections - StyledScrollBar.vertical: StyledScrollBar { - flickable: sidebarFlickable + delegate: SectionNavButton { + required property var modelData + + Layout.fillWidth: true + section: modelData + active: root.activeSection === modelData.id + onClicked: root.activeSection = modelData.id + } + } + } } + } - ColumnLayout { - id: sidebarLayout - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top + rightContent: Component { + Item { + id: rightPaneItem - spacing: Appearance.spacing.normal + property string paneId: root.activeSection + property Component targetComponent: root.componentForSection(root.activeSection) + property Component nextComponent: root.componentForSection(root.activeSection) - RowLayout { - spacing: Appearance.spacing.smaller + onPaneIdChanged: { + nextComponent = root.componentForSection(root.activeSection); + } - StyledText { - text: qsTr("Taskbar") - font.pointSize: Appearance.font.size.large - font.weight: 500 + Loader { + id: rightLoader + + anchors.fill: parent + asynchronous: true + opacity: 1 + scale: 1 + transformOrigin: Item.Center + sourceComponent: rightPaneItem.targetComponent + } + + Behavior on paneId { + PaneTransition { + target: rightLoader + propertyActions: [ + PropertyAction { + target: rightPaneItem + property: "targetComponent" + value: rightPaneItem.nextComponent + } + ] } } + } + } + } + + // ── Section content components ──────────────────────────────────────────── + // Background rule: + // ConnectedButtonGroup & SwitchRow already carry their own bg card — + // no SectionContainer wrapper needed around them. + // SliderInput / CustomSpinBox have no bg — wrap in SectionContainer. + + Component { + id: statusIconsComponent + + SectionPage { + title: qsTr("Status Icons") + subtitle: qsTr("Toggle which icons appear in the status bar.") + + // ConnectedButtonGroup has its own bg — no SectionContainer needed + ConnectedButtonGroup { + Layout.fillWidth: true + rootItem: root + + options: [ + { label: qsTr("Speakers"), propertyName: "showAudio", onToggled: function(c) { root.showAudio = c; root.saveConfig(); } }, + { label: qsTr("Microphone"), propertyName: "showMicrophone", onToggled: function(c) { root.showMicrophone = c; root.saveConfig(); } }, + { label: qsTr("Keyboard"), propertyName: "showKbLayout", onToggled: function(c) { root.showKbLayout = c; root.saveConfig(); } }, + { label: qsTr("Network"), propertyName: "showNetwork", onToggled: function(c) { root.showNetwork = c; root.saveConfig(); } }, + { label: qsTr("Wifi"), propertyName: "showWifi", onToggled: function(c) { root.showWifi = c; root.saveConfig(); } }, + { label: qsTr("Bluetooth"), propertyName: "showBluetooth", onToggled: function(c) { root.showBluetooth = c; root.saveConfig(); } }, + { label: qsTr("Battery"), propertyName: "showBattery", onToggled: function(c) { root.showBattery = c; root.saveConfig(); } }, + { label: qsTr("Capslock"), propertyName: "showLockStatus", onToggled: function(c) { root.showLockStatus = c; root.saveConfig(); } } + ] + } + } + } + + Component { + id: workspacesComponent + + SectionPage { + title: qsTr("Workspaces") + subtitle: qsTr("Configure workspace display in the bar.") - SectionContainer { + // Spinbox rows have no bg — group in SectionContainer + SectionContainer { + Layout.fillWidth: true + + RowLayout { Layout.fillWidth: true - alignTop: true + spacing: Tokens.spacing.normal StyledText { - text: qsTr("Status Icons") - font.pointSize: Appearance.font.size.normal + Layout.fillWidth: true + text: qsTr("Shown") } - 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(); - } - } - ] + CustomSpinBox { + min: 1 + max: 20 + value: root.workspacesShown + onValueModified: v => { + root.workspacesShown = v; + root.saveConfig(); + } } } RowLayout { - id: mainRowLayout Layout.fillWidth: true - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal - ColumnLayout { - id: leftColumnLayout + StyledText { Layout.fillWidth: true - Layout.alignment: Qt.AlignTop - spacing: Appearance.spacing.normal + text: qsTr("Max window icons") + } - SectionContainer { - Layout.fillWidth: true - alignTop: true + CustomSpinBox { + min: 0 + max: 20 + value: root.workspacesMaxWindowIcons + onValueModified: v => { + root.workspacesMaxWindowIcons = v; + root.saveConfig(); + } + } + } + } - StyledText { - text: qsTr("Workspaces") - font.pointSize: Appearance.font.size.normal - } + // SwitchRow has its own bg — standalone cards + SwitchRow { + label: qsTr("Active indicator") + checked: root.workspacesActiveIndicator + onToggled: checked => { + root.workspacesActiveIndicator = checked; + root.saveConfig(); + } + } - 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(); - } - } - } - } + SwitchRow { + label: qsTr("Occupied background") + checked: root.workspacesOccupiedBg + onToggled: checked => { + root.workspacesOccupiedBg = checked; + 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(); - } - } - } - } + SwitchRow { + label: qsTr("Show windows") + checked: root.workspacesShowWindows + onToggled: checked => { + root.workspacesShowWindows = 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(); - } - } - } - } + SwitchRow { + label: qsTr("Per monitor workspaces") + checked: root.workspacesPerMonitor + onToggled: checked => { + root.workspacesPerMonitor = 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(); - } - } - } - } + Component { + id: scrollActionsComponent - 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(); - } - } - } - } - } + SectionPage { + title: qsTr("Scroll Actions") + subtitle: qsTr("Choose which actions mouse scrolling triggers.") - SectionContainer { - Layout.fillWidth: true - alignTop: true + ConnectedButtonGroup { + Layout.fillWidth: true + rootItem: root - StyledText { - text: qsTr("Scroll Actions") - font.pointSize: Appearance.font.size.normal - } + options: [ + { label: qsTr("Workspaces"), propertyName: "scrollWorkspaces", onToggled: function(c) { root.scrollWorkspaces = c; root.saveConfig(); } }, + { label: qsTr("Volume"), propertyName: "scrollVolume", onToggled: function(c) { root.scrollVolume = c; root.saveConfig(); } }, + { label: qsTr("Brightness"), propertyName: "scrollBrightness", onToggled: function(c) { root.scrollBrightness = c; root.saveConfig(); } } + ] + } + } + } - 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(); - } - } - ] - } - } + Component { + id: clockComponent + + SectionPage { + title: qsTr("Clock") + subtitle: qsTr("Adjust the clock appearance in the bar.") + + // SwitchRow has its own bg — no SectionContainer wrapper + 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(); + } + } + } + } + + Component { + id: barBehaviorComponent + + SectionPage { + title: qsTr("Bar Behavior") + subtitle: qsTr("Control when the bar appears and how drag reveal feels.") + + // SwitchRow has its own bg — standalone + 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(); + } + } + + // SliderInput has no bg — wrap in SectionContainer + SectionContainer { + Layout.fillWidth: true + contentSpacing: Tokens.spacing.normal + + SliderInput { + Layout.fillWidth: true + label: qsTr("Drag threshold") + value: root.dragThreshold + from: 0 + to: 100 + suffix: "px" + validator: IntValidator { + bottom: 0 + top: 100 } + formatValueFunction: val => Math.round(val).toString() + parseValueFunction: text => parseInt(text) + onValueModified: newValue => { + root.dragThreshold = Math.round(newValue); + root.saveConfig(); + } + } + } + } + } - ColumnLayout { - id: middleColumnLayout - Layout.fillWidth: true - Layout.alignment: Qt.AlignTop - spacing: Appearance.spacing.normal + Component { + id: activeWindowComponent + + SectionPage { + title: qsTr("Active Window") + subtitle: qsTr("Configure the active window button style.") + + SwitchRow { + label: qsTr("Compact") + checked: root.activeWindowCompact + onToggled: checked => { + root.activeWindowCompact = checked; + root.saveConfig(); + } + } - SectionContainer { - Layout.fillWidth: true - alignTop: true + SwitchRow { + label: qsTr("Inverted") + checked: root.activeWindowInverted + onToggled: checked => { + root.activeWindowInverted = checked; + root.saveConfig(); + } + } + } + } - StyledText { - text: qsTr("Clock") - font.pointSize: Appearance.font.size.normal - } + Component { + id: popoutsComponent + + SectionPage { + title: qsTr("Popouts") + subtitle: qsTr("Choose which bar elements float as popouts.") + + SwitchRow { + label: qsTr("Active window") + checked: root.popoutActiveWindow + onToggled: checked => { + root.popoutActiveWindow = checked; + root.saveConfig(); + } + } - SwitchRow { - label: qsTr("Show clock icon") - checked: root.clockShowIcon - onToggled: checked => { - root.clockShowIcon = checked; - root.saveConfig(); - } - } - } + SwitchRow { + label: qsTr("Tray") + checked: root.popoutTray + onToggled: checked => { + root.popoutTray = checked; + root.saveConfig(); + } + } - SectionContainer { - Layout.fillWidth: true - alignTop: true + SwitchRow { + label: qsTr("Status icons") + checked: root.popoutStatusIcons + onToggled: checked => { + root.popoutStatusIcons = checked; + root.saveConfig(); + } + } + } + } - StyledText { - text: qsTr("Bar Behavior") - font.pointSize: Appearance.font.size.normal - } + Component { + id: traySettingsComponent - SwitchRow { - label: qsTr("Persistent") - checked: root.persistent - onToggled: checked => { - root.persistent = checked; - root.saveConfig(); - } - } + SectionPage { + title: qsTr("Tray Settings") + subtitle: qsTr("Change the system tray presentation.") - SwitchRow { - label: qsTr("Show on hover") - checked: root.showOnHover - onToggled: checked => { - root.showOnHover = checked; - root.saveConfig(); - } - } + ConnectedButtonGroup { + Layout.fillWidth: true + rootItem: root - 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(); - } - } - } + options: [ + { label: qsTr("Background"), propertyName: "trayBackground", onToggled: function(c) { root.trayBackground = c; root.saveConfig(); } }, + { label: qsTr("Compact"), propertyName: "trayCompact", onToggled: function(c) { root.trayCompact = c; root.saveConfig(); } }, + { label: qsTr("Recolour"), propertyName: "trayRecolour", onToggled: function(c) { root.trayRecolour = c; root.saveConfig(); } } + ] + } + } + } + + Component { + id: monitorsComponent + + SectionPage { + title: qsTr("Monitors") + subtitle: qsTr("Choose which monitors display the bar.") + + ConnectedButtonGroup { + Layout.fillWidth: true + rootItem: root + rows: Math.ceil(root.monitorNames.length / 3) + + options: root.monitorNames.map(e => ({ + label: qsTr(e), + propertyName: "monitor" + e, + onToggled: function (_) { + let addedBack = excludedScreens.includes(e); + if (addedBack) { + const idx = excludedScreens.indexOf(e); + if (idx !== -1) + excludedScreens.splice(idx, 1); + } else { + if (!excludedScreens.includes(e)) + excludedScreens.push(e); } - } + root.saveConfig(); + }, + state: !Strings.testRegexList(root.excludedScreens, e) + })) + } + } + } - ColumnLayout { - id: rightColumnLayout - Layout.fillWidth: true - Layout.alignment: Qt.AlignTop - spacing: Appearance.spacing.normal + // ── Inline component definitions ────────────────────────────────────────── - SectionContainer { - Layout.fillWidth: true - alignTop: true + component SectionPage: StyledFlickable { + id: sectionPage - StyledText { - text: qsTr("Popouts") - font.pointSize: Appearance.font.size.normal - } + required property string title + property string subtitle: "" + default property alias contentItems: contentLayout.data - SwitchRow { - label: qsTr("Active window") - checked: root.popoutActiveWindow - onToggled: checked => { - root.popoutActiveWindow = checked; - root.saveConfig(); - } - } + flickableDirection: Flickable.VerticalFlick + contentHeight: contentLayout.height - SwitchRow { - label: qsTr("Tray") - checked: root.popoutTray - onToggled: checked => { - root.popoutTray = checked; - root.saveConfig(); - } - } + StyledScrollBar.vertical: StyledScrollBar { + flickable: sectionPage + } - SwitchRow { - label: qsTr("Status icons") - checked: root.popoutStatusIcons - onToggled: checked => { - root.popoutStatusIcons = checked; - root.saveConfig(); - } - } - } + ColumnLayout { + id: contentLayout - SectionContainer { - Layout.fillWidth: true - alignTop: true + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + spacing: Tokens.spacing.normal - StyledText { - text: qsTr("Tray Settings") - font.pointSize: Appearance.font.size.normal - } + StyledText { + Layout.fillWidth: true + text: sectionPage.title + font.pointSize: Tokens.font.size.extraLarge + font.weight: 600 + } - 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(); - } - } - ] - } - } - } + StyledText { + Layout.fillWidth: true + Layout.bottomMargin: Tokens.spacing.small + text: sectionPage.subtitle + color: Colours.palette.m3outline + visible: text.length > 0 + wrapMode: Text.WordWrap + } + } + } + + component SectionNavButton: StyledRect { + id: navButton + + required property var section + property bool active: false + + signal clicked + + implicitHeight: navRow.implicitHeight + Tokens.padding.normal * 2 + color: active ? Colours.layer(Colours.palette.m3surfaceContainer, 2) : "transparent" + radius: Tokens.rounding.normal + + Behavior on color { + CAnim {} + } + + StateLayer { + onClicked: navButton.clicked() + } + + RowLayout { + id: navRow + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Tokens.padding.normal + spacing: Tokens.spacing.normal + + MaterialIcon { + Layout.alignment: Qt.AlignVCenter + text: navButton.section.icon + fill: navButton.active ? 1 : 0 + color: navButton.active ? Colours.palette.m3primary : Colours.palette.m3onSurfaceVariant + font.pointSize: Tokens.font.size.large + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + StyledText { + Layout.fillWidth: true + text: navButton.section.title + font.weight: navButton.active ? 500 : 400 + elide: Text.ElideRight + maximumLineCount: 1 + } + + StyledText { + Layout.fillWidth: true + text: navButton.section.description + color: Colours.palette.m3outline + font.pointSize: Tokens.font.size.small + elide: Text.ElideRight + maximumLineCount: 1 } } + + MaterialIcon { + Layout.alignment: Qt.AlignVCenter + text: "chevron_right" + opacity: navButton.active ? 1 : 0 + color: Colours.palette.m3primary + } } } } diff --git a/modules/dashboard/Background.qml b/modules/dashboard/Background.qml deleted file mode 100644 index e2a91f741..000000000 --- a/modules/dashboard/Background.qml +++ /dev/null @@ -1,66 +0,0 @@ -import qs.components -import qs.services -import qs.config -import QtQuick -import QtQuick.Shapes - -ShapePath { - id: root - - required property Wrapper wrapper - readonly property real rounding: Config.border.rounding - readonly property bool flatten: wrapper.height < rounding * 2 - readonly property real roundingY: flatten ? wrapper.height / 2 : rounding - - strokeWidth: -1 - fillColor: Colours.palette.m3surface - - PathArc { - relativeX: root.rounding - relativeY: root.roundingY - radiusX: root.rounding - radiusY: Math.min(root.rounding, root.wrapper.height) - } - - PathLine { - relativeX: 0 - relativeY: root.wrapper.height - root.roundingY * 2 - } - - PathArc { - relativeX: root.rounding - relativeY: root.roundingY - radiusX: root.rounding - radiusY: Math.min(root.rounding, root.wrapper.height) - direction: PathArc.Counterclockwise - } - - PathLine { - relativeX: root.wrapper.width - root.rounding * 2 - relativeY: 0 - } - - PathArc { - relativeX: root.rounding - relativeY: -root.roundingY - radiusX: root.rounding - radiusY: Math.min(root.rounding, root.wrapper.height) - direction: PathArc.Counterclockwise - } - - PathLine { - relativeX: 0 - relativeY: -(root.wrapper.height - root.roundingY * 2) - } - - PathArc { - relativeX: root.rounding - relativeY: -root.roundingY - radiusX: root.rounding - radiusY: Math.min(root.rounding, root.wrapper.height) - } - - Behavior on fillColor { - CAnim {} - } -} diff --git a/modules/dashboard/Content.qml b/modules/dashboard/Content.qml index 1cc960a47..95d2a08a7 100644 --- a/modules/dashboard/Content.qml +++ b/modules/dashboard/Content.qml @@ -1,19 +1,59 @@ pragma ComponentBehavior: Bound -import qs.components -import qs.components.filedialog -import qs.config -import Quickshell -import Quickshell.Widgets import QtQuick import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Caelestia.Config +import qs.components +import qs.components.filedialog Item { id: root - required property PersistentProperties visibilities - required property PersistentProperties state + 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 dashState 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 @@ -26,11 +66,12 @@ Item { anchors.top: parent.top anchors.left: parent.left anchors.right: parent.right - anchors.topMargin: Appearance.padding.normal - anchors.margins: Appearance.padding.large + anchors.topMargin: Tokens.padding.normal + anchors.margins: Tokens.padding.large nonAnimWidth: root.nonAnimWidth - anchors.margins * 2 - state: root.state + dashState: root.dashState + tabs: root.dashboardTabs } ClippingRectangle { @@ -40,79 +81,116 @@ Item { anchors.left: parent.left anchors.right: parent.right anchors.bottom: parent.bottom - anchors.margins: Appearance.padding.large + anchors.margins: Tokens.padding.large - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal color: "transparent" Flickable { id: view - readonly property int currentIndex: root.state.currentTab - readonly property Item currentItem: row.children[currentIndex] + readonly property int currentIndex: root.dashState.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 - implicitHeight: currentItem.implicitHeight + implicitWidth: currentItem?.implicitWidth ?? 0 + implicitHeight: currentItem?.implicitHeight ?? 0 - contentX: currentItem.x + contentX: currentItem?.x ?? 0 contentWidth: row.implicitWidth contentHeight: row.implicitHeight onContentXChanged: { - if (!moving) + 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); + root.dashState.currentTab = Math.min(root.dashState.currentTab + 1, tabs.count - 1); else if (x < -currentItem.implicitWidth / 2) - root.state.currentTab = Math.max(root.state.currentTab - 1, 0); + root.dashState.currentTab = Math.max(root.dashState.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); + root.dashState.currentTab = Math.min(root.dashState.currentTab + 1, tabs.count - 1); else if (x < -currentItem.implicitWidth / 10) - root.state.currentTab = Math.max(root.state.currentTab - 1, 0); + root.dashState.currentTab = Math.max(root.dashState.currentTab - 1, 0); else - contentX = Qt.binding(() => currentItem.x); + contentX = Qt.binding(() => currentItem?.x ?? 0); } RowLayout { id: row - Pane { - index: 0 - sourceComponent: Dash { - visibilities: root.visibilities - state: root.state - facePicker: root.facePicker + Repeater { + id: repeater + + model: ScriptModel { + values: root.dashboardTabs } - } - Pane { - index: 1 - sourceComponent: Media { - visibilities: root.visibilities + 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); + }) } } + } - Pane { - index: 2 - sourceComponent: Performance {} + Component { + id: dashComponent + + Dash { + visibilities: root.visibilities + dashState: root.dashState + facePicker: root.facePicker } + } + + Component { + id: mediaComponent - Pane { - index: 3 - sourceComponent: Weather {} + MediaWrapper { + visibilities: root.visibilities } } + Component { + id: performanceComponent + + Performance {} + } + + Component { + id: weatherComponent + + WeatherTab {} + } + Behavior on contentX { Anim {} } @@ -121,32 +199,13 @@ Item { Behavior on implicitWidth { Anim { - duration: Appearance.anim.durations.large - easing.bezierCurve: Appearance.anim.curves.emphasized + type: Anim.EmphasizedLarge } } Behavior on implicitHeight { Anim { - duration: Appearance.anim.durations.large - easing.bezierCurve: Appearance.anim.curves.emphasized + type: Anim.EmphasizedLarge } } - - component Pane: Loader { - id: pane - - required property int index - - Layout.alignment: Qt.AlignTop - - Component.onCompleted: active = Qt.binding(() => { - // Always keep current tab loaded - if (pane.index === view.currentIndex) - return true; - const vx = Math.floor(view.visibleArea.xPosition * view.contentWidth); - const vex = Math.floor(vx + view.visibleArea.widthRatio * view.contentWidth); - return (vx >= x && vx <= x + implicitWidth) || (vex >= x && vex <= x + implicitWidth); - }) - } } diff --git a/modules/dashboard/Dash.qml b/modules/dashboard/Dash.qml index 71e224fbe..6785ede8a 100644 --- a/modules/dashboard/Dash.qml +++ b/modules/dashboard/Dash.qml @@ -1,20 +1,19 @@ +import "dash" +import QtQuick.Layouts +import Caelestia.Config import qs.components import qs.components.filedialog import qs.services -import qs.config -import "dash" -import Quickshell -import QtQuick.Layouts GridLayout { id: root - required property PersistentProperties visibilities - required property PersistentProperties state + required property DrawerVisibilities visibilities + required property DashboardState dashState required property FileDialog facePicker - rowSpacing: Appearance.spacing.normal - columnSpacing: Appearance.spacing.normal + rowSpacing: Tokens.spacing.normal + columnSpacing: Tokens.spacing.normal Rect { Layout.column: 2 @@ -22,13 +21,12 @@ GridLayout { Layout.preferredWidth: user.implicitWidth Layout.preferredHeight: user.implicitHeight - radius: Appearance.rounding.large + radius: Tokens.rounding.large User { id: user visibilities: root.visibilities - state: root.state facePicker: root.facePicker } } @@ -36,12 +34,12 @@ GridLayout { Rect { Layout.row: 0 Layout.columnSpan: 2 - Layout.preferredWidth: Config.dashboard.sizes.weatherWidth + Layout.preferredWidth: Tokens.sizes.dashboard.weatherWidth Layout.fillHeight: true - radius: Appearance.rounding.large * 1.5 + radius: Tokens.rounding.large * 1.5 - Weather {} + SmallWeather {} } Rect { @@ -49,7 +47,7 @@ GridLayout { Layout.preferredWidth: dateTime.implicitWidth Layout.fillHeight: true - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal DateTime { id: dateTime @@ -63,12 +61,12 @@ GridLayout { Layout.fillWidth: true Layout.preferredHeight: calendar.implicitHeight - radius: Appearance.rounding.large + radius: Tokens.rounding.large Calendar { id: calendar - state: root.state + dashState: root.dashState } } @@ -78,7 +76,7 @@ GridLayout { Layout.preferredWidth: resources.implicitWidth Layout.fillHeight: true - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal Resources { id: resources @@ -92,7 +90,7 @@ GridLayout { Layout.preferredWidth: media.implicitWidth Layout.fillHeight: true - radius: Appearance.rounding.large * 2 + radius: Tokens.rounding.large * 2 Media { id: media diff --git a/modules/dashboard/LyricMenu.qml b/modules/dashboard/LyricMenu.qml new file mode 100644 index 000000000..1e5461f56 --- /dev/null +++ b/modules/dashboard/LyricMenu.qml @@ -0,0 +1,412 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.components.controls +import qs.services + +StyledRect { + id: root + + required property real contentHeight + + function searchCandidates(title, artist) { + LyricsService.currentRequestId++; + LyricsService.fetchNetEaseCandidates(title, artist, LyricsService.currentRequestId); + } + + implicitHeight: contentHeight + + radius: Tokens.rounding.large + color: Colours.tPalette.m3surfaceContainer + + Loader { + asynchronous: true + anchors.fill: parent + active: root.height > 0 + + sourceComponent: ColumnLayout { + anchors.fill: parent + anchors.margins: Tokens.padding.large + spacing: Tokens.spacing.normal + + // Header: icon, backend selector, refresh, toggle + RowLayout { + Layout.fillWidth: true + spacing: Tokens.padding.small + + MaterialIcon { + text: "lyrics" + fill: 1 + color: Colours.palette.m3primary + font.pointSize: Tokens.spacing.large + } + + Rectangle { + Layout.preferredHeight: 24 + Layout.preferredWidth: 80 + radius: Tokens.rounding.small + color: Qt.rgba(Colours.palette.m3primary.r, Colours.palette.m3primary.g, Colours.palette.m3primary.b, 0.15) + + StyledText { + anchors.centerIn: parent + text: LyricsService.preferredBackend + font.pointSize: Tokens.font.size.small + color: Colours.palette.m3primary + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + const backends = ["Auto", "Local", "NetEase"]; + const currentIndex = backends.indexOf(LyricsService.preferredBackend); + const nextIndex = (currentIndex + 1) % backends.length; + LyricsService.preferredBackend = backends[nextIndex]; + LyricsService.loadLyrics(); + } + } + } + + Rectangle { + Layout.preferredHeight: 24 + Layout.preferredWidth: 60 + radius: Tokens.rounding.small + visible: LyricsService.preferredBackend === "Auto" + color: LyricsService.backend === "Local" ? Qt.rgba(Colours.palette.m3tertiary.r, Colours.palette.m3tertiary.g, Colours.palette.m3tertiary.b, 0.15) : Qt.rgba(Colours.palette.m3secondary.r, Colours.palette.m3secondary.g, Colours.palette.m3secondary.b, 0.15) + + StyledText { + anchors.centerIn: parent + text: LyricsService.backend + font.pointSize: Tokens.font.size.small + color: LyricsService.backend === "Local" ? Colours.palette.m3tertiary : Colours.palette.m3secondary + } + } + + Item { + Layout.fillWidth: true + } + + IconButton { + icon: "refresh" + type: IconButton.Text + onClicked: LyricsService.loadLyrics() + } + + StyledSwitch { + checked: LyricsService.lyricsVisible + onToggled: LyricsService.toggleVisibility() + } + } + + StyledText { + Layout.fillWidth: true + text: LyricsService.preferredBackend === "Local" ? "Loaded File:" : "Fetched Candidates:" + color: Colours.palette.m3outline + font.pointSize: Tokens.font.size.small + elide: Text.ElideRight + visible: LyricsService.preferredBackend === "Local" ? LyricsService.loadedLocalFile.length > 0 : LyricsService.candidatesModel.count > 0 + } + + // Local file info (shown in Local mode) + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 48 + visible: LyricsService.preferredBackend === "Local" && LyricsService.loadedLocalFile.length > 0 + radius: Tokens.rounding.small + color: Qt.rgba(Colours.palette.m3tertiary.r, Colours.palette.m3tertiary.g, Colours.palette.m3tertiary.b, 0.1) + + ColumnLayout { + anchors.fill: parent + anchors.margins: Tokens.padding.small + spacing: 0 + + StyledText { + Layout.fillWidth: true + text: { + const path = LyricsService.loadedLocalFile; + const parts = path.split('/'); + return parts[parts.length - 1]; + } + font.pointSize: Tokens.font.size.small + color: Colours.palette.m3tertiary + elide: Text.ElideMiddle + } + + StyledText { + Layout.fillWidth: true + text: { + const path = LyricsService.loadedLocalFile; + const parts = path.split('/'); + if (parts.length > 2) { + return parts.slice(-3, -1).join('/'); + } + return ""; + } + font.pointSize: Tokens.font.size.small + color: Colours.palette.m3outline + elide: Text.ElideMiddle + } + } + } + + // Candidates list + Loader { + Layout.fillWidth: true + Layout.fillHeight: true + + active: LyricsService.preferredBackend !== "Local" + + sourceComponent: ListView { + id: candidatesView + + model: LyricsService.candidatesModel + clip: true + spacing: Tokens.spacing.small + visible: LyricsService.candidatesModel.count > 0 + opacity: visible ? 1 : 0 + + 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: Tokens.anim.durations.small + easing.type: Easing.OutCubic + } + } + + Rectangle { + id: background + + anchors.fill: parent + radius: Tokens.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: Tokens.anim.durations.small + } + } + Behavior on border.width { + NumberAnimation { + duration: Tokens.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: Tokens.padding.normal + spacing: Tokens.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: Tokens.anim.durations.small + } + } + } + + Column { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - 30 + spacing: 4 + + Text { + text: delegateRoot.title + font.pointSize: Tokens.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: Tokens.anim.durations.small + } + } + } + + Text { + text: delegateRoot.artist + font.pointSize: Tokens.font.size.small + color: Colours.palette.m3onSurfaceVariant + elide: Text.ElideRight + } + } + } + } + } + } + + Item { + Layout.fillHeight: true + visible: LyricsService.candidatesModel.count == 0 && LyricsService.preferredBackend !== "Local" + } + + // Manual search + ColumnLayout { + Layout.fillWidth: true + spacing: Tokens.padding.small + + StyledText { + Layout.fillWidth: true + text: "Manual Search" + font.pointSize: Tokens.font.size.small + color: Colours.palette.m3onSurfaceVariant + elide: Text.ElideRight + } + + RowLayout { + Layout.fillWidth: true + spacing: Tokens.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: Tokens.padding.small + + MaterialIcon { + text: "contrast_square" + font.pointSize: Tokens.font.size.large + color: Colours.palette.m3secondary + } + + StyledText { + text: "Offset" + color: Colours.palette.m3outline + font.pointSize: Tokens.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: Tokens.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(); + } + } + } + } + } +} diff --git a/modules/dashboard/LyricsView.qml b/modules/dashboard/LyricsView.qml new file mode 100644 index 000000000..c0188304d --- /dev/null +++ b/modules/dashboard/LyricsView.qml @@ -0,0 +1,112 @@ +import QtQuick +import QtQuick.Effects +import Quickshell +import Caelestia.Config +import qs.components +import qs.components.containers +import qs.services + +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 : Tokens.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 + Tokens.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: Tokens.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: Tokens.anim.durations.small + } + } + Behavior on scale { + Anim { + type: Anim.StandardSmall + } + } + } + } + + Timer { + id: hideTimer + + interval: 300 // long enough to bridge the track switch gap + running: false + repeat: false + } +} diff --git a/modules/dashboard/Media.qml b/modules/dashboard/Media.qml index 37d12263a..367d9e98a 100644 --- a/modules/dashboard/Media.qml +++ b/modules/dashboard/Media.qml @@ -1,27 +1,33 @@ pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import QtQuick.Shapes +import Quickshell +import Quickshell.Services.Mpris +import Caelestia.Config +import Caelestia.Services import qs.components -import qs.components.effects import qs.components.controls import qs.services import qs.utils -import qs.config -import Caelestia.Services -import Quickshell -import Quickshell.Widgets -import Quickshell.Services.Mpris -import QtQuick -import QtQuick.Layouts -import QtQuick.Shapes Item { id: root - required property PersistentProperties visibilities + required property DrawerVisibilities visibilities + readonly property bool needsKeyboard: lyricMenuOpen + + readonly property real nonAnimHeight: Math.max(cover.implicitHeight + Tokens.sizes.dashboard.mediaVisualiserSize * 2, lyricMenuOpen ? lyricMenu.implicitHeight : details.implicitHeight, bongocat.implicitHeight) + Tokens.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; + return active?.length ? (active.position % active.length) / active.length : 0; } function lengthStr(length: int): string { @@ -37,21 +43,54 @@ Item { return `${mins}:${secs}`; } - implicitWidth: cover.implicitWidth + Config.dashboard.sizes.mediaVisualiserSize * 2 + details.implicitWidth + details.anchors.leftMargin + bongocat.implicitWidth + bongocat.anchors.leftMargin * 2 + Appearance.padding.large * 2 - implicitHeight: Math.max(cover.implicitHeight + Config.dashboard.sizes.mediaVisualiserSize * 2, details.implicitHeight, bongocat.implicitHeight) + Appearance.padding.large * 2 + onLyricsShowingChanged: { + if (lyricsShowing) { + lyricsHideDelay.stop(); + lyricsShowingDebounced = true; + } else { + lyricsHideDelay.restart(); + } + } + + implicitWidth: cover.implicitWidth + Tokens.sizes.dashboard.mediaVisualiserSize * 2 + details.implicitWidth + details.anchors.leftMargin + bongocat.implicitWidth + bongocat.anchors.leftMargin * 2 + Tokens.padding.large * 2 + implicitHeight: nonAnimHeight + + Behavior on implicitHeight { + Anim {} + } Behavior on playerProgress { Anim { - duration: Appearance.anim.durations.large + type: Anim.StandardLarge } } Timer { running: Players.active?.isPlaying ?? false - interval: Config.dashboard.mediaUpdateInterval + interval: GlobalConfig.dashboard.mediaUpdateInterval triggeredOnStart: true repeat: true - onTriggered: Players.active?.positionChanged() + 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 { @@ -67,12 +106,12 @@ Item { 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 + readonly property real innerX: cover.implicitWidth / 2 + Tokens.spacing.small + readonly property real innerY: cover.implicitHeight / 2 + Tokens.spacing.small property color colour: Colours.palette.m3primary anchors.fill: cover - anchors.margins: -Config.dashboard.sizes.mediaVisualiserSize + anchors.margins: -Tokens.sizes.dashboard.mediaVisualiserSize asynchronous: true preferredRendererType: Shape.CurveRenderer @@ -83,7 +122,7 @@ Item { id: visualiserBars model: Array.from({ - length: Config.services.visualiserBars + length: GlobalConfig.services.visualiserBars }, (_, i) => i) ShapePath { @@ -92,13 +131,13 @@ Item { 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 angle: modelData * 2 * Math.PI / GlobalConfig.services.visualiserBars + readonly property real magnitude: value * root.Tokens.sizes.dashboard.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 + capStyle: root.Tokens.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap + strokeWidth: 360 / GlobalConfig.services.visualiserBars - root.Tokens.spacing.small / 4 strokeColor: Colours.palette.m3primary startX: visualiser.centerX + (visualiser.innerX + strokeWidth / 2) * cos @@ -120,10 +159,10 @@ Item { anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left - anchors.leftMargin: Appearance.padding.large + Config.dashboard.sizes.mediaVisualiserSize + anchors.leftMargin: Tokens.padding.large + Tokens.sizes.dashboard.mediaVisualiserSize - implicitWidth: Config.dashboard.sizes.mediaCoverArtSize - implicitHeight: Config.dashboard.sizes.mediaCoverArtSize + implicitWidth: Tokens.sizes.dashboard.mediaCoverArtSize + implicitHeight: Tokens.sizes.dashboard.mediaCoverArtSize color: Colours.tPalette.m3surfaceContainerHigh radius: Infinity @@ -142,11 +181,20 @@ Item { anchors.fill: parent - source: Players.active?.trackArtUrl ?? "" + source: Players.getArtUrl(Players.active) asynchronous: true fillMode: Image.PreserveAspectCrop - sourceSize.width: width - sourceSize.height: height + sourceSize: { + const dpr = (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1; + return Qt.size(width * dpr, height * dpr); + } + + MouseArea { + anchors.fill: parent + onClicked: { + LyricsService.toggleVisibility(); + } + } } } @@ -155,9 +203,9 @@ Item { anchors.verticalCenter: parent.verticalCenter anchors.left: visualiser.right - anchors.leftMargin: Appearance.spacing.normal + anchors.leftMargin: Tokens.spacing.normal - spacing: Appearance.spacing.small + spacing: Tokens.spacing.small StyledText { id: title @@ -169,7 +217,7 @@ Item { 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 + font.pointSize: Tokens.font.size.normal elide: Text.ElideRight } @@ -184,7 +232,7 @@ Item { visible: !!Players.active text: Players.active?.trackAlbum || qsTr("Unknown album") color: Colours.palette.m3outline - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small elide: Text.ElideRight } @@ -202,19 +250,34 @@ Item { 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 + Layout.topMargin: Tokens.spacing.small + Layout.bottomMargin: Tokens.spacing.smaller - spacing: Appearance.spacing.small + spacing: Tokens.spacing.small + + PlayerControl { + type: IconButton.Text + icon: Players.active?.shuffle ? "shuffle_on" : "shuffle" + font.pointSize: Math.round(Tokens.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) + font.pointSize: Math.round(Tokens.font.size.large * 1.5) disabled: !Players.active?.canGoPrevious onClicked: Players.active?.previous() } @@ -223,9 +286,9 @@ Item { icon: Players.active?.isPlaying ? "pause" : "play_arrow" label.animate: true toggle: true - padding: Appearance.padding.small / 2 + padding: Tokens.padding.small / 2 checked: Players.active?.isPlaying ?? false - font.pointSize: Math.round(Appearance.font.size.large * 1.5) + font.pointSize: Math.round(Tokens.font.size.large * 1.5) disabled: !Players.active?.canTogglePlaying onClicked: Players.active?.togglePlaying() } @@ -233,10 +296,17 @@ Item { PlayerControl { type: IconButton.Text icon: "skip_next" - font.pointSize: Math.round(Appearance.font.size.large * 1.5) + font.pointSize: Math.round(Tokens.font.size.large * 1.5) disabled: !Players.active?.canGoNext onClicked: Players.active?.next() } + + PlayerControl { + type: IconButton.Text + icon: "lyrics" + font.pointSize: Math.round(Tokens.font.size.large) + onClicked: root.lyricMenuOpen = !root.lyricMenuOpen + } } StyledSlider { @@ -244,7 +314,7 @@ Item { enabled: !!Players.active implicitWidth: 280 - implicitHeight: Appearance.padding.normal * 3 + implicitHeight: Tokens.padding.normal * 3 onMoved: { const active = Players.active; @@ -260,9 +330,6 @@ Item { } CustomMouseArea { - anchors.fill: parent - acceptedButtons: Qt.NoButton - function onWheel(event: WheelEvent) { const active = Players.active; if (!active?.canSeek || !active?.positionSupported) @@ -274,6 +341,9 @@ Item { active.position = Math.max(0, Math.min(active.length, active.position + delta)); }); } + + anchors.fill: parent + acceptedButtons: Qt.NoButton } } @@ -286,9 +356,9 @@ Item { anchors.left: parent.left - text: root.lengthStr(Players.active?.position ?? -1) + text: root.lengthStr(Players.active ? Players.active.position % Players.active.length : -1) color: Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small } StyledText { @@ -298,105 +368,146 @@ Item { text: root.lengthStr(Players.active?.length ?? -1) color: Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small } } + } - RowLayout { - Layout.alignment: Qt.AlignHCenter - spacing: Appearance.spacing.small + ColumnLayout { + id: leftSection - 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; - } + anchors.verticalCenter: parent.verticalCenter + anchors.verticalCenterOffset: playerChanger.parent == leftSection ? -playerChanger.height : 0 + anchors.left: details.right + anchors.leftMargin: Tokens.spacing.normal + + visible: lyricMenu.height === 0 || opacity > 0 + opacity: lyricMenu.height === 0 ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: Tokens.anim.durations.normal + easing.type: Easing.OutCubic } + } + + Item { + id: bongocat - SplitButton { - id: playerSelector + implicitWidth: visualiser.width + implicitHeight: visualiser.height - disabled: !Players.list.length - active: menuItems.find(m => m.modelData === Players.active) ?? menuItems[0] ?? null - menu.onItemSelected: item => Players.manualActive = item.modelData + AnimatedImage { + anchors.centerIn: parent - menuItems: playerList.instances - fallbackIcon: "music_off" - fallbackText: qsTr("No players") + width: visualiser.width * 0.75 + height: visualiser.height * 0.75 - label.Layout.maximumWidth: slider.implicitWidth * 0.28 - label.elide: Text.ElideRight + playing: Players.active?.isPlaying ?? false + speed: Audio.beatTracker.bpm / Config.general.mediaGifSpeedAdjustment // qmllint disable unresolved-type + source: Paths.absolutePath(Config.paths.mediaGif) + asynchronous: true + fillMode: AnimatedImage.PreserveAspectFit + } + } + } - stateLayer.disabled: true - menuOnTop: true + LyricMenu { + id: lyricMenu - Variants { - id: playerList + anchors.top: parent.top + anchors.left: details.right + anchors.right: parent.right + anchors.leftMargin: Tokens.spacing.normal - model: Players.list + contentHeight: !root.lyricsShowingDebounced ? root.detailsHeightWithoutLyrics + Tokens.padding.large * 5 : root.detailsHeightWithoutLyrics + lyricsViewInDetails.implicitHeight - MenuItem { - required property MprisPlayer modelData + visible: root.lyricMenuOpen || height > 0 + height: root.lyricMenuOpen ? implicitHeight : 0 + clip: true - icon: modelData === Players.active ? "check" : "" - text: Players.getIdentity(modelData) - activeIcon: "animated_images" - } - } + Behavior on height { + NumberAnimation { + duration: Tokens.anim.durations.normal + easing.type: Easing.OutCubic } + } + } - 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() + RowLayout { + id: playerChanger + + parent: !root.lyricsShowingDebounced ? details : leftSection + Layout.alignment: Qt.AlignHCenter + spacing: Tokens.spacing.small + + PlayerControl { + type: IconButton.Text + icon: "move_up" + inactiveOnColour: Colours.palette.m3secondary + padding: Tokens.padding.small + font.pointSize: Tokens.font.size.large + disabled: !Players.active?.canRaise + onClicked: { + Players.active?.raise(); + root.visibilities.dashboard = false; } } - } - Item { - id: bongocat + SplitButton { + id: playerSelector - anchors.verticalCenter: parent.verticalCenter - anchors.left: details.right - anchors.leftMargin: Appearance.spacing.normal + disabled: !Players.list.length + active: menuItems.find(m => m.modelData === Players.active) ?? menuItems[0] ?? null + menu.onItemSelected: item => Players.manualActive = (item as PlayerItem).modelData - implicitWidth: visualiser.width - implicitHeight: visualiser.height + menuItems: playerList.instances + fallbackIcon: "music_off" + fallbackText: qsTr("No players") - AnimatedImage { - anchors.centerIn: parent + label.Layout.maximumWidth: slider.implicitWidth * 0.28 + label.elide: Text.ElideRight - width: visualiser.width * 0.75 - height: visualiser.height * 0.75 + stateLayer.disabled: true + menuOnTop: true - playing: Players.active?.isPlaying ?? false - speed: Audio.beatTracker.bpm / 300 - source: Paths.absolutePath(Config.paths.mediaGif) - asynchronous: true - fillMode: AnimatedImage.PreserveAspectFit + Variants { + id: playerList + + model: Players.list + + PlayerItem {} + } } + + PlayerControl { + type: IconButton.Text + icon: "delete" + inactiveOnColour: Colours.palette.m3error + padding: Tokens.padding.small + font.pointSize: Tokens.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 + Layout.preferredWidth: implicitWidth + (stateLayer.pressed ? Tokens.padding.large : internalChecked ? Tokens.padding.smaller : 0) + radius: stateLayer.pressed ? Tokens.rounding.small / 2 : internalChecked ? Tokens.rounding.small : implicitHeight / 2 + radiusAnim.duration: Tokens.anim.durations.expressiveFastSpatial + radiusAnim.easing: Tokens.anim.expressiveFastSpatial Behavior on Layout.preferredWidth { Anim { - duration: Appearance.anim.durations.expressiveFastSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + type: Anim.FastSpatial } } } diff --git a/modules/dashboard/MediaWrapper.qml b/modules/dashboard/MediaWrapper.qml new file mode 100644 index 000000000..b03a11ee8 --- /dev/null +++ b/modules/dashboard/MediaWrapper.qml @@ -0,0 +1,13 @@ +import QtQuick + +Item { + property alias visibilities: media.visibilities + readonly property alias needsKeyboard: media.needsKeyboard + + implicitWidth: media.implicitWidth + implicitHeight: media.nonAnimHeight + + Media { + id: media + } +} diff --git a/modules/dashboard/Monitors.qml b/modules/dashboard/Monitors.qml new file mode 100644 index 000000000..8af113063 --- /dev/null +++ b/modules/dashboard/Monitors.qml @@ -0,0 +1,221 @@ +import qs.components +import qs.components.controls +import qs.services +import Caelestia.Config +import Quickshell +import QtQuick +import QtQuick.Layouts + +ColumnLayout { + id: root + + spacing: Tokens.spacing.large + + RowLayout { + Layout.fillWidth: true + Layout.margins: Tokens.padding.normal + + StyledText { + text: qsTr("Monitors") + font.pointSize: Tokens.font.size.extraLarge + Layout.fillWidth: true + } + + IconTextButton { + icon: "info" + text: qsTr("Identify") + toggle: true + checked: Monitors.identifying + onClicked: Monitors.toggleIdentification() + } + } + + Flickable { + Layout.fillWidth: true + Layout.fillHeight: true + contentHeight: monitorsLayout.implicitHeight + clip: true + + ColumnLayout { + id: monitorsLayout + anchors.left: parent.left + anchors.right: parent.right + spacing: Tokens.spacing.normal + + Repeater { + model: Hyprctl.monitors + + delegate: StyledRect { + id: monitorDelegate + Layout.fillWidth: true + implicitHeight: monitorContent.implicitHeight + Tokens.padding.large * 2 + color: Colours.tPalette.m3surfaceContainerHigh + radius: Tokens.rounding.large + + readonly property var mon: modelData + readonly property var brightnessMon: Brightness.getMonitor(mon.name) + + ColumnLayout { + id: monitorContent + anchors.fill: parent + anchors.margins: Tokens.padding.large + spacing: Tokens.spacing.medium + + RowLayout { + Layout.fillWidth: true + MaterialIcon { + text: "monitor" + color: Colours.palette.m3primary + } + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + StyledText { + text: `${mon.name} - ${mon.make} ${mon.model}` + font.pointSize: Tokens.font.size.large + Layout.fillWidth: true + } + StyledText { + text: `${mon.width}x${mon.height}@${(mon.refreshRate ?? 0).toFixed(2)}Hz` + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Tokens.font.size.small + } + } + StyledText { + text: `ID: ${mon.id}` + color: Colours.palette.m3onSurfaceVariant + } + } + + // Brightness + RowLayout { + Layout.fillWidth: true + visible: !!brightnessMon + + MaterialIcon { + text: "brightness_medium" + font.pointSize: Tokens.font.size.normal + } + + StyledSlider { + Layout.fillWidth: true + value: brightnessMon?.brightness ?? 0 + onMoved: if (brightnessMon) brightnessMon.setBrightness(value) + } + + StyledText { + text: `${Math.round((brightnessMon?.brightness ?? 0) * 100)}%` + Layout.preferredWidth: 40 + } + } + + // Scaling + RowLayout { + Layout.fillWidth: true + + MaterialIcon { + text: "zoom_in" + font.pointSize: Tokens.font.size.normal + } + + StyledSlider { + Layout.fillWidth: true + from: 0.5 + to: 3.0 + value: mon.scale + onMoved: Monitors.setScale(mon.name, value) + } + + StyledText { + text: `${mon.scale.toFixed(2)}x` + Layout.preferredWidth: 40 + } + } + + // Refresh Rate + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.small + + StyledText { + text: qsTr("Refresh Rate") + Layout.fillWidth: true + } + + CustomSpinBox { + id: rrSelector + min: 10 + max: 1000 + step: 1 + value: mon.refreshRate + onValueModified: val => Monitors.setRefreshRate(mon.name, val) + } + } + + // Rotation + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.small + + StyledText { + text: qsTr("Rotation") + Layout.fillWidth: true + } + + Repeater { + model: [ + { label: "0°", val: 0, icon: "screen_rotation" }, + { label: "90°", val: 1, icon: "screen_rotation" }, + { label: "180°", val: 2, icon: "screen_rotation" }, + { label: "270°", val: 3, icon: "screen_rotation" } + ] + + delegate: IconButton { + icon: modelData.icon + toggle: true + checked: mon.transform === modelData.val + onClicked: Monitors.rotate(mon.name, modelData.val * 90) + } + } + } + + // Arrangement + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.small + + StyledText { + text: qsTr("Position relative to:") + Layout.fillWidth: true + } + + CustomSpinBox { + id: targetMonSelector + min: 0 + max: Math.max(0, (Hyprctl.monitors?.length ?? 1) - 1) + value: 0 + } + + IconButton { + icon: "arrow_back" + onClicked: Monitors.arrange(mon.name, "left", targetMonSelector.value) + } + IconButton { + icon: "arrow_forward" + onClicked: Monitors.arrange(mon.name, "right", targetMonSelector.value) + } + IconButton { + icon: "arrow_upward" + onClicked: Monitors.arrange(mon.name, "top", targetMonSelector.value) + } + IconButton { + icon: "arrow_downward" + onClicked: Monitors.arrange(mon.name, "bottom", targetMonSelector.value) + } + } + } + } + } + } + } +} diff --git a/modules/dashboard/Performance.qml b/modules/dashboard/Performance.qml index 5e00d8920..c93499ca6 100644 --- a/modules/dashboard/Performance.qml +++ b/modules/dashboard/Performance.qml @@ -1,227 +1,826 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell.Services.UPower +import Caelestia.Config +import Caelestia.Internal import qs.components import qs.components.misc import qs.services -import qs.config -import QtQuick -import QtQuick.Layouts -RowLayout { +Item { id: root - readonly property int padding: Appearance.padding.large + readonly property int minWidth: 400 + 400 + Tokens.spacing.normal + 120 + Tokens.padding.large * 2 function displayTemp(temp: real): string { - return `${Math.ceil(Config.services.useFahrenheit ? temp * 1.8 + 32 : temp)}°${Config.services.useFahrenheit ? "F" : "C"}`; + return `${Math.ceil(GlobalConfig.services.useFahrenheitPerformance ? temp * 1.8 + 32 : temp)}°${GlobalConfig.services.useFahrenheitPerformance ? "F" : "C"}`; } - spacing: Appearance.spacing.large * 3 + implicitWidth: Math.max(minWidth, content.implicitWidth) + implicitHeight: placeholder.visible ? placeholder.height : content.implicitHeight - Ref { - service: SystemUsage - } + StyledRect { + id: placeholder + + anchors.centerIn: parent + width: 400 + height: 350 + radius: Tokens.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) - Resource { - Layout.alignment: Qt.AlignVCenter - Layout.topMargin: root.padding - Layout.bottomMargin: root.padding - Layout.leftMargin: root.padding * 2 + ColumnLayout { + anchors.centerIn: parent + spacing: Tokens.spacing.normal - value1: Math.min(1, SystemUsage.gpuTemp / 90) - value2: SystemUsage.gpuPerc + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + text: "tune" + font.pointSize: Tokens.font.size.extraLarge * 2 + color: Colours.palette.m3onSurfaceVariant + } - label1: root.displayTemp(SystemUsage.gpuTemp) - label2: `${Math.round(SystemUsage.gpuPerc * 100)}%` + StyledText { + Layout.alignment: Qt.AlignHCenter + text: qsTr("No widgets enabled") + font.pointSize: Tokens.font.size.large + color: Colours.palette.m3onSurface + } - sublabel1: qsTr("GPU temp") - sublabel2: qsTr("Usage") + StyledText { + Layout.alignment: Qt.AlignHCenter + text: qsTr("Enable widgets in dashboard settings") + font.pointSize: Tokens.font.size.small + color: Colours.palette.m3onSurfaceVariant + } + } } - Resource { - Layout.alignment: Qt.AlignVCenter - Layout.topMargin: root.padding - Layout.bottomMargin: root.padding + RowLayout { + id: content - primary: true + anchors.left: parent.left + anchors.right: parent.right + spacing: Tokens.spacing.normal + visible: !placeholder.visible - value1: Math.min(1, SystemUsage.cpuTemp / 90) - value2: SystemUsage.cpuPerc + Ref { + service: SystemUsage + } + + ColumnLayout { + id: mainColumn + + Layout.fillWidth: true + spacing: Tokens.spacing.normal + + RowLayout { + Layout.fillWidth: true + spacing: Tokens.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 + } + } - label1: root.displayTemp(SystemUsage.cpuTemp) - label2: `${Math.round(SystemUsage.cpuPerc * 100)}%` + RowLayout { + Layout.fillWidth: true + spacing: Tokens.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 + } + } + } - sublabel1: qsTr("CPU temp") - sublabel2: qsTr("Usage") + BatteryTank { + Layout.preferredWidth: 120 + Layout.preferredHeight: mainColumn.implicitHeight + visible: UPower.displayDevice.isLaptopBattery && Config.dashboard.performance.showBattery + } } - Resource { - Layout.alignment: Qt.AlignVCenter - Layout.topMargin: root.padding - Layout.bottomMargin: root.padding - Layout.rightMargin: root.padding * 3 + 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: Tokens.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) + } - value1: SystemUsage.memPerc - value2: SystemUsage.storagePerc + ColumnLayout { + anchors.fill: parent + anchors.margins: Tokens.padding.large + spacing: Tokens.spacing.small + + // Header Section + ColumnLayout { + Layout.fillWidth: true + spacing: Tokens.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: Tokens.font.size.large + color: batteryTank.accentColor + } + + StyledText { + Layout.fillWidth: true + text: qsTr("Battery") + font.pointSize: Tokens.font.size.normal + color: Colours.palette.m3onSurface + } + } - label1: { - const fmt = SystemUsage.formatKib(SystemUsage.memUsed); - return `${+fmt.value.toFixed(1)}${fmt.unit}`; + 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: Tokens.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: Tokens.font.size.smaller + color: Colours.palette.m3onSurfaceVariant + } + } + } + + Behavior on animatedPercentage { + Anim { + type: Anim.StandardLarge + } } - label2: { - const fmt = SystemUsage.formatKib(SystemUsage.storageUsed); - return `${Math.floor(fmt.value)}${fmt.unit}`; + } + + component CardHeader: RowLayout { + property string icon + property string title + property color accentColor: Colours.palette.m3primary + + Layout.fillWidth: true + spacing: Tokens.spacing.small + + MaterialIcon { + text: parent.icon + fill: 1 + color: parent.accentColor + font.pointSize: Tokens.spacing.large } - sublabel1: qsTr("Memory") - sublabel2: qsTr("Storage") + StyledText { + Layout.fillWidth: true + text: parent.title + font.pointSize: Tokens.font.size.normal + elide: Text.ElideRight + } } - component Resource: Item { - id: res + 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: Tokens.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: Tokens.rounding.full + } - required property real value1 - required property real value2 - required property string sublabel1 - required property string sublabel2 - required property string label1 - required property string label2 + Behavior on animatedValue { + Anim { + type: Anim.StandardLarge + } + } + } - property bool primary - readonly property real primaryMult: primary ? 1.2 : 1 + 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: Tokens.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 + implicitWidth: parent.width * heroCard.animatedUsage + color: Qt.alpha(heroCard.accentColor, 0.15) + } - readonly property real thickness: Config.dashboard.sizes.resourceProgessThickness * primaryMult + CardHeader { + anchors.left: parent.left + anchors.top: parent.top + anchors.leftMargin: Tokens.padding.large + anchors.topMargin: Math.round(Tokens.padding.large * 1.2) - property color fg1: Colours.palette.m3primary - property color fg2: Colours.palette.m3secondary - property color bg1: Colours.palette.m3primaryContainer - property color bg2: Colours.palette.m3secondaryContainer + width: parent.width - anchors.leftMargin - usageColumn.anchors.rightMargin - usageLabel.width - Tokens.spacing.normal + icon: heroCard.icon + title: heroCard.title + accentColor: heroCard.accentColor + } - implicitWidth: Config.dashboard.sizes.resourceSize * primaryMult - implicitHeight: Config.dashboard.sizes.resourceSize * primaryMult + Column { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: Math.round(Tokens.padding.large * 1.2) + anchors.bottomMargin: Math.round(Tokens.padding.large * 1.3) + + spacing: Tokens.spacing.small + + Row { + spacing: Tokens.spacing.small + + StyledText { + text: heroCard.secondaryValue + font.pointSize: Tokens.font.size.normal + font.weight: Font.Medium + } + + StyledText { + text: heroCard.secondaryLabel + font.pointSize: Tokens.font.size.small + color: Colours.palette.m3onSurfaceVariant + anchors.baseline: parent.children[0].baseline + } + } - onValue1Changed: canvas.requestPaint() - onValue2Changed: canvas.requestPaint() - onFg1Changed: canvas.requestPaint() - onFg2Changed: canvas.requestPaint() - onBg1Changed: canvas.requestPaint() - onBg2Changed: canvas.requestPaint() + ProgressBar { + implicitWidth: parent.width * 0.5 + implicitHeight: 6 + value: heroCard.tempProgress + fgColor: heroCard.accentColor + bgColor: Qt.alpha(heroCard.accentColor, 0.2) + } + } Column { - anchors.centerIn: parent + id: usageColumn + + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Tokens.padding.large + anchors.rightMargin: 32 + spacing: 0 StyledText { - anchors.horizontalCenter: parent.horizontalCenter + id: usageLabel - text: res.label1 - font.pointSize: Appearance.font.size.extraLarge * res.primaryMult + anchors.right: parent.right + text: heroCard.mainLabel + font.pointSize: Tokens.font.size.normal + color: Colours.palette.m3onSurfaceVariant } StyledText { - anchors.horizontalCenter: parent.horizontalCenter + anchors.right: parent.right + text: heroCard.mainValue + font.pointSize: Tokens.font.size.extraLarge + font.weight: Font.Medium + color: heroCard.accentColor + } + } - text: res.sublabel1 - color: Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.smaller * res.primaryMult + Behavior on animatedUsage { + Anim { + type: Anim.StandardLarge } } - Column { - anchors.horizontalCenter: parent.right - anchors.top: parent.verticalCenter - anchors.horizontalCenterOffset: -res.thickness / 2 - anchors.topMargin: res.thickness / 2 + Appearance.spacing.small + Behavior on animatedTemp { + Anim { + type: Anim.StandardLarge + } + } + } - StyledText { - anchors.horizontalCenter: parent.horizontalCenter + 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: Tokens.rounding.large + clip: true + Component.onCompleted: animatedPercentage = percentage + onPercentageChanged: animatedPercentage = percentage + + ColumnLayout { + anchors.fill: parent + anchors.margins: Tokens.padding.large + spacing: Tokens.spacing.smaller - text: res.label2 - font.pointSize: Appearance.font.size.smaller * res.primaryMult + CardHeader { + icon: gaugeCard.icon + title: gaugeCard.title + accentColor: gaugeCard.accentColor } - StyledText { - anchors.horizontalCenter: parent.horizontalCenter + 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: Tokens.font.size.extraLarge + font.weight: Font.Medium + color: gaugeCard.accentColor + } + } - text: res.sublabel2 + StyledText { + Layout.alignment: Qt.AlignHCenter + text: gaugeCard.subtitle + font.pointSize: Tokens.font.size.smaller color: Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.small * res.primaryMult } } - Canvas { - id: canvas + Behavior on animatedPercentage { + Anim { + type: Anim.StandardLarge + } + } + } - readonly property real centerX: width / 2 - readonly property real centerY: height / 2 + 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: Tokens.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; - readonly property real arc1Start: degToRad(45) - readonly property real arc1End: degToRad(220) - readonly property real arc2Start: degToRad(230) - readonly property real arc2End: degToRad(360) + // Update animated percentage when disk data refreshes + if (storageGaugeCard.currentDisk) + storageGaugeCard.animatedPercentage = storageGaugeCard.currentDisk.perc; + } + + target: SystemUsage + } - function degToRad(deg: int): real { - return deg * Math.PI / 180; + 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: Tokens.padding.large + spacing: Tokens.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: Tokens.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 + } + } + } - onPaint: { - const ctx = getContext("2d"); - ctx.reset(); + 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: Tokens.font.size.extraLarge + font.weight: Font.Medium + color: storageGaugeCard.accentColor + } + } - ctx.lineWidth = res.thickness; - ctx.lineCap = Appearance.rounding.scale === 0 ? "square" : "round"; + 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: Tokens.font.size.smaller + color: Colours.palette.m3onSurfaceVariant + } + } - const radius = (Math.min(width, height) - ctx.lineWidth) / 2; - const cx = centerX; - const cy = centerY; - const a1s = arc1Start; - const a1e = arc1End; - const a2s = arc2Start; - const a2e = arc2End; + Behavior on animatedPercentage { + Anim { + type: Anim.StandardLarge + } + } + } - ctx.beginPath(); - ctx.arc(cx, cy, radius, a1s, a1e, false); - ctx.strokeStyle = res.bg1; - ctx.stroke(); + component NetworkCard: StyledRect { + id: networkCard - ctx.beginPath(); - ctx.arc(cx, cy, radius, a1s, (a1e - a1s) * res.value1 + a1s, false); - ctx.strokeStyle = res.fg1; - ctx.stroke(); + property color accentColor: Colours.palette.m3primary - ctx.beginPath(); - ctx.arc(cx, cy, radius, a2s, a2e, false); - ctx.strokeStyle = res.bg2; - ctx.stroke(); + color: Colours.tPalette.m3surfaceContainer + radius: Tokens.rounding.large + clip: true - ctx.beginPath(); - ctx.arc(cx, cy, radius, a2s, (a2e - a2s) * res.value2 + a2s, false); - ctx.strokeStyle = res.fg2; - ctx.stroke(); - } + Ref { + service: NetworkUsage } - Behavior on value1 { - Anim {} - } + ColumnLayout { + anchors.fill: parent + anchors.margins: Tokens.padding.large + spacing: Tokens.spacing.small - Behavior on value2 { - Anim {} - } + CardHeader { + icon: "swap_vert" + title: qsTr("Network") + accentColor: networkCard.accentColor + } - Behavior on fg1 { - CAnim {} - } + // 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 // qmllint disable missing-type + line1Color: Colours.palette.m3secondary + line1FillAlpha: 0.15 + line2: NetworkUsage.downloadBuffer // qmllint disable missing-type + 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: GlobalConfig.dashboard.resourceUpdateInterval + } + + Behavior on smoothMax { + Anim { + type: Anim.StandardLarge + } + } + } + + // "No data" placeholder + StyledText { + anchors.centerIn: parent + text: qsTr("Collecting data...") + font.pointSize: Tokens.font.size.small + color: Colours.palette.m3onSurfaceVariant + visible: NetworkUsage.downloadBuffer.count < 2 + opacity: 0.6 + } + } - Behavior on fg2 { - CAnim {} - } + // Download row + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.normal + + MaterialIcon { + text: "download" + color: Colours.palette.m3tertiary + font.pointSize: Tokens.font.size.normal + } + + StyledText { + text: qsTr("Download") + font.pointSize: Tokens.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: Tokens.font.size.normal + font.weight: Font.Medium + color: Colours.palette.m3tertiary + } + } - Behavior on bg1 { - CAnim {} - } + // Upload row + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.normal + + MaterialIcon { + text: "upload" + color: Colours.palette.m3secondary + font.pointSize: Tokens.font.size.normal + } + + StyledText { + text: qsTr("Upload") + font.pointSize: Tokens.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: Tokens.font.size.normal + font.weight: Font.Medium + color: Colours.palette.m3secondary + } + } - Behavior on bg2 { - CAnim {} + // Session totals + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.normal + + MaterialIcon { + text: "history" + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Tokens.font.size.normal + } + + StyledText { + text: qsTr("Total") + font.pointSize: Tokens.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: Tokens.font.size.small + color: Colours.palette.m3onSurfaceVariant + } + } } } } diff --git a/modules/dashboard/Tabs.qml b/modules/dashboard/Tabs.qml index 98ea880e5..5bf99ca3e 100644 --- a/modules/dashboard/Tabs.qml +++ b/modules/dashboard/Tabs.qml @@ -1,19 +1,21 @@ pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Controls +import Quickshell +import Quickshell.Widgets +import Caelestia.Config import qs.components import qs.components.controls import qs.services -import qs.config -import Quickshell -import Quickshell.Widgets -import QtQuick -import QtQuick.Controls Item { id: root required property real nonAnimWidth - required property PersistentProperties state + required property DashboardState dashState + required property var tabs + readonly property alias count: bar.count implicitHeight: bar.implicitHeight + indicator.implicitHeight + indicator.anchors.topMargin + separator.implicitHeight @@ -25,50 +27,45 @@ Item { anchors.right: parent.right anchors.top: parent.top - currentIndex: root.state.currentTab + currentIndex: root.dashState.currentTab background: null - onCurrentIndexChanged: root.state.currentTab = currentIndex - - Tab { - iconName: "dashboard" - text: qsTr("Dashboard") - } + onCurrentIndexChanged: root.dashState.currentTab = currentIndex - Tab { - iconName: "queue_music" - text: qsTr("Media") - } + Repeater { + model: ScriptModel { + values: root.tabs + } - Tab { - iconName: "speed" - text: qsTr("Performance") - } + delegate: Tab { + required property var modelData - Tab { - iconName: "cloud" - text: qsTr("Weather") + iconName: modelData.iconName + text: modelData.text + } } - - // Tab { - // iconName: "workspaces" - // text: qsTr("Workspaces") - // } } Item { id: indicator anchors.top: bar.bottom - anchors.topMargin: Config.dashboard.sizes.tabIndicatorSpacing + anchors.topMargin: 5 - implicitWidth: bar.currentItem.implicitWidth - implicitHeight: Config.dashboard.sizes.tabIndicatorHeight + 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; - return width * tab.TabBar.index + (width - tab.implicitWidth) / 2; + const tabWidth = tab?.implicitWidth ?? width; + return width * bar.currentIndex + (width - tabWidth) / 2; } clip: true @@ -80,7 +77,7 @@ Item { implicitHeight: parent.implicitHeight * 2 color: Colours.palette.m3primary - radius: Appearance.rounding.full + radius: Tokens.rounding.full } Behavior on x { @@ -114,13 +111,21 @@ Item { contentItem: CustomMouseArea { id: mouse + function onWheel(event: WheelEvent): void { + if (event.angleDelta.y < 0) + root.dashState.currentTab = Math.min(root.dashState.currentTab + 1, bar.count - 1); + else if (event.angleDelta.y > 0) + root.dashState.currentTab = Math.max(root.dashState.currentTab - 1, 0); + } + implicitWidth: Math.max(icon.width, label.width) implicitHeight: icon.height + label.height + hoverEnabled: true cursorShape: Qt.PointingHandCursor onPressed: event => { - root.state.currentTab = tab.TabBar.index; + root.dashState.currentTab = tab.TabBar.index; const stateY = stateWrapper.y; rippleAnim.x = event.x; @@ -132,13 +137,6 @@ Item { rippleAnim.restart(); } - function onWheel(event: WheelEvent): void { - if (event.angleDelta.y < 0) - root.state.currentTab = Math.min(root.state.currentTab + 1, bar.count - 1); - else if (event.angleDelta.y > 0) - root.state.currentTab = Math.max(root.state.currentTab - 1, 0); - } - SequentialAnimation { id: rippleAnim @@ -166,16 +164,13 @@ Item { properties: "implicitWidth,implicitHeight" from: 0 to: rippleAnim.radius * 2 - duration: Appearance.anim.durations.normal - easing.bezierCurve: Appearance.anim.curves.standardDecel + duration: Tokens.anim.durations.normal + easing: Tokens.anim.standardDecel } Anim { target: ripple property: "opacity" to: 0 - duration: Appearance.anim.durations.normal - easing.type: Easing.BezierSpline - easing.bezierCurve: Appearance.anim.curves.standard } } @@ -185,10 +180,10 @@ Item { anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - implicitHeight: parent.height + Config.dashboard.sizes.tabIndicatorSpacing * 2 + implicitHeight: parent.height + Tokens.sizes.dashboard.tabIndicatorSpacing * 2 color: "transparent" - radius: Appearance.rounding.small + radius: Tokens.rounding.small StyledRect { id: stateLayer @@ -196,7 +191,7 @@ Item { anchors.fill: parent color: tab.current ? Colours.palette.m3primary : Colours.palette.m3onSurface - opacity: mouse.pressed ? 0.1 : tab.hovered ? 0.08 : 0 + opacity: mouse.pressed ? 0.1 : mouse.containsMouse ? 0.08 : 0 Behavior on opacity { Anim {} @@ -206,7 +201,7 @@ Item { StyledRect { id: ripple - radius: Appearance.rounding.full + radius: Tokens.rounding.full color: tab.current ? Colours.palette.m3primary : Colours.palette.m3onSurface opacity: 0 @@ -226,7 +221,7 @@ Item { text: tab.iconName color: tab.current ? Colours.palette.m3primary : Colours.palette.m3onSurfaceVariant fill: tab.current ? 1 : 0 - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large Behavior on fill { Anim {} diff --git a/modules/dashboard/Weather.qml b/modules/dashboard/WeatherTab.qml similarity index 74% rename from modules/dashboard/Weather.qml rename to modules/dashboard/WeatherTab.qml index 3981633ac..3cf8d3b71 100644 --- a/modules/dashboard/Weather.qml +++ b/modules/dashboard/WeatherTab.qml @@ -1,43 +1,42 @@ -import qs.components -import qs.services -import qs.config import QtQuick import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.services Item { id: root - implicitWidth: layout.implicitWidth > 800 ? layout.implicitWidth : 840 - implicitHeight: layout.implicitHeight - readonly property var today: Weather.forecast && Weather.forecast.length > 0 ? Weather.forecast[0] : null + implicitWidth: layout.implicitWidth > 800 ? layout.implicitWidth : 840 + implicitHeight: layout.implicitHeight Component.onCompleted: Weather.reload() ColumnLayout { id: layout anchors.fill: parent - spacing: Appearance.spacing.smaller + spacing: Tokens.spacing.smaller RowLayout { - Layout.leftMargin: Appearance.padding.large - Layout.rightMargin: Appearance.padding.large + Layout.leftMargin: Tokens.padding.large + Layout.rightMargin: Tokens.padding.large Layout.fillWidth: true Column { - spacing: Appearance.spacing.small / 2 + spacing: Tokens.spacing.small / 2 StyledText { text: Weather.city || qsTr("Loading...") - font.pointSize: Appearance.font.size.extraLarge + font.pointSize: Tokens.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 + font.pointSize: Tokens.font.size.small color: Colours.palette.m3onSurfaceVariant } } @@ -47,7 +46,7 @@ Item { } Row { - spacing: Appearance.spacing.large + spacing: Tokens.spacing.large WeatherStat { icon: "wb_twilight" @@ -67,40 +66,40 @@ Item { StyledRect { Layout.fillWidth: true - implicitHeight: bigInfoRow.implicitHeight + Appearance.padding.small * 2 + implicitHeight: bigInfoRow.implicitHeight + Tokens.padding.small * 2 - radius: Appearance.rounding.large * 2 + radius: Tokens.rounding.large * 2 color: Colours.tPalette.m3surfaceContainer RowLayout { id: bigInfoRow anchors.centerIn: parent - spacing: Appearance.spacing.large + spacing: Tokens.spacing.large MaterialIcon { Layout.alignment: Qt.AlignVCenter text: Weather.icon - font.pointSize: Appearance.font.size.extraLarge * 3 + font.pointSize: Tokens.font.size.extraLarge * 3 color: Colours.palette.m3secondary animate: true } ColumnLayout { Layout.alignment: Qt.AlignVCenter - spacing: -Appearance.spacing.small + spacing: -Tokens.spacing.small StyledText { text: Weather.temp - font.pointSize: Appearance.font.size.extraLarge * 2 + font.pointSize: Tokens.font.size.extraLarge * 2 font.weight: 500 color: Colours.palette.m3primary } StyledText { - Layout.leftMargin: Appearance.padding.small + Layout.leftMargin: Tokens.padding.small text: Weather.description - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.font.size.normal color: Colours.palette.m3onSurfaceVariant } } @@ -109,7 +108,7 @@ Item { RowLayout { Layout.fillWidth: true - spacing: Appearance.spacing.smaller + spacing: Tokens.spacing.smaller DetailCard { icon: "water_drop" @@ -132,18 +131,18 @@ Item { } StyledText { - Layout.topMargin: Appearance.spacing.normal - Layout.leftMargin: Appearance.padding.normal + Layout.topMargin: Tokens.spacing.normal + Layout.leftMargin: Tokens.padding.normal visible: forecastRepeater.count > 0 text: qsTr("7-Day Forecast") - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.font.size.normal font.weight: 600 color: Colours.palette.m3onSurface } RowLayout { Layout.fillWidth: true - spacing: Appearance.spacing.smaller + spacing: Tokens.spacing.smaller Repeater { id: forecastRepeater @@ -157,30 +156,30 @@ Item { required property var modelData Layout.fillWidth: true - implicitHeight: forecastItemColumn.implicitHeight + Appearance.padding.normal * 2 + implicitHeight: forecastItemColumn.implicitHeight + Tokens.padding.normal * 2 - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal color: Colours.tPalette.m3surfaceContainer ColumnLayout { id: forecastItemColumn anchors.centerIn: parent - spacing: Appearance.spacing.small + spacing: Tokens.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.pointSize: Tokens.font.size.normal font.weight: 600 color: Colours.palette.m3primary } StyledText { - Layout.topMargin: -Appearance.spacing.small / 2 + Layout.topMargin: -Tokens.spacing.small / 2 Layout.alignment: Qt.AlignHCenter text: new Date(forecastItem.modelData.date).toLocaleDateString(Qt.locale(), "MMM d") - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small opacity: 0.7 color: Colours.palette.m3onSurfaceVariant } @@ -188,13 +187,13 @@ Item { MaterialIcon { Layout.alignment: Qt.AlignHCenter text: forecastItem.modelData.icon - font.pointSize: Appearance.font.size.extraLarge + font.pointSize: Tokens.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 + "°" + text: GlobalConfig.services.useFahrenheit ? forecastItem.modelData.maxTempF + "°" + " / " + forecastItem.modelData.minTempF + "°" : forecastItem.modelData.maxTempC + "°" + " / " + forecastItem.modelData.minTempC + "°" font.weight: 600 color: Colours.palette.m3tertiary } @@ -214,17 +213,17 @@ Item { Layout.fillWidth: true Layout.preferredHeight: 60 - radius: Appearance.rounding.small + radius: Tokens.rounding.small color: Colours.tPalette.m3surfaceContainer Row { anchors.centerIn: parent - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal MaterialIcon { text: detailRoot.icon color: detailRoot.colour - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large anchors.verticalCenter: parent.verticalCenter } @@ -234,7 +233,7 @@ Item { StyledText { text: detailRoot.label - font.pointSize: Appearance.font.size.smaller + font.pointSize: Tokens.font.size.smaller opacity: 0.7 horizontalAlignment: Text.AlignLeft } @@ -255,23 +254,23 @@ Item { property string value property color colour - spacing: Appearance.spacing.small + spacing: Tokens.spacing.small MaterialIcon { text: weatherStat.icon - font.pointSize: Appearance.font.size.extraLarge + font.pointSize: Tokens.font.size.extraLarge color: weatherStat.colour } Column { StyledText { text: weatherStat.label - font.pointSize: Appearance.font.size.smaller + font.pointSize: Tokens.font.size.smaller color: Colours.palette.m3onSurfaceVariant } StyledText { text: weatherStat.value - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small font.weight: 600 color: Colours.palette.m3onSurface } diff --git a/modules/dashboard/Wrapper.qml b/modules/dashboard/Wrapper.qml index 0e37909e8..f7f037426 100644 --- a/modules/dashboard/Wrapper.qml +++ b/modules/dashboard/Wrapper.qml @@ -1,21 +1,19 @@ pragma ComponentBehavior: Bound +import QtQuick +import Quickshell +import Caelestia +import Caelestia.Config import qs.components import qs.components.filedialog -import qs.config import qs.utils -import Caelestia -import Quickshell -import QtQuick Item { id: root - required property PersistentProperties visibilities - readonly property PersistentProperties dashState: PersistentProperties { - property int currentTab - property date currentDate: new Date() - + 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 { @@ -30,60 +28,19 @@ Item { } } - readonly property real nonAnimHeight: state === "visible" ? (content.item?.nonAnimHeight ?? 0) : 0 - - visible: height > 0 - implicitHeight: 0 - implicitWidth: content.implicitWidth - - onStateChanged: { - if (state === "visible" && timer.running) { - timer.triggered(); - timer.stop(); - } - } - - states: State { - name: "visible" - when: root.visibilities.dashboard && Config.dashboard.enabled - - PropertyChanges { - root.implicitHeight: content.implicitHeight - } - } - - transitions: [ - Transition { - from: "" - to: "visible" - - Anim { - target: root - property: "implicitHeight" - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial - } - }, - Transition { - from: "visible" - to: "" - - Anim { - target: root - property: "implicitHeight" - easing.bezierCurve: Appearance.anim.curves.emphasized - } - } - ] + readonly property real nonAnimHeight: state === "visible" ? ((content.item as Content)?.nonAnimHeight ?? 0) : 0 + readonly property bool shouldBeActive: visibilities.dashboard && Config.dashboard.enabled + property real offsetScale: shouldBeActive ? 0 : 1 - Timer { - id: timer + visible: offsetScale < 1 + anchors.topMargin: (-implicitHeight - 5) * offsetScale + implicitHeight: content.implicitHeight + implicitWidth: content.implicitWidth || 854 // Hard coded fallback for first open + opacity: 1 - offsetScale - running: true - interval: Appearance.anim.durations.extraLarge - onTriggered: { - content.active = Qt.binding(() => (root.visibilities.dashboard && Config.dashboard.enabled) || root.visible); - content.visible = true; + Behavior on offsetScale { + Anim { + type: Anim.DefaultSpatial } } @@ -93,12 +50,11 @@ Item { anchors.horizontalCenter: parent.horizontalCenter anchors.bottom: parent.bottom - visible: false - active: true + active: root.shouldBeActive || root.visible sourceComponent: Content { visibilities: root.visibilities - state: root.dashState + dashState: root.dashState facePicker: root.facePicker } } diff --git a/modules/dashboard/dash/Calendar.qml b/modules/dashboard/dash/Calendar.qml index 56c04938c..e2af3c015 100644 --- a/modules/dashboard/dash/Calendar.qml +++ b/modules/dashboard/dash/Calendar.qml @@ -1,61 +1,58 @@ 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 +import Caelestia.Config +import qs.components +import qs.components.controls +import qs.components.effects +import qs.services CustomMouseArea { id: root - required property var state + required property DashboardState dashState + + readonly property int currMonth: dashState.currentDate.getMonth() + readonly property int currYear: dashState.currentDate.getFullYear() - 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.dashState.currentDate = new Date(root.currYear, root.currMonth - 1, 1); + else if (event.angleDelta.y < 0) + root.dashState.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() - - 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); - } + onClicked: root.dashState.currentDate = new Date() ColumnLayout { id: inner anchors.fill: parent - anchors.margins: Appearance.padding.large - spacing: Appearance.spacing.small + anchors.margins: Tokens.padding.large + spacing: Tokens.spacing.small RowLayout { id: monthNavigationRow Layout.fillWidth: true - spacing: Appearance.spacing.small + spacing: Tokens.spacing.small Item { implicitWidth: implicitHeight - implicitHeight: prevMonthText.implicitHeight + Appearance.padding.small * 2 + implicitHeight: prevMonthText.implicitHeight + Tokens.padding.small * 2 StateLayer { id: prevMonthStateLayer - radius: Appearance.rounding.full - - function onClicked(): void { - root.state.currentDate = new Date(root.currYear, root.currMonth - 1, 1); - } + radius: Tokens.rounding.full + onClicked: root.dashState.currentDate = new Date(root.currYear, root.currMonth - 1, 1) } MaterialIcon { @@ -64,7 +61,7 @@ CustomMouseArea { anchors.centerIn: parent text: "chevron_left" color: Colours.palette.m3tertiary - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.font.size.normal font.weight: 700 } } @@ -72,24 +69,24 @@ CustomMouseArea { Item { Layout.fillWidth: true - implicitWidth: monthYearDisplay.implicitWidth + Appearance.padding.small * 2 - implicitHeight: monthYearDisplay.implicitHeight + Appearance.padding.small * 2 + implicitWidth: monthYearDisplay.implicitWidth + Tokens.padding.small * 2 + implicitHeight: monthYearDisplay.implicitHeight + Tokens.padding.small * 2 StateLayer { + onClicked: { + root.dashState.currentDate = new Date(); + } + anchors.fill: monthYearDisplay - anchors.margins: -Appearance.padding.small - anchors.leftMargin: -Appearance.padding.normal - anchors.rightMargin: -Appearance.padding.normal + anchors.margins: -Tokens.padding.small + anchors.leftMargin: -Tokens.padding.normal + anchors.rightMargin: -Tokens.padding.normal - radius: Appearance.rounding.full + radius: Tokens.rounding.full disabled: { const now = new Date(); return root.currMonth === now.getMonth() && root.currYear === now.getFullYear(); } - - function onClicked(): void { - root.state.currentDate = new Date(); - } } StyledText { @@ -98,7 +95,7 @@ CustomMouseArea { anchors.centerIn: parent text: grid.title color: Colours.palette.m3primary - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.font.size.normal font.weight: 500 font.capitalization: Font.Capitalize } @@ -106,16 +103,16 @@ CustomMouseArea { Item { implicitWidth: implicitHeight - implicitHeight: nextMonthText.implicitHeight + Appearance.padding.small * 2 + implicitHeight: nextMonthText.implicitHeight + Tokens.padding.small * 2 StateLayer { id: nextMonthStateLayer - radius: Appearance.rounding.full - - function onClicked(): void { - root.state.currentDate = new Date(root.currYear, root.currMonth + 1, 1); + onClicked: { + root.dashState.currentDate = new Date(root.currYear, root.currMonth + 1, 1); } + + radius: Tokens.rounding.full } MaterialIcon { @@ -124,7 +121,7 @@ CustomMouseArea { anchors.centerIn: parent text: "chevron_right" color: Colours.palette.m3tertiary - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.font.size.normal font.weight: 700 } } @@ -167,7 +164,7 @@ CustomMouseArea { required property var model implicitWidth: implicitHeight - implicitHeight: text.implicitHeight + Appearance.padding.small * 2 + implicitHeight: text.implicitHeight + Tokens.padding.small * 2 StyledText { id: text @@ -184,7 +181,7 @@ CustomMouseArea { return Colours.palette.m3onSurfaceVariant; } opacity: dayItem.model.today || dayItem.model.month === grid.month ? 1 : 0.4 - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.font.size.normal font.weight: 500 } } @@ -208,7 +205,7 @@ CustomMouseArea { implicitHeight: today?.implicitHeight ?? 0 clip: true - radius: Appearance.rounding.full + radius: Tokens.rounding.full color: Colours.palette.m3primary opacity: todayItem ? 1 : 0 @@ -236,15 +233,13 @@ CustomMouseArea { Behavior on x { Anim { - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + type: Anim.DefaultSpatial } } Behavior on y { Anim { - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + type: Anim.DefaultSpatial } } } diff --git a/modules/dashboard/dash/DateTime.qml b/modules/dashboard/dash/DateTime.qml index e74044883..82bee151c 100644 --- a/modules/dashboard/dash/DateTime.qml +++ b/modules/dashboard/dash/DateTime.qml @@ -1,17 +1,17 @@ pragma ComponentBehavior: Bound -import qs.components -import qs.services -import qs.config import QtQuick import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.services Item { id: root anchors.top: parent.top anchors.bottom: parent.bottom - implicitWidth: Config.dashboard.sizes.dateTimeWidth + implicitWidth: Tokens.sizes.dashboard.dateTimeWidth ColumnLayout { anchors.left: parent.left @@ -24,8 +24,8 @@ Item { Layout.alignment: Qt.AlignHCenter text: Time.hourStr color: Colours.palette.m3secondary - font.pointSize: Appearance.font.size.extraLarge - font.family: Appearance.font.family.clock + font.pointSize: Tokens.font.size.extraLarge + font.family: Tokens.font.family.clock font.weight: 600 } @@ -33,8 +33,8 @@ Item { Layout.alignment: Qt.AlignHCenter text: "•••" color: Colours.palette.m3primary - font.pointSize: Appearance.font.size.extraLarge * 0.9 - font.family: Appearance.font.family.clock + font.pointSize: Tokens.font.size.extraLarge * 0.9 + font.family: Tokens.font.family.clock } StyledText { @@ -42,22 +42,23 @@ Item { Layout.alignment: Qt.AlignHCenter text: Time.minuteStr color: Colours.palette.m3secondary - font.pointSize: Appearance.font.size.extraLarge - font.family: Appearance.font.family.clock + font.pointSize: Tokens.font.size.extraLarge + font.family: Tokens.font.family.clock font.weight: 600 } Loader { + asynchronous: true Layout.alignment: Qt.AlignHCenter - active: Config.services.useTwelveHourClock + active: GlobalConfig.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.pointSize: Tokens.font.size.large + font.family: Tokens.font.family.clock font.weight: 600 } } diff --git a/modules/dashboard/dash/Media.qml b/modules/dashboard/dash/Media.qml index 3a2b685ef..a14e5e389 100644 --- a/modules/dashboard/dash/Media.qml +++ b/modules/dashboard/dash/Media.qml @@ -1,32 +1,33 @@ +import QtQuick +import QtQuick.Shapes +import Quickshell +import Caelestia.Config +import Caelestia.Services 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; + return active?.length ? (active.position % active.length) / active.length : 0; } anchors.top: parent.top anchors.bottom: parent.bottom - implicitWidth: Config.dashboard.sizes.mediaWidth + implicitWidth: Tokens.sizes.dashboard.mediaWidth Behavior on playerProgress { Anim { - duration: Appearance.anim.durations.large + type: Anim.StandardLarge } } Timer { running: Players.active?.isPlaying ?? false - interval: Config.dashboard.mediaUpdateInterval + interval: GlobalConfig.dashboard.mediaUpdateInterval triggeredOnStart: true repeat: true onTriggered: Players.active?.positionChanged() @@ -42,16 +43,16 @@ Item { ShapePath { fillColor: "transparent" strokeColor: Colours.layer(Colours.palette.m3surfaceContainerHigh, 2) - strokeWidth: Config.dashboard.sizes.mediaProgressThickness - capStyle: Appearance.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap + strokeWidth: root.Tokens.sizes.dashboard.mediaProgressThickness + capStyle: root.Tokens.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 + radiusX: (cover.width + root.Tokens.sizes.dashboard.mediaProgressThickness) / 2 + root.Tokens.spacing.small + radiusY: (cover.height + root.Tokens.sizes.dashboard.mediaProgressThickness) / 2 + root.Tokens.spacing.small + startAngle: -90 - root.Tokens.sizes.dashboard.mediaProgressSweep / 2 + sweepAngle: root.Tokens.sizes.dashboard.mediaProgressSweep } Behavior on strokeColor { @@ -62,16 +63,16 @@ Item { ShapePath { fillColor: "transparent" strokeColor: Colours.palette.m3primary - strokeWidth: Config.dashboard.sizes.mediaProgressThickness - capStyle: Appearance.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap + strokeWidth: root.Tokens.sizes.dashboard.mediaProgressThickness + capStyle: root.Tokens.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 + radiusX: (cover.width + root.Tokens.sizes.dashboard.mediaProgressThickness) / 2 + root.Tokens.spacing.small + radiusY: (cover.height + root.Tokens.sizes.dashboard.mediaProgressThickness) / 2 + root.Tokens.spacing.small + startAngle: -90 - root.Tokens.sizes.dashboard.mediaProgressSweep / 2 + sweepAngle: root.Tokens.sizes.dashboard.mediaProgressSweep * root.playerProgress } Behavior on strokeColor { @@ -86,7 +87,7 @@ Item { anchors.top: parent.top anchors.left: parent.left anchors.right: parent.right - anchors.margins: Appearance.padding.large + Config.dashboard.sizes.mediaProgressThickness + Appearance.spacing.small + anchors.margins: Tokens.padding.large + Tokens.sizes.dashboard.mediaProgressThickness + Tokens.spacing.small implicitHeight: width color: Colours.tPalette.m3surfaceContainerHigh @@ -106,11 +107,13 @@ Item { anchors.fill: parent - source: Players.active?.trackArtUrl ?? "" + source: Players.getArtUrl(Players.active) asynchronous: true fillMode: Image.PreserveAspectCrop - sourceSize.width: width - sourceSize.height: height + sourceSize: { + const dpr = (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1; + return Qt.size(width * dpr, height * dpr); + } } } @@ -119,15 +122,15 @@ Item { anchors.top: cover.bottom anchors.horizontalCenter: parent.horizontalCenter - anchors.topMargin: Appearance.spacing.normal + anchors.topMargin: Tokens.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 + font.pointSize: Tokens.font.size.normal - width: parent.implicitWidth - Appearance.padding.large * 2 + width: parent.implicitWidth - Tokens.padding.large * 2 elide: Text.ElideRight } @@ -136,15 +139,15 @@ Item { anchors.top: title.bottom anchors.horizontalCenter: parent.horizontalCenter - anchors.topMargin: Appearance.spacing.small + anchors.topMargin: Tokens.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 + font.pointSize: Tokens.font.size.small - width: parent.implicitWidth - Appearance.padding.large * 2 + width: parent.implicitWidth - Tokens.padding.large * 2 elide: Text.ElideRight } @@ -153,14 +156,14 @@ Item { anchors.top: album.bottom anchors.horizontalCenter: parent.horizontalCenter - anchors.topMargin: Appearance.spacing.small + anchors.topMargin: Tokens.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 + width: parent.implicitWidth - Tokens.padding.large * 2 elide: Text.ElideRight } @@ -169,35 +172,26 @@ Item { anchors.top: artist.bottom anchors.horizontalCenter: parent.horizontalCenter - anchors.topMargin: Appearance.spacing.smaller + anchors.topMargin: Tokens.spacing.smaller - spacing: Appearance.spacing.small + spacing: Tokens.spacing.small - Control { + PlayerControl { icon: "skip_previous" canUse: Players.active?.canGoPrevious ?? false - - function onClicked(): void { - Players.active?.previous(); - } + onClicked: Players.active?.previous() } - Control { + PlayerControl { icon: Players.active?.isPlaying ? "pause" : "play_arrow" canUse: Players.active?.canTogglePlaying ?? false - - function onClicked(): void { - Players.active?.togglePlaying(); - } + onClicked: Players.active?.togglePlaying() } - Control { + PlayerControl { icon: "skip_next" canUse: Players.active?.canGoNext ?? false - - function onClicked(): void { - Players.active?.next(); - } + onClicked: Players.active?.next() } } @@ -208,35 +202,32 @@ Item { 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 + anchors.topMargin: Tokens.spacing.small + anchors.bottomMargin: Tokens.padding.large + anchors.margins: Tokens.padding.large * 2 playing: Players.active?.isPlaying ?? false - speed: Audio.beatTracker.bpm / 300 + speed: Audio.beatTracker.bpm / Config.general.mediaGifSpeedAdjustment // qmllint disable unresolved-type source: Paths.absolutePath(Config.paths.mediaGif) asynchronous: true fillMode: AnimatedImage.PreserveAspectFit } - component Control: StyledRect { + component PlayerControl: StyledRect { id: control required property string icon required property bool canUse - function onClicked(): void { - } - implicitWidth: Math.max(icon.implicitHeight, icon.implicitHeight) + Appearance.padding.small + signal clicked + + implicitWidth: Math.max(icon.implicitHeight, icon.implicitHeight) + Tokens.padding.small implicitHeight: implicitWidth StateLayer { disabled: !control.canUse - radius: Appearance.rounding.full - - function onClicked(): void { - control.onClicked(); - } + radius: Tokens.rounding.full + onClicked: control.clicked() } MaterialIcon { @@ -248,7 +239,7 @@ Item { animate: true text: control.icon color: control.canUse ? Colours.palette.m3onSurface : Colours.palette.m3outline - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large } } } diff --git a/modules/dashboard/dash/Resources.qml b/modules/dashboard/dash/Resources.qml index 7f44a9d0b..2e0f085b7 100644 --- a/modules/dashboard/dash/Resources.qml +++ b/modules/dashboard/dash/Resources.qml @@ -1,8 +1,8 @@ +import QtQuick +import Caelestia.Config import qs.components import qs.components.misc import qs.services -import qs.config -import QtQuick Row { id: root @@ -10,8 +10,8 @@ Row { anchors.top: parent.top anchors.bottom: parent.bottom - padding: Appearance.padding.large - spacing: Appearance.spacing.normal + padding: Tokens.padding.large + spacing: Tokens.spacing.normal Ref { service: SystemUsage @@ -44,19 +44,19 @@ Row { anchors.top: parent.top anchors.bottom: parent.bottom - anchors.margins: Appearance.padding.large + anchors.margins: Tokens.padding.large implicitWidth: icon.implicitWidth StyledRect { anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top anchors.bottom: icon.top - anchors.bottomMargin: Appearance.spacing.small + anchors.bottomMargin: Tokens.spacing.small - implicitWidth: Config.dashboard.sizes.resourceProgessThickness + implicitWidth: Tokens.sizes.dashboard.resourceProgressThickness color: Colours.layer(Colours.palette.m3surfaceContainerHigh, 2) - radius: Appearance.rounding.full + radius: Tokens.rounding.full StyledRect { anchors.left: parent.left @@ -65,7 +65,7 @@ Row { implicitHeight: res.value * parent.height color: res.colour - radius: Appearance.rounding.full + radius: Tokens.rounding.full } } @@ -80,7 +80,7 @@ Row { Behavior on value { Anim { - duration: Appearance.anim.durations.large + type: Anim.StandardLarge } } } diff --git a/modules/dashboard/dash/Weather.qml b/modules/dashboard/dash/SmallWeather.qml similarity index 76% rename from modules/dashboard/dash/Weather.qml rename to modules/dashboard/dash/SmallWeather.qml index c90ccf0a4..998c7125a 100644 --- a/modules/dashboard/dash/Weather.qml +++ b/modules/dashboard/dash/SmallWeather.qml @@ -1,8 +1,7 @@ +import QtQuick +import Caelestia.Config import qs.components import qs.services -import qs.config -import qs.utils -import QtQuick Item { id: root @@ -22,7 +21,7 @@ Item { animate: true text: Weather.icon color: Colours.palette.m3secondary - font.pointSize: Appearance.font.size.extraLarge * 2 + font.pointSize: Tokens.font.size.extraLarge * 2 } Column { @@ -30,9 +29,9 @@ Item { anchors.verticalCenter: parent.verticalCenter anchors.left: icon.right - anchors.leftMargin: Appearance.spacing.large + anchors.leftMargin: Tokens.spacing.large - spacing: Appearance.spacing.small + spacing: Tokens.spacing.small StyledText { anchors.horizontalCenter: parent.horizontalCenter @@ -40,7 +39,7 @@ Item { animate: true text: Weather.temp color: Colours.palette.m3primary - font.pointSize: Appearance.font.size.extraLarge + font.pointSize: Tokens.font.size.extraLarge font.weight: 500 } @@ -51,7 +50,7 @@ Item { text: Weather.description elide: Text.ElideRight - width: Math.min(implicitWidth, root.parent.width - icon.implicitWidth - info.anchors.leftMargin - Appearance.padding.large * 2) + width: Math.min(implicitWidth, root.parent.width - icon.implicitWidth - info.anchors.leftMargin - Tokens.padding.large * 2) } } } diff --git a/modules/dashboard/dash/User.qml b/modules/dashboard/dash/User.qml index b66b1f9a4..79787de51 100644 --- a/modules/dashboard/dash/User.qml +++ b/modules/dashboard/dash/User.qml @@ -1,28 +1,26 @@ +import QtQuick +import Caelestia.Config import qs.components import qs.components.effects -import qs.components.images import qs.components.filedialog +import qs.components.images import qs.services -import qs.config import qs.utils -import Quickshell -import QtQuick Row { id: root - required property PersistentProperties visibilities - required property PersistentProperties state + required property DrawerVisibilities visibilities required property FileDialog facePicker - padding: Appearance.padding.large - spacing: Appearance.spacing.normal + padding: Tokens.padding.large + spacing: Tokens.spacing.normal StyledClippingRect { implicitWidth: info.implicitHeight implicitHeight: info.implicitHeight - radius: Appearance.rounding.large + radius: Tokens.rounding.large color: Colours.layer(Colours.palette.m3surfaceContainerHigh, 2) MaterialIcon { @@ -32,6 +30,7 @@ Row { fill: 1 grade: 200 font.pointSize: Math.floor(info.implicitHeight / 2) || 1 + visible: pfp.status !== Image.Ready } CachingImage { @@ -53,7 +52,7 @@ Row { Behavior on opacity { Anim { - duration: Appearance.anim.durations.expressiveFastSpatial + duration: Tokens.anim.durations.expressiveFastSpatial } } } @@ -61,18 +60,17 @@ Row { StyledRect { anchors.centerIn: parent - implicitWidth: selectIcon.implicitHeight + Appearance.padding.small * 2 - implicitHeight: selectIcon.implicitHeight + Appearance.padding.small * 2 + implicitWidth: selectIcon.implicitHeight + Tokens.padding.small * 2 + implicitHeight: selectIcon.implicitHeight + Tokens.padding.small * 2 - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal color: Colours.palette.m3primary scale: parent.containsMouse ? 1 : 0.5 opacity: parent.containsMouse ? 1 : 0 StateLayer { color: Colours.palette.m3onPrimary - - function onClicked(): void { + onClicked: { root.visibilities.launcher = false; root.facePicker.open(); } @@ -86,19 +84,18 @@ Row { text: "frame_person" color: Colours.palette.m3onPrimary - font.pointSize: Appearance.font.size.extraLarge + font.pointSize: Tokens.font.size.extraLarge } Behavior on scale { Anim { - duration: Appearance.anim.durations.expressiveFastSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + type: Anim.FastSpatial } } Behavior on opacity { Anim { - duration: Appearance.anim.durations.expressiveFastSpatial + duration: Tokens.anim.durations.expressiveFastSpatial } } } @@ -109,7 +106,7 @@ Row { id: info anchors.verticalCenter: parent.verticalCenter - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal Item { id: line @@ -121,10 +118,10 @@ Row { id: icon anchors.left: parent.left - anchors.leftMargin: (Config.dashboard.sizes.infoIconSize - implicitWidth) / 2 + anchors.leftMargin: (Tokens.sizes.dashboard.infoIconSize - implicitWidth) / 2 source: SysInfo.osLogo - implicitSize: Math.floor(Appearance.font.size.normal * 1.34) + implicitSize: Math.floor(Tokens.font.size.normal * 1.34) colour: Colours.palette.m3primary } @@ -135,9 +132,9 @@ Row { anchors.left: icon.right anchors.leftMargin: icon.anchors.leftMargin text: `: ${SysInfo.osPrettyName || SysInfo.osName}` - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.font.size.normal - width: Config.dashboard.sizes.infoWidth + width: Tokens.sizes.dashboard.infoWidth elide: Text.ElideRight } } @@ -171,12 +168,12 @@ Row { id: icon anchors.left: parent.left - anchors.leftMargin: (Config.dashboard.sizes.infoIconSize - implicitWidth) / 2 + anchors.leftMargin: (Tokens.sizes.dashboard.infoIconSize - implicitWidth) / 2 fill: 1 text: line.icon color: line.colour - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.font.size.normal } StyledText { @@ -186,9 +183,9 @@ Row { anchors.left: icon.right anchors.leftMargin: icon.anchors.leftMargin text: `: ${line.text}` - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.font.size.normal - width: Config.dashboard.sizes.infoWidth + width: Tokens.sizes.dashboard.infoWidth elide: Text.ElideRight } } diff --git a/modules/drawers/Backgrounds.qml b/modules/drawers/Backgrounds.qml deleted file mode 100644 index 7fa2ca176..000000000 --- a/modules/drawers/Backgrounds.qml +++ /dev/null @@ -1,86 +0,0 @@ -import qs.services -import qs.config -import qs.modules.osd as Osd -import qs.modules.notifications as Notifications -import qs.modules.session as Session -import qs.modules.launcher as Launcher -import qs.modules.dashboard as Dashboard -import qs.modules.bar.popouts as BarPopouts -import qs.modules.utilities as Utilities -import qs.modules.sidebar as Sidebar -import QtQuick -import QtQuick.Shapes - -Shape { - id: root - - required property Panels panels - required property Item bar - - anchors.fill: parent - anchors.margins: Config.border.thickness - anchors.leftMargin: bar.implicitWidth - preferredRendererType: Shape.CurveRenderer - - Osd.Background { - wrapper: root.panels.osd - - startX: root.width - root.panels.session.width - root.panels.sidebar.width - startY: (root.height - wrapper.height) / 2 - rounding - } - - Notifications.Background { - wrapper: root.panels.notifications - sidebar: sidebar - - startX: root.width - startY: 0 - } - - Session.Background { - wrapper: root.panels.session - - startX: root.width - root.panels.sidebar.width - startY: (root.height - wrapper.height) / 2 - rounding - } - - Launcher.Background { - wrapper: root.panels.launcher - - startX: (root.width - wrapper.width) / 2 - rounding - startY: root.height - } - - Dashboard.Background { - wrapper: root.panels.dashboard - - startX: (root.width - wrapper.width) / 2 - rounding - startY: 0 - } - - BarPopouts.Background { - wrapper: root.panels.popouts - invertBottomRounding: wrapper.y + wrapper.height + 1 >= root.height - - startX: wrapper.x - startY: wrapper.y - rounding * sideRounding - } - - Utilities.Background { - wrapper: root.panels.utilities - sidebar: sidebar - - startX: root.width - startY: root.height - } - - Sidebar.Background { - id: sidebar - - wrapper: root.panels.sidebar - panels: root.panels - - startX: root.width - startY: root.panels.notifications.height - } -} diff --git a/modules/drawers/Border.qml b/modules/drawers/Border.qml deleted file mode 100644 index 6fdd73bd7..000000000 --- a/modules/drawers/Border.qml +++ /dev/null @@ -1,44 +0,0 @@ -pragma ComponentBehavior: Bound - -import qs.components -import qs.services -import qs.config -import QtQuick -import QtQuick.Effects - -Item { - id: root - - required property Item bar - - anchors.fill: parent - - StyledRect { - anchors.fill: parent - color: Colours.palette.m3surface - - layer.enabled: true - layer.effect: MultiEffect { - maskSource: mask - maskEnabled: true - maskInverted: true - maskThresholdMin: 0.5 - maskSpreadAtMin: 1 - } - } - - Item { - id: mask - - anchors.fill: parent - layer.enabled: true - visible: false - - Rectangle { - anchors.fill: parent - anchors.margins: Config.border.thickness - anchors.leftMargin: root.bar.implicitWidth - radius: Config.border.rounding - } - } -} diff --git a/modules/drawers/ContentWindow.qml b/modules/drawers/ContentWindow.qml new file mode 100644 index 000000000..677c84a09 --- /dev/null +++ b/modules/drawers/ContentWindow.qml @@ -0,0 +1,318 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Effects +import Quickshell +import Quickshell.Hyprland +import Quickshell.Wayland +import Caelestia.Blobs +import Caelestia.Config +import qs.components +import qs.components.containers +import qs.services +import qs.modules.bar + +StyledWindow { + id: root + + readonly property alias bar: bar + readonly property alias interactionWrapper: interactions + + readonly property HyprlandMonitor monitor: Hypr.monitorFor(screen) + readonly property bool hasSpecialWorkspace: (monitor?.lastIpcObject.specialWorkspace?.name.length ?? 0) > 0 + readonly property bool hasFullscreen: { + if (hasSpecialWorkspace) { + const specialName = monitor?.lastIpcObject.specialWorkspace?.name; + if (!specialName) + return false; + const specialWs = Hypr.workspaces.values.find(ws => ws.name === specialName); + return specialWs?.toplevels.values.some(t => t.lastIpcObject.fullscreen > 1) ?? false; + } + return monitor?.activeWorkspace?.toplevels.values.some(t => t.lastIpcObject.fullscreen > 1) ?? false; + } + + property real fsTransitionProg: hasFullscreen ? 1 : 0 + readonly property real sdfBorderOffset: 2 * fsTransitionProg // SDFs joins are not exact, so offset by 2px to ensure nothing shows + readonly property real borderThickness: contentItem.Config.border.thickness * (1 - fsTransitionProg) + readonly property real borderRounding: contentItem.Config.border.rounding * (1 - fsTransitionProg) + readonly property real shadowOpacity: 0.7 * (1 - fsTransitionProg) + readonly property real borderLayoutThickness: hasFullscreen ? 0 : contentItem.Config.border.thickness + + readonly property int dragMaskPadding: { + if (focusGrab.active || panels.popouts.isDetached) + return 0; + + if (monitor?.lastIpcObject.specialWorkspace?.name || monitor?.activeWorkspace.lastIpcObject.windows > 0) + return 0; + + const thresholds = []; + for (const panel of ["dashboard", "launcher", "session", "sidebar"]) + if (contentItem.Config[panel].enabled) + thresholds.push(contentItem.Config[panel].dragThreshold); + return Math.max(...thresholds); + } + + onHasFullscreenChanged: { + visibilities.launcher = false; + visibilities.session = false; + visibilities.dashboard = false; + panels.popouts.close(); + } + + name: "drawers" + WlrLayershell.exclusionMode: ExclusionMode.Ignore + WlrLayershell.layer: fsTransitionProg > 0 && contentItem.Config.general.showOverFullscreen ? WlrLayer.Overlay : WlrLayer.Top + WlrLayershell.keyboardFocus: visibilities.launcher || visibilities.session || panels.dashboard.needsKeyboard ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None + + mask: hasFullscreen ? emptyRegion : regions + + anchors.top: true + anchors.bottom: true + anchors.left: true + anchors.right: true + + Behavior on fsTransitionProg { + Anim {} + } + + Region { + id: emptyRegion + + x: panels.notifications.x + bar.implicitWidth + y: panels.notifications.y + root.borderThickness + width: panels.notifications.width + height: panels.notifications.height + + Region { + x: root.width - width + y: panels.osdWrapper.y + root.borderThickness + width: panels.osdWrapper.width * (1 - panels.osd.offsetScale) + root.borderThickness + height: panels.osd.height + } + } + + Regions { + id: regions + + bar: bar + panels: panels + win: root + } + + HyprlandFocusGrab { + id: focusGrab + + active: (visibilities.launcher && root.contentItem.Config.launcher.enabled) || (visibilities.session && root.contentItem.Config.session.enabled) || (visibilities.sidebar && root.contentItem.Config.sidebar.enabled) || (!root.contentItem.Config.dashboard.showOnHover && visibilities.dashboard && root.contentItem.Config.dashboard.enabled) || (panels.popouts.currentName.startsWith("traymenu") && (panels.popouts.current as StackView)?.depth > 1) + windows: [root] + 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, Math.max(0, root.shadowOpacity)) + } + + BlobGroup { + id: blobGroup + + color: Colours.palette.m3surface + smoothing: root.contentItem.Config.border.smoothing + + Behavior on color { + CAnim {} + } + } + + BlobInvertedRect { + anchors.fill: parent + anchors.margins: -50 // Make border thicker to smooth out bulge from closed drawers + group: blobGroup + radius: root.borderRounding + borderLeft: bar.implicitWidth - anchors.margins - root.sdfBorderOffset + borderRight: root.borderThickness - anchors.margins - root.sdfBorderOffset + borderTop: root.borderThickness - anchors.margins - root.sdfBorderOffset + borderBottom: root.borderThickness - anchors.margins - root.sdfBorderOffset + } + + PanelBg { + id: dashBg + + panel: panels.dashboard + deformAmount: 0.1 + } + + PanelBg { + id: launcherBg + + panel: panels.launcher + deformAmount: 0.1 + } + + PanelBg { + id: sessionBg + + panel: panels.sessionWrapper + deformAmount: 0.2 + x: panels.sessionWrapper.x + panels.session.x + bar.implicitWidth + implicitWidth: panels.session.width + } + + PanelBg { + id: sidebarBg + + panel: panels.sidebar + deformAmount: 0.03 + implicitHeight: panel.height * (1 / rawDeformMatrix.m22) + 2 + exclude: panels.sidebar.offsetScale > 0.08 ? [] : [utilsBg] + bottomLeftRadius: Math.max(0, Math.min(1, panels.sidebar.offsetScale / 0.3)) * radius + } + + PanelBg { + id: osdBg + + panel: panels.osdWrapper + deformAmount: 0.25 + x: panels.osdWrapper.x + panels.osd.x + bar.implicitWidth + implicitWidth: panels.osd.width + } + + PanelBg { + id: notifsBg + + panel: panels.notifications + } + + PanelBg { + id: utilsBg + + panel: panels.utilities + deformAmount: panels.sidebar.visible ? 0.1 : 0.15 + exclude: panels.sidebar.offsetScale > 0.08 ? [] : [sidebarBg] + topLeftRadius: Math.max(0, Math.min(1, panels.sidebar.offsetScale / 0.3)) * radius + } + + PanelBg { + id: popoutBg + + // Extra width to prevent vertical movement deformation partially detaching panel from bar + property real extraWidth: panels.popouts.isDetached ? 0 : 0.2 + + panel: panels.popoutsWrapper + deformAmount: panels.popouts.isDetached ? 0.05 : panels.popouts.hasCurrent ? 0.15 : 0.1 + x: panels.popoutsWrapper.x + panels.popouts.x + bar.implicitWidth - panels.popouts.width * extraWidth + implicitWidth: panels.popouts.width * (1 + extraWidth) + + Behavior on extraWidth { + Anim { + type: Anim.DefaultSpatial + } + } + } + } + + DrawerVisibilities { + id: visibilities + + Component.onCompleted: Visibilities.load(root.screen, this) + } + + Interactions { + id: interactions + + screen: root.screen + popouts: panels.popouts + visibilities: visibilities + panels: panels + bar: bar + borderThickness: root.borderLayoutThickness + fullscreen: root.hasFullscreen + + Panels { + id: panels + + screen: root.screen + visibilities: visibilities + bar: bar + borderThickness: root.borderThickness + + utilities.horizontalStretch: (sidebarBg.rawDeformMatrix.m11 - 1) / 2 + 1 + utilities.deformMatrix: utilsBg.rawDeformMatrix + + dashboard.transform: Matrix4x4 { + matrix: dashBg.deformMatrix + } + launcher.transform: Matrix4x4 { + matrix: launcherBg.deformMatrix + } + session.transform: Matrix4x4 { + matrix: sessionBg.deformMatrix + } + sidebar.transform: Matrix4x4 { + matrix: sidebarBg.deformMatrix + } + osd.transform: Matrix4x4 { + matrix: osdBg.deformMatrix + } + notifications.transform: Matrix4x4 { + matrix: notifsBg.deformMatrix + } + utilities.transform: Matrix4x4 { + matrix: utilsBg.deformMatrix + } + popouts.transform: Matrix4x4 { + matrix: popoutBg.deformMatrix + } + } + + BarWrapper { + id: bar + + anchors.top: parent.top + anchors.bottom: parent.bottom + + screen: root.screen + visibilities: visibilities + popouts: panels.popouts + + fullscreen: root.hasFullscreen + + Component.onCompleted: Visibilities.bars.set(root.screen, this) + } + } + + component PanelBg: BlobRect { + required property Item panel + property real deformAmount: 0.15 + + group: blobGroup + x: panel.x + bar.implicitWidth + y: panel.y + root.borderThickness + implicitWidth: panel.width + implicitHeight: panel.height + radius: Tokens.rounding.large + deformScale: (deformAmount * Config.appearance.deformScale) / 10000 + } +} diff --git a/modules/drawers/Drawers.qml b/modules/drawers/Drawers.qml index 00f9596a4..c642692c3 100644 --- a/modules/drawers/Drawers.qml +++ b/modules/drawers/Drawers.qml @@ -1,193 +1,24 @@ -pragma ComponentBehavior: Bound - -import qs.components -import qs.components.containers -import qs.services -import qs.config -import qs.modules.bar import Quickshell -import Quickshell.Wayland -import Quickshell.Hyprland -import QtQuick -import QtQuick.Effects +import qs.services Variants { - model: Quickshell.screens + model: Screens.screens Scope { id: scope required property ShellScreen modelData - readonly property bool barDisabled: { - const regexChecker = /^\^.*\$$/; - for (const filter of Config.bar.excludedScreens) { - // If filter is a regex - if (regexChecker.test(filter)) { - if ((new RegExp(filter)).test(modelData.name)) - return true; - } else { - if (filter === modelData.name) - return true; - } - } - return false; - } Exclusions { screen: scope.modelData - bar: bar + bar: drawerWindow.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; - } + ContentWindow { + id: drawerWindow screen: scope.modelData name: "drawers" - WlrLayershell.exclusionMode: ExclusionMode.Ignore - WlrLayershell.keyboardFocus: visibilities.launcher || visibilities.session ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None - - mask: Region { - x: bar.implicitWidth + win.dragMaskPadding - y: Config.border.thickness + win.dragMaskPadding - width: win.width - bar.implicitWidth - Config.border.thickness - win.dragMaskPadding * 2 - height: win.height - Config.border.thickness * 2 - win.dragMaskPadding * 2 - intersection: Intersection.Xor - - regions: regions.instances - } - - anchors.top: true - anchors.bottom: true - anchors.left: true - anchors.right: true - - Variants { - id: regions - - model: panels.children - - Region { - required property Item modelData - - x: modelData.x + bar.implicitWidth - y: modelData.y + Config.border.thickness - width: modelData.width - height: modelData.height - intersection: Intersection.Subtract - } - } - - HyprlandFocusGrab { - id: focusGrab - - active: (visibilities.launcher && Config.launcher.enabled) || (visibilities.session && Config.session.enabled) || (visibilities.sidebar && Config.sidebar.enabled) || (!Config.dashboard.showOnHover && visibilities.dashboard && Config.dashboard.enabled) || (panels.popouts.currentName.startsWith("traymenu") && panels.popouts.current?.depth > 1) - windows: [win] - onCleared: { - visibilities.launcher = false; - visibilities.session = false; - visibilities.sidebar = false; - visibilities.dashboard = false; - panels.popouts.hasCurrent = false; - bar.closeTray(); - } - } - - StyledRect { - anchors.fill: parent - opacity: visibilities.session && Config.session.enabled ? 0.5 : 0 - color: Colours.palette.m3scrim - - Behavior on opacity { - Anim {} - } - } - - Item { - anchors.fill: parent - opacity: Colours.transparency.enabled ? Colours.transparency.base : 1 - layer.enabled: true - layer.effect: MultiEffect { - shadowEnabled: true - blurMax: 15 - shadowColor: Qt.alpha(Colours.palette.m3shadow, 0.7) - } - - Border { - bar: bar - } - - Backgrounds { - panels: panels - bar: bar - } - } - - PersistentProperties { - id: visibilities - - property bool bar - property bool osd - property bool session - property bool launcher - property bool dashboard - property bool utilities - property bool sidebar - - Component.onCompleted: Visibilities.load(scope.modelData, this) - } - - Interactions { - screen: scope.modelData - popouts: panels.popouts - visibilities: visibilities - panels: panels - bar: bar - - Panels { - id: panels - - screen: scope.modelData - visibilities: visibilities - bar: bar - } - - BarWrapper { - id: bar - - anchors.top: parent.top - anchors.bottom: parent.bottom - - screen: scope.modelData - visibilities: visibilities - popouts: panels.popouts - - disabled: scope.barDisabled - - Component.onCompleted: Visibilities.bars.set(scope.modelData, this) - } - } } } } diff --git a/modules/drawers/Exclusions.qml b/modules/drawers/Exclusions.qml index e4015c89a..fe72730c3 100644 --- a/modules/drawers/Exclusions.qml +++ b/modules/drawers/Exclusions.qml @@ -1,15 +1,16 @@ pragma ComponentBehavior: Bound -import qs.components.containers -import qs.config -import Quickshell import QtQuick +import Quickshell +import Caelestia.Config +import qs.components.containers +import qs.modules.bar as Bar Scope { id: root required property ShellScreen screen - required property Item bar + required property Bar.BarWrapper bar ExclusionZone { anchors.left: true @@ -31,7 +32,7 @@ Scope { component ExclusionZone: StyledWindow { screen: root.screen name: "border-exclusion" - exclusiveZone: Config.border.thickness + exclusiveZone: contentItem.Config.border.thickness mask: Region {} implicitWidth: 1 implicitHeight: 1 diff --git a/modules/drawers/Interactions.qml b/modules/drawers/Interactions.qml index 9579b15ae..b89cb0077 100644 --- a/modules/drawers/Interactions.qml +++ b/modules/drawers/Interactions.qml @@ -1,17 +1,22 @@ +import QtQuick +import QtQuick.Controls +import Quickshell +import Caelestia.Config +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 CustomMouseArea { id: root required property ShellScreen screen required property BarPopouts.Wrapper popouts - required property PersistentProperties visibilities + required property DrawerVisibilities visibilities required property Panels panels - required property Item bar + required property Bar.BarWrapper bar + required property real borderThickness + required property bool fullscreen property point dragStart property bool dashboardShortcutActive @@ -19,7 +24,7 @@ CustomMouseArea { property bool utilitiesShortcutActive function withinPanelHeight(panel: Item, x: real, y: real): bool { - const panelY = Config.border.thickness + panel.y; + const panelY = root.borderThickness + panel.y; return y >= panelY - Config.border.rounding && y <= panelY + panel.height + Config.border.rounding; } @@ -33,24 +38,29 @@ CustomMouseArea { } function inRightPanel(panel: Item, x: real, y: real): bool { - return x > bar.implicitWidth + panel.x && withinPanelHeight(panel, x, y); + 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 < Config.border.thickness + panel.y + panel.height && withinPanelWidth(panel, x, y); + const panelHeight = panel.height * (1 - (panel.offsetScale ?? 0)); // qmllint disable missing-property + return y < Math.max(Config.border.minThickness, Config.border.thickness + panelHeight) && withinPanelWidth(panel, x, y); } - function inBottomPanel(panel: Item, x: real, y: real): bool { - return y > root.height - Config.border.thickness - panel.height - Config.border.rounding && withinPanelWidth(panel, x, y); + function inBottomPanel(panel: Item, x: real, y: real, isCorner = false): bool { + const panelHeight = panel.height * (1 - (panel.offsetScale ?? 0)); // qmllint disable missing-property + return y > height - Math.max(Config.border.minThickness, Config.border.thickness + panelHeight) - (isCorner ? Config.border.rounding : 0) && withinPanelWidth(panel, x, y); } function onWheel(event: WheelEvent): void { + if (fullscreen) + return; if (event.x < bar.implicitWidth) { bar.handleWheel(event.y, event.angleDelta); } } anchors.fill: parent + acceptedButtons: fullscreen ? Qt.NoButton : Qt.AllButtons hoverEnabled: true onPressed: event => dragStart = Qt.point(event.x, event.y) @@ -68,7 +78,7 @@ CustomMouseArea { if (!utilitiesShortcutActive) visibilities.utilities = false; - if (!popouts.currentName.startsWith("traymenu") || (popouts.current?.depth ?? 0) <= 1) { + if (!popouts.currentName.startsWith("traymenu") || ((popouts.current as StackView)?.depth ?? 0) <= 1) { popouts.hasCurrent = false; bar.closeTray(); } @@ -87,21 +97,26 @@ CustomMouseArea { const dragX = x - dragStart.x; const dragY = y - dragStart.y; + if (fullscreen) { + root.panels.osd.hovered = inRightPanel(panels.osdWrapper, x, y); + return; + } + // Show bar in non-exclusive mode on hover - if (!visibilities.bar && Config.bar.showOnHover && x < bar.implicitWidth) + if (!visibilities.bar && Config.bar.showOnHover && x < bar.clampedWidth) bar.isHovered = true; // Show/hide bar on drag - if (pressed && dragStart.x < bar.implicitWidth) { + 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) { + if (panels.sidebar.offsetScale === 1) { // Show osd on hover - const showOsd = inRightPanel(panels.osd, x, y); + const showOsd = inRightPanel(panels.osdWrapper, x, y); // Always update visibility based on hover if not in shortcut mode if (!osdShortcutActive) { @@ -113,26 +128,26 @@ CustomMouseArea { root.panels.osd.hovered = true; } - const showSidebar = pressed && dragStart.x > bar.implicitWidth + panels.sidebar.x; + 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 (pressed && inRightPanel(panels.sessionWrapper, dragStart.x, dragStart.y) && withinPanelHeight(panels.sessionWrapper, 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) + if (showSidebar && panels.session.offsetScale <= 0 && 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; + const outOfSidebar = x < width - panels.sidebar.width * (1 - panels.sidebar.offsetScale); // Show osd on hover - const showOsd = outOfSidebar && inRightPanel(panels.osd, x, y); + const showOsd = outOfSidebar && inRightPanel(panels.osdWrapper, x, y); // Always update visibility based on hover if not in shortcut mode if (!osdShortcutActive) { @@ -145,7 +160,7 @@ CustomMouseArea { } // Show/hide session on drag - if (pressed && outOfSidebar && inRightPanel(panels.session, dragStart.x, dragStart.y) && withinPanelHeight(panels.session, x, y)) { + if (pressed && outOfSidebar && inRightPanel(panels.sessionWrapper, dragStart.x, dragStart.y) && withinPanelHeight(panels.sessionWrapper, x, y)) { if (dragX < -Config.session.dragThreshold) visibilities.session = true; else if (dragX > Config.session.dragThreshold) @@ -188,7 +203,7 @@ CustomMouseArea { } // Show utilities on hover - const showUtilities = inBottomPanel(panels.utilities, x, y); + const showUtilities = inBottomPanel(panels.utilities, x, y, true); // Always update visibility based on hover if not in shortcut mode if (!utilitiesShortcutActive) { @@ -201,7 +216,7 @@ CustomMouseArea { // Show popouts on hover if (x < bar.implicitWidth) { bar.checkPopout(y); - } else if ((!popouts.currentName.startsWith("traymenu") || (popouts.current?.depth ?? 0) <= 1) && !inLeftPanel(panels.popouts, x, y)) { + } else if ((!popouts.currentName.startsWith("traymenu") || ((popouts.current as StackView)?.depth ?? 0) <= 1) && !inLeftPanel(panels.popoutsWrapper, x, y)) { popouts.hasCurrent = false; bar.closeTray(); } @@ -209,8 +224,6 @@ CustomMouseArea { // Monitor individual visibility changes Connections { - target: root.visibilities - function onLauncherChanged() { // If launcher is hidden, clear shortcut flags for dashboard and OSD if (!root.visibilities.launcher) { @@ -220,7 +233,7 @@ CustomMouseArea { // 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); + const inOsdArea = root.inRightPanel(root.panels.osdWrapper, root.mouseX, root.mouseY); if (!inDashboardArea) { root.visibilities.dashboard = false; @@ -248,7 +261,7 @@ CustomMouseArea { 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); + const inOsdArea = root.inRightPanel(root.panels.osdWrapper, root.mouseX, root.mouseY); if (!inOsdArea) { root.osdShortcutActive = true; } @@ -270,5 +283,7 @@ CustomMouseArea { root.utilitiesShortcutActive = false; } } + + target: root.visibilities } } diff --git a/modules/drawers/Panels.qml b/modules/drawers/Panels.qml index 7705732a7..4ca420e3e 100644 --- a/modules/drawers/Panels.qml +++ b/modules/drawers/Panels.qml @@ -1,69 +1,106 @@ -import qs.config -import qs.modules.osd as Osd +import QtQuick +import Quickshell +import Caelestia.Config +import qs.components +import qs.modules.bar as Bar +import qs.modules.dashboard as Dashboard +import qs.modules.launcher as Launcher import qs.modules.notifications as Notifications +import qs.modules.osd as Osd 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.sidebar as Sidebar import qs.modules.utilities as Utilities +import qs.modules.bar.popouts as BarPopouts import qs.modules.utilities.toasts as Toasts -import qs.modules.sidebar as Sidebar -import Quickshell -import QtQuick Item { id: root required property ShellScreen screen - required property PersistentProperties visibilities - required property Item bar + required property DrawerVisibilities visibilities + required property Bar.BarWrapper bar + required property real borderThickness readonly property alias osd: osd + readonly property alias osdWrapper: osdWrapper readonly property alias notifications: notifications readonly property alias session: session + readonly property alias sessionWrapper: sessionWrapper readonly property alias launcher: launcher readonly property alias dashboard: dashboard - readonly property alias popouts: popouts + readonly property alias popouts: popoutsWrapper.content + readonly property alias popoutsWrapper: popoutsWrapper readonly property alias utilities: utilities readonly property alias toasts: toasts readonly property alias sidebar: sidebar anchors.fill: parent - anchors.margins: Config.border.thickness + anchors.margins: borderThickness anchors.leftMargin: bar.implicitWidth - Osd.Wrapper { - id: osd + Behavior on anchors.margins { + Anim {} + } - clip: session.width > 0 || sidebar.width > 0 - screen: root.screen - visibilities: root.visibilities + Behavior on anchors.leftMargin { + Anim {} + } + + Item { + id: osdWrapper anchors.verticalCenter: parent.verticalCenter anchors.right: parent.right - anchors.rightMargin: session.width + sidebar.width + anchors.rightMargin: sessionWrapper.anchors.rightMargin + session.width * (1 - session.offsetScale) + clip: sidebar.visible || session.visible + + implicitWidth: osd.implicitWidth * (1 - osd.offsetScale) + implicitHeight: osd.implicitHeight + + Osd.Wrapper { + id: osd + + screen: root.screen + visibilities: root.visibilities + sidebarOrSessionVisible: sidebar.visible || session.visible + + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + } } Notifications.Wrapper { id: notifications visibilities: root.visibilities - panels: root + sidebarPanel: sidebar + osdPanel: osdWrapper + sessionPanel: sessionWrapper anchors.top: parent.top anchors.right: parent.right } - Session.Wrapper { - id: session - - clip: sidebar.width > 0 - visibilities: root.visibilities - panels: root + Item { + id: sessionWrapper anchors.verticalCenter: parent.verticalCenter anchors.right: parent.right - anchors.rightMargin: sidebar.width + anchors.rightMargin: sidebar.width * (1 - sidebar.offsetScale) + clip: sidebar.visible + + implicitWidth: session.implicitWidth * (1 - session.offsetScale) + implicitHeight: session.implicitHeight + + Session.Wrapper { + id: session + + visibilities: root.visibilities + sidebarVisible: sidebar.visible + + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + } } Launcher.Wrapper { @@ -86,22 +123,11 @@ Item { anchors.top: parent.top } - BarPopouts.Wrapper { - id: popouts + BarPopouts.ClipWrapper { + id: popoutsWrapper 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); - } + borderThickness: root.borderThickness } Utilities.Wrapper { @@ -109,7 +135,7 @@ Item { visibilities: root.visibilities sidebar: sidebar - popouts: popouts + popouts: popoutsWrapper.content anchors.bottom: parent.bottom anchors.right: parent.right @@ -120,14 +146,13 @@ Item { anchors.bottom: sidebar.visible ? parent.bottom : utilities.top anchors.right: sidebar.left - anchors.margins: Appearance.padding.normal + anchors.margins: Tokens.padding.normal } Sidebar.Wrapper { id: sidebar visibilities: root.visibilities - panels: root anchors.top: notifications.bottom anchors.bottom: utilities.top diff --git a/modules/drawers/Regions.qml b/modules/drawers/Regions.qml new file mode 100644 index 000000000..47ad06804 --- /dev/null +++ b/modules/drawers/Regions.qml @@ -0,0 +1,84 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Caelestia.Config +import qs.modules.bar as Bar + +Region { + id: root + + required property Bar.BarWrapper bar + required property Panels panels + required property var win + + readonly property real borderThickness: win.contentItem.Config.border.thickness + readonly property real clampedThickness: win.contentItem.Config.border.clampedThickness + + x: bar.clampedWidth + win.dragMaskPadding + y: clampedThickness + win.dragMaskPadding + width: win.width - bar.clampedWidth - clampedThickness - win.dragMaskPadding * 2 + height: win.height - clampedThickness * 2 - win.dragMaskPadding * 2 + intersection: Intersection.Xor + + R { + panel: root.panels.dashboard + y: 0 + height: panel.height * (1 - root.panels.dashboard.offsetScale) + root.borderThickness + } + + R { + panel: root.panels.launcher + y: root.win.height - height + height: panel.height * (1 - root.panels.launcher.offsetScale) + root.borderThickness + } + + R { + id: sessionRegion + + panel: root.panels.sessionWrapper + x: root.win.width - width + width: panel.width * (1 - root.panels.session.offsetScale) + root.borderThickness + sidebarRegion.width + } + + R { + id: sidebarRegion + + panel: root.panels.sidebar + x: root.win.width - width + width: panel.width * (1 - root.panels.sidebar.offsetScale) + root.borderThickness + } + + R { + panel: root.panels.osdWrapper + x: root.win.width - width + width: panel.width * (1 - root.panels.osd.offsetScale) + root.borderThickness + sessionRegion.width + } + + R { + panel: root.panels.notifications + y: 0 + height: panel.height + root.borderThickness + } + + R { + panel: root.panels.utilities + y: root.win.height - height + height: panel.height * (1 - root.panels.utilities.offsetScale) + root.borderThickness + } + + R { + panel: root.panels.popoutsWrapper + width: panel.width * (1 - root.panels.popoutsWrapper.offsetScale) + } + + component R: Region { + required property Item panel + + x: panel.x + root.bar.implicitWidth + y: panel.y + root.borderThickness + width: panel.width + height: panel.height + intersection: Intersection.Subtract + } +} diff --git a/modules/launcher/AppList.qml b/modules/launcher/AppList.qml index 7f7b843a9..a2109c040 100644 --- a/modules/launcher/AppList.qml +++ b/modules/launcher/AppList.qml @@ -1,20 +1,20 @@ pragma ComponentBehavior: Bound -import "items" -import "services" +import QtQuick +import Quickshell +import Caelestia.Config import qs.components -import qs.components.controls import qs.components.containers +import qs.components.controls import qs.services -import qs.config -import Quickshell -import QtQuick +import qs.modules.launcher.items +import qs.modules.launcher.services StyledListView { id: root required property StyledTextField search - required property PersistentProperties visibilities + required property DrawerVisibilities visibilities model: ScriptModel { id: model @@ -22,9 +22,9 @@ StyledListView { onValuesChanged: root.currentIndex = 0 } - spacing: Appearance.spacing.small + spacing: Tokens.spacing.small orientation: Qt.Vertical - implicitHeight: (Config.launcher.sizes.itemHeight + spacing) * Math.min(Config.launcher.maxShown, count) - spacing + implicitHeight: (Tokens.sizes.launcher.itemHeight + spacing) * Math.min(Config.launcher.maxShown, count) - spacing preferredHighlightBegin: 0 preferredHighlightEnd: height @@ -32,7 +32,7 @@ StyledListView { highlightFollowsCurrentItem: false highlight: StyledRect { - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal color: Colours.palette.m3onSurface opacity: 0.08 @@ -42,15 +42,14 @@ StyledListView { Behavior on y { Anim { - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + type: Anim.DefaultSpatial } } } state: { const text = search.text; - const prefix = Config.launcher.actionPrefix; + const prefix = GlobalConfig.launcher.actionPrefix; if (text.startsWith(prefix)) { for (const action of ["calc", "scheme", "variant"]) if (text.startsWith(`${prefix}${action} `)) @@ -118,16 +117,16 @@ StyledListView { property: "opacity" from: 1 to: 0 - duration: Appearance.anim.durations.small - easing.bezierCurve: Appearance.anim.curves.standardAccel + duration: Tokens.anim.durations.small + easing: Tokens.anim.standardAccel } Anim { target: root property: "scale" from: 1 to: 0.9 - duration: Appearance.anim.durations.small - easing.bezierCurve: Appearance.anim.curves.standardAccel + duration: Tokens.anim.durations.small + easing: Tokens.anim.standardAccel } } PropertyAction { @@ -140,16 +139,16 @@ StyledListView { property: "opacity" from: 0 to: 1 - duration: Appearance.anim.durations.small - easing.bezierCurve: Appearance.anim.curves.standardDecel + duration: Tokens.anim.durations.small + easing: Tokens.anim.standardDecel } Anim { target: root property: "scale" from: 0.9 to: 1 - duration: Appearance.anim.durations.small - easing.bezierCurve: Appearance.anim.curves.standardDecel + duration: Tokens.anim.durations.small + easing: Tokens.anim.standardDecel } } PropertyAction { @@ -197,7 +196,7 @@ StyledListView { addDisplaced: Transition { Anim { property: "y" - duration: Appearance.anim.durations.small + type: Anim.StandardSmall } Anim { properties: "opacity,scale" diff --git a/modules/launcher/Background.qml b/modules/launcher/Background.qml deleted file mode 100644 index 709c7d035..000000000 --- a/modules/launcher/Background.qml +++ /dev/null @@ -1,60 +0,0 @@ -import qs.components -import qs.services -import qs.config -import QtQuick -import QtQuick.Shapes - -ShapePath { - id: root - - required property Wrapper wrapper - readonly property real rounding: Config.border.rounding - readonly property bool flatten: wrapper.height < rounding * 2 - readonly property real roundingY: flatten ? wrapper.height / 2 : rounding - - strokeWidth: -1 - fillColor: Colours.palette.m3surface - - PathArc { - relativeX: root.rounding - relativeY: -root.roundingY - radiusX: root.rounding - radiusY: Math.min(root.rounding, root.wrapper.height) - direction: PathArc.Counterclockwise - } - PathLine { - relativeX: 0 - relativeY: -(root.wrapper.height - root.roundingY * 2) - } - PathArc { - relativeX: root.rounding - relativeY: -root.roundingY - radiusX: root.rounding - radiusY: Math.min(root.rounding, root.wrapper.height) - } - PathLine { - relativeX: root.wrapper.width - root.rounding * 2 - relativeY: 0 - } - PathArc { - relativeX: root.rounding - relativeY: root.roundingY - radiusX: root.rounding - radiusY: Math.min(root.rounding, root.wrapper.height) - } - PathLine { - relativeX: 0 - relativeY: root.wrapper.height - root.roundingY * 2 - } - PathArc { - relativeX: root.rounding - relativeY: root.roundingY - radiusX: root.rounding - radiusY: Math.min(root.rounding, root.wrapper.height) - direction: PathArc.Counterclockwise - } - - Behavior on fillColor { - CAnim {} - } -} diff --git a/modules/launcher/Content.qml b/modules/launcher/Content.qml index c08597698..69be0c3ae 100644 --- a/modules/launcher/Content.qml +++ b/modules/launcher/Content.qml @@ -1,22 +1,21 @@ pragma ComponentBehavior: Bound -import "services" +import QtQuick +import Caelestia.Config import qs.components import qs.components.controls import qs.services -import qs.config -import Quickshell -import QtQuick +import qs.modules.launcher.services Item { id: root - required property PersistentProperties visibilities + 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 + readonly property int padding: Tokens.padding.large + readonly property int rounding: Tokens.rounding.large implicitWidth: listWrapper.width + padding * 2 implicitHeight: searchWrapper.height + listWrapper.height + padding * 2 @@ -48,7 +47,7 @@ Item { id: searchWrapper color: Colours.layer(Colours.palette.m3surfaceContainer, 2) - radius: Appearance.rounding.full + radius: Tokens.rounding.full anchors.left: parent.left anchors.right: parent.right @@ -73,13 +72,13 @@ Item { anchors.left: searchIcon.right anchors.right: clearIcon.left - anchors.leftMargin: Appearance.spacing.small - anchors.rightMargin: Appearance.spacing.small + anchors.leftMargin: Tokens.spacing.small + anchors.rightMargin: Tokens.spacing.small - topPadding: Appearance.padding.larger - bottomPadding: Appearance.padding.larger + topPadding: Tokens.padding.larger + bottomPadding: Tokens.padding.larger - placeholderText: qsTr("Type \"%1\" for commands").arg(Config.launcher.actionPrefix) + placeholderText: qsTr("Type \"%1\" for commands").arg(GlobalConfig.launcher.actionPrefix) onAccepted: { const currentItem = list.currentList?.currentItem; @@ -89,8 +88,8 @@ Item { 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 `)) + } else if (text.startsWith(GlobalConfig.launcher.actionPrefix)) { + if (text.startsWith(`${GlobalConfig.launcher.actionPrefix}calc `)) currentItem.onClicked(); else currentItem.modelData.onClicked(list.currentList); @@ -107,14 +106,14 @@ Item { Keys.onEscapePressed: root.visibilities.launcher = false Keys.onPressed: event => { - if (!Config.launcher.vimKeybinds) + if (!GlobalConfig.launcher.vimKeybinds) return; if (event.modifiers & Qt.ControlModifier) { - if (event.key === Qt.Key_J) { + if (event.key === Qt.Key_J || event.key === Qt.Key_N) { list.currentList?.incrementCurrentIndex(); event.accepted = true; - } else if (event.key === Qt.Key_K) { + } else if (event.key === Qt.Key_K || event.key === Qt.Key_P) { list.currentList?.decrementCurrentIndex(); event.accepted = true; } @@ -130,8 +129,6 @@ Item { Component.onCompleted: forceActiveFocus() Connections { - target: root.visibilities - function onLauncherChanged(): void { if (!root.visibilities.launcher) search.text = ""; @@ -141,6 +138,8 @@ Item { if (!root.visibilities.session) search.forceActiveFocus(); } + + target: root.visibilities } } @@ -177,13 +176,13 @@ Item { Behavior on width { Anim { - duration: Appearance.anim.durations.small + type: Anim.StandardSmall } } Behavior on opacity { Anim { - duration: Appearance.anim.durations.small + type: Anim.StandardSmall } } } diff --git a/modules/launcher/ContentList.qml b/modules/launcher/ContentList.qml index b2a9c7708..db7abdf92 100644 --- a/modules/launcher/ContentList.qml +++ b/modules/launcher/ContentList.qml @@ -1,26 +1,25 @@ pragma ComponentBehavior: Bound +import QtQuick +import Caelestia.Config import qs.components import qs.components.controls import qs.services -import qs.config import qs.utils -import Quickshell -import QtQuick Item { id: root required property var content - required property PersistentProperties visibilities + required property 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 Item currentList: showWallpapers ? wallpaperList.item : appList.item + readonly property bool showWallpapers: search.text.startsWith(`${GlobalConfig.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 @@ -33,7 +32,7 @@ Item { name: "apps" PropertyChanges { - root.implicitWidth: Config.launcher.sizes.itemWidth + root.implicitWidth: root.Tokens.sizes.launcher.itemWidth root.implicitHeight: Math.min(root.maxHeight, appList.implicitHeight > 0 ? appList.implicitHeight : empty.implicitHeight) appList.active: true } @@ -47,8 +46,8 @@ Item { name: "wallpapers" PropertyChanges { - root.implicitWidth: Math.max(Config.launcher.sizes.itemWidth * 1.2, wallpaperList.implicitWidth) - root.implicitHeight: Config.launcher.sizes.wallpaperHeight + root.implicitWidth: Math.max(root.Tokens.sizes.launcher.itemWidth * 1.2, wallpaperList.implicitWidth) + root.implicitHeight: root.Tokens.sizes.launcher.wallpaperHeight wallpaperList.active: true } } @@ -61,7 +60,7 @@ Item { property: "opacity" from: 1 to: 0 - duration: Appearance.anim.durations.small + type: Anim.StandardSmall } PropertyAction {} Anim { @@ -69,7 +68,7 @@ Item { property: "opacity" from: 0 to: 1 - duration: Appearance.anim.durations.small + type: Anim.StandardSmall } } } @@ -90,6 +89,7 @@ Item { Loader { id: wallpaperList + asynchronous: true active: false anchors.top: parent.top @@ -110,8 +110,8 @@ Item { opacity: root.currentList?.count === 0 ? 1 : 0 scale: root.currentList?.count === 0 ? 1 : 0.5 - spacing: Appearance.spacing.normal - padding: Appearance.padding.large + spacing: Tokens.spacing.normal + padding: Tokens.padding.large anchors.horizontalCenter: parent.horizontalCenter anchors.verticalCenter: parent.verticalCenter @@ -119,7 +119,7 @@ Item { MaterialIcon { text: root.state === "wallpapers" ? "wallpaper_slideshow" : "manage_search" color: Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.extraLarge + font.pointSize: Tokens.font.size.extraLarge anchors.verticalCenter: parent.verticalCenter } @@ -130,14 +130,14 @@ Item { StyledText { text: root.state === "wallpapers" ? qsTr("No wallpapers found") : qsTr("No results") color: Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.larger + font.pointSize: Tokens.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 + font.pointSize: Tokens.font.size.normal } } @@ -154,8 +154,8 @@ Item { enabled: root.visibilities.launcher Anim { - duration: Appearance.anim.durations.large - easing.bezierCurve: Appearance.anim.curves.emphasizedDecel + duration: Tokens.anim.durations.large + easing: Tokens.anim.emphasizedDecel } } @@ -163,8 +163,8 @@ Item { enabled: root.visibilities.launcher Anim { - duration: Appearance.anim.durations.large - easing.bezierCurve: Appearance.anim.curves.emphasizedDecel + duration: Tokens.anim.durations.large + easing: Tokens.anim.emphasizedDecel } } } diff --git a/modules/launcher/WallpaperList.qml b/modules/launcher/WallpaperList.qml index 4aba4365b..3663f4bc4 100644 --- a/modules/launcher/WallpaperList.qml +++ b/modules/launcher/WallpaperList.qml @@ -1,11 +1,11 @@ pragma ComponentBehavior: Bound import "items" +import QtQuick +import Quickshell +import Caelestia.Config import qs.components.controls import qs.services -import qs.config -import Quickshell -import QtQuick PathView { id: root @@ -15,10 +15,10 @@ PathView { 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 itemWidth: Tokens.sizes.launcher.wallpaperWidth * 0.8 + Tokens.padding.larger * 2 readonly property int numItems: { - const screen = QsWindow.window?.screen; + const screen = (QsWindow.window as QsWindow)?.screen; if (!screen) return 0; @@ -58,7 +58,7 @@ PathView { onCurrentItemChanged: { if (currentItem) - Wallpapers.preview(currentItem.modelData.path); + Wallpapers.preview((currentItem as WallpaperItem).modelData.path); } implicitWidth: Math.min(numItems, count) * itemWidth diff --git a/modules/launcher/Wrapper.qml b/modules/launcher/Wrapper.qml index d62d726ab..d5630b233 100644 --- a/modules/launcher/Wrapper.qml +++ b/modules/launcher/Wrapper.qml @@ -1,111 +1,47 @@ pragma ComponentBehavior: Bound -import qs.components -import qs.config -import Quickshell import QtQuick +import Quickshell +import Caelestia.Config +import qs.components +import qs.modules.launcher.services Item { id: root required property ShellScreen screen - required property PersistentProperties visibilities + 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; + let max = screen.height - Config.border.thickness * 2 - Tokens.spacing.large; if (visibilities.dashboard) max -= panels.dashboard.nonAnimHeight; return max; } - onMaxHeightChanged: timer.start() - - visible: height > 0 - implicitHeight: 0 - implicitWidth: content.implicitWidth + property real offsetScale: shouldBeActive ? 0 : 1 onShouldBeActiveChanged: { - if (shouldBeActive) { - timer.stop(); - hideAnim.stop(); - showAnim.start(); - } else { - showAnim.stop(); - hideAnim.start(); - } + if (shouldBeActive) + implicitHeight = Qt.binding(() => content.implicitHeight); + else + implicitHeight = implicitHeight; // Break binding during close anim } - 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) - } - } + visible: offsetScale < 1 + anchors.bottomMargin: (-implicitHeight - 5) * offsetScale + implicitHeight: content.implicitHeight + implicitWidth: content.implicitWidth || 630 // Hard coded fallback for first open + opacity: 1 - offsetScale - SequentialAnimation { - id: hideAnim + Component.onCompleted: Qt.callLater(() => Apps) // Load apps on init - ScriptAction { - script: root.implicitHeight = root.implicitHeight - } + Behavior on offsetScale { Anim { - target: root - property: "implicitHeight" - to: 0 - easing.bezierCurve: Appearance.anim.curves.emphasized - } - } - - Connections { - target: Config.launcher - - function onEnabledChanged(): void { - timer.start(); - } - - function onMaxShownChanged(): void { - timer.start(); - } - } - - Connections { - target: DesktopEntries.applications - - function onValuesChanged(): void { - if (DesktopEntries.applications.values.length < Config.launcher.maxShown) - timer.start(); - } - } - - Timer { - id: timer - - interval: Appearance.anim.durations.extraLarge - onRunningChanged: { - if (running && !root.shouldBeActive) { - content.visible = false; - content.active = true; - } else { - root.contentHeight = Math.min(root.maxHeight, content.implicitHeight); - content.active = Qt.binding(() => root.shouldBeActive || root.visible); - content.visible = true; - if (showAnim.running) { - showAnim.stop(); - showAnim.start(); - } - } + type: Anim.DefaultSpatial } } @@ -115,16 +51,12 @@ Item { anchors.top: parent.top anchors.horizontalCenter: parent.horizontalCenter - visible: false - active: false - Component.onCompleted: timer.start() + active: root.shouldBeActive || root.visible sourceComponent: Content { visibilities: root.visibilities panels: root.panels maxHeight: root.maxHeight - - Component.onCompleted: root.contentHeight = implicitHeight } } } diff --git a/modules/launcher/items/ActionItem.qml b/modules/launcher/items/ActionItem.qml index e15802907..0f9fb5dd6 100644 --- a/modules/launcher/items/ActionItem.qml +++ b/modules/launcher/items/ActionItem.qml @@ -1,8 +1,7 @@ -import "../services" +import QtQuick +import Caelestia.Config import qs.components import qs.services -import qs.config -import QtQuick Item { id: root @@ -10,37 +9,34 @@ Item { required property var modelData required property var list - implicitHeight: Config.launcher.sizes.itemHeight + implicitHeight: Tokens.sizes.launcher.itemHeight anchors.left: parent?.left anchors.right: parent?.right StateLayer { - radius: Appearance.rounding.normal - - function onClicked(): void { - root.modelData?.onClicked(root.list); - } + radius: Tokens.rounding.normal + onClicked: root.modelData?.onClicked(root.list) } Item { anchors.fill: parent - anchors.leftMargin: Appearance.padding.larger - anchors.rightMargin: Appearance.padding.larger - anchors.margins: Appearance.padding.smaller + anchors.leftMargin: Tokens.padding.larger + anchors.rightMargin: Tokens.padding.larger + anchors.margins: Tokens.padding.smaller MaterialIcon { id: icon text: root.modelData?.icon ?? "" - font.pointSize: Appearance.font.size.extraLarge + font.pointSize: Tokens.font.size.extraLarge anchors.verticalCenter: parent.verticalCenter } Item { anchors.left: icon.right - anchors.leftMargin: Appearance.spacing.normal + anchors.leftMargin: Tokens.spacing.normal anchors.verticalCenter: icon.verticalCenter implicitWidth: parent.width - icon.width @@ -50,18 +46,18 @@ Item { id: name text: root.modelData?.name ?? "" - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.font.size.normal } StyledText { id: desc text: root.modelData?.desc ?? "" - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small color: Colours.palette.m3outline elide: Text.ElideRight - width: root.width - icon.width - Appearance.rounding.normal * 2 + width: root.width - icon.width - Tokens.rounding.normal * 2 anchors.top: name.bottom } diff --git a/modules/launcher/items/AppItem.qml b/modules/launcher/items/AppItem.qml index 48aace76d..80739c8f1 100644 --- a/modules/launcher/items/AppItem.qml +++ b/modules/launcher/items/AppItem.qml @@ -1,26 +1,26 @@ -import "../services" -import qs.components -import qs.services -import qs.config +import QtQuick import Quickshell import Quickshell.Widgets -import QtQuick +import Caelestia.Config +import qs.components +import qs.services +import qs.utils +import qs.modules.launcher.services Item { id: root required property DesktopEntry modelData - required property PersistentProperties visibilities + required property DrawerVisibilities visibilities - implicitHeight: Config.launcher.sizes.itemHeight + implicitHeight: Tokens.sizes.launcher.itemHeight anchors.left: parent?.left anchors.right: parent?.right StateLayer { - radius: Appearance.rounding.normal - - function onClicked(): void { + radius: Tokens.rounding.normal + onClicked: { Apps.launch(root.modelData); root.visibilities.launcher = false; } @@ -28,13 +28,14 @@ Item { Item { anchors.fill: parent - anchors.leftMargin: Appearance.padding.larger - anchors.rightMargin: Appearance.padding.larger - anchors.margins: Appearance.padding.smaller + anchors.leftMargin: Tokens.padding.larger + anchors.rightMargin: Tokens.padding.larger + anchors.margins: Tokens.padding.smaller IconImage { id: icon + asynchronous: true source: Quickshell.iconPath(root.modelData?.icon, "image-missing") implicitSize: parent.height * 0.8 @@ -43,31 +44,46 @@ Item { Item { anchors.left: icon.right - anchors.leftMargin: Appearance.spacing.normal + anchors.leftMargin: Tokens.spacing.normal anchors.verticalCenter: icon.verticalCenter - implicitWidth: parent.width - icon.width + 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 + font.pointSize: Tokens.font.size.normal } StyledText { id: comment text: (root.modelData?.comment || root.modelData?.genericName || root.modelData?.name) ?? "" - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small color: Colours.palette.m3outline elide: Text.ElideRight - width: root.width - icon.width - Appearance.rounding.normal * 2 + width: root.width - icon.width - favouriteIcon.width - Tokens.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(GlobalConfig.launcher.favouriteApps, root.modelData.id) + + sourceComponent: MaterialIcon { + text: "favorite" + fill: 1 + color: Colours.palette.m3primary + } + } } } diff --git a/modules/launcher/items/CalcItem.qml b/modules/launcher/items/CalcItem.qml index 65489d9bc..f746f96c6 100644 --- a/modules/launcher/items/CalcItem.qml +++ b/modules/launcher/items/CalcItem.qml @@ -1,46 +1,48 @@ -import qs.components -import qs.services -import qs.config -import Caelestia -import Quickshell import QtQuick import QtQuick.Layouts +import Quickshell +import Caelestia +import Caelestia.Config +import qs.components +import qs.services Item { id: root required property var list - readonly property string math: list.search.text.slice(`${Config.launcher.actionPrefix}calc `.length) + readonly property string math: list.search.text.slice(`${GlobalConfig.launcher.actionPrefix}calc `.length) function onClicked(): void { - Quickshell.execDetached(["wl-copy", Qalculator.eval(math, false)]); + Quickshell.execDetached(["wl-copy", Qalculator.rawResult]); root.list.visibilities.launcher = false; } - implicitHeight: Config.launcher.sizes.itemHeight + onMathChanged: { + if (math.length > 0) + Qalculator.evalAsync(math); + } + + implicitHeight: Tokens.sizes.launcher.itemHeight anchors.left: parent?.left anchors.right: parent?.right StateLayer { - radius: Appearance.rounding.normal - - function onClicked(): void { - root.onClicked(); - } + radius: Tokens.rounding.normal + onClicked: root.onClicked() } RowLayout { anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - anchors.margins: Appearance.padding.larger + anchors.margins: Tokens.padding.larger - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal MaterialIcon { text: "function" - font.pointSize: Appearance.font.size.extraLarge + font.pointSize: Tokens.font.size.extraLarge Layout.alignment: Qt.AlignVCenter } @@ -55,7 +57,7 @@ Item { return Colours.palette.m3onSurface; } - text: root.math.length > 0 ? Qalculator.eval(root.math) : qsTr("Type an expression to calculate") + text: root.math.length > 0 ? (Qalculator.result || qsTr("Calculating...")) : qsTr("Type an expression to calculate") elide: Text.ElideLeft Layout.fillWidth: true @@ -64,23 +66,23 @@ Item { StyledRect { color: Colours.palette.m3tertiary - radius: Appearance.rounding.normal + radius: Tokens.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 + implicitWidth: (stateLayer.containsMouse ? label.implicitWidth + label.anchors.rightMargin : 0) + icon.implicitWidth + Tokens.padding.normal * 2 + implicitHeight: Math.max(label.implicitHeight, icon.implicitHeight) + Tokens.padding.small * 2 Layout.alignment: Qt.AlignVCenter StateLayer { id: stateLayer - color: Colours.palette.m3onTertiary - - function onClicked(): void { - Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.terminal, "fish", "-C", `exec qalc -i '${root.math}'`]); + onClicked: { + Quickshell.execDetached(["app2unit", "--", ...GlobalConfig.general.apps.terminal, "fish", "-C", `exec qalc -i '${root.math}'`]); root.list.visibilities.launcher = false; } + + color: Colours.palette.m3onTertiary } StyledText { @@ -88,11 +90,11 @@ Item { anchors.verticalCenter: parent.verticalCenter anchors.right: icon.left - anchors.rightMargin: Appearance.spacing.small + anchors.rightMargin: Tokens.spacing.small text: qsTr("Open in calculator") color: Colours.palette.m3onTertiary - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.font.size.normal opacity: stateLayer.containsMouse ? 1 : 0 @@ -106,16 +108,16 @@ Item { anchors.verticalCenter: parent.verticalCenter anchors.right: parent.right - anchors.rightMargin: Appearance.padding.normal + anchors.rightMargin: Tokens.padding.normal text: "open_in_new" color: Colours.palette.m3onTertiary - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large } Behavior on implicitWidth { Anim { - easing.bezierCurve: Appearance.anim.curves.emphasized + type: Anim.Emphasized } } } diff --git a/modules/launcher/items/SchemeItem.qml b/modules/launcher/items/SchemeItem.qml index 3ff184681..ab0784bd6 100644 --- a/modules/launcher/items/SchemeItem.qml +++ b/modules/launcher/items/SchemeItem.qml @@ -1,8 +1,8 @@ -import "../services" +import QtQuick +import Caelestia.Config import qs.components import qs.services -import qs.config -import QtQuick +import qs.modules.launcher.services Item { id: root @@ -10,24 +10,21 @@ Item { required property Schemes.Scheme modelData required property var list - implicitHeight: Config.launcher.sizes.itemHeight + implicitHeight: Tokens.sizes.launcher.itemHeight anchors.left: parent?.left anchors.right: parent?.right StateLayer { - radius: Appearance.rounding.normal - - function onClicked(): void { - root.modelData?.onClicked(root.list); - } + radius: Tokens.rounding.normal + onClicked: root.modelData?.onClicked(root.list) } Item { anchors.fill: parent - anchors.leftMargin: Appearance.padding.larger - anchors.rightMargin: Appearance.padding.larger - anchors.margins: Appearance.padding.smaller + anchors.leftMargin: Tokens.padding.larger + anchors.rightMargin: Tokens.padding.larger + anchors.margins: Tokens.padding.smaller StyledRect { id: preview @@ -38,7 +35,7 @@ Item { border.color: Qt.alpha(`#${root.modelData?.colours?.outline}`, 0.5) color: `#${root.modelData?.colours?.surface}` - radius: Appearance.rounding.full + radius: Tokens.rounding.full implicitWidth: parent.height * 0.8 implicitHeight: parent.height * 0.8 @@ -57,27 +54,27 @@ Item { implicitWidth: preview.implicitWidth color: `#${root.modelData?.colours?.primary}` - radius: Appearance.rounding.full + radius: Tokens.rounding.full } } } Column { anchors.left: preview.right - anchors.leftMargin: Appearance.spacing.normal + anchors.leftMargin: Tokens.spacing.normal anchors.verticalCenter: parent.verticalCenter - width: parent.width - preview.width - anchors.leftMargin - (current.active ? current.width + Appearance.spacing.normal : 0) + width: parent.width - preview.width - anchors.leftMargin - (current.active ? current.width + Tokens.spacing.normal : 0) spacing: 0 StyledText { text: root.modelData?.flavour ?? "" - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.font.size.normal } StyledText { text: root.modelData?.name ?? "" - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small color: Colours.palette.m3outline elide: Text.ElideRight @@ -89,6 +86,7 @@ Item { Loader { id: current + asynchronous: true anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter @@ -97,7 +95,7 @@ Item { sourceComponent: MaterialIcon { text: "check" color: Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large } } } diff --git a/modules/launcher/items/VariantItem.qml b/modules/launcher/items/VariantItem.qml index 5c34fa89f..b13d5d8e4 100644 --- a/modules/launcher/items/VariantItem.qml +++ b/modules/launcher/items/VariantItem.qml @@ -1,8 +1,8 @@ -import "../services" +import QtQuick +import Caelestia.Config import qs.components import qs.services -import qs.config -import QtQuick +import qs.modules.launcher.services Item { id: root @@ -10,50 +10,47 @@ Item { required property M3Variants.Variant modelData required property var list - implicitHeight: Config.launcher.sizes.itemHeight + implicitHeight: Tokens.sizes.launcher.itemHeight anchors.left: parent?.left anchors.right: parent?.right StateLayer { - radius: Appearance.rounding.normal - - function onClicked(): void { - root.modelData?.onClicked(root.list); - } + radius: Tokens.rounding.normal + onClicked: root.modelData?.onClicked(root.list) } Item { anchors.fill: parent - anchors.leftMargin: Appearance.padding.larger - anchors.rightMargin: Appearance.padding.larger - anchors.margins: Appearance.padding.smaller + anchors.leftMargin: Tokens.padding.larger + anchors.rightMargin: Tokens.padding.larger + anchors.margins: Tokens.padding.smaller MaterialIcon { id: icon text: root.modelData?.icon ?? "" - font.pointSize: Appearance.font.size.extraLarge + font.pointSize: Tokens.font.size.extraLarge anchors.verticalCenter: parent.verticalCenter } Column { anchors.left: icon.right - anchors.leftMargin: Appearance.spacing.larger + anchors.leftMargin: Tokens.spacing.larger anchors.verticalCenter: icon.verticalCenter - width: parent.width - icon.width - anchors.leftMargin - (current.active ? current.width + Appearance.spacing.normal : 0) + width: parent.width - icon.width - anchors.leftMargin - (current.active ? current.width + Tokens.spacing.normal : 0) spacing: 0 StyledText { text: root.modelData?.name ?? "" - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.font.size.normal } StyledText { text: root.modelData?.description ?? "" - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small color: Colours.palette.m3outline elide: Text.ElideRight @@ -65,6 +62,7 @@ Item { Loader { id: current + asynchronous: true anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter @@ -73,7 +71,7 @@ Item { sourceComponent: MaterialIcon { text: "check" color: Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large } } } diff --git a/modules/launcher/items/WallpaperItem.qml b/modules/launcher/items/WallpaperItem.qml index 9fdac3f38..d09a894c6 100644 --- a/modules/launcher/items/WallpaperItem.qml +++ b/modules/launcher/items/WallpaperItem.qml @@ -1,34 +1,33 @@ +import QtQuick +import Quickshell +import Caelestia.Config +import Caelestia.Models import qs.components import qs.components.effects import qs.components.images import qs.services -import qs.config -import Caelestia.Models -import Quickshell -import QtQuick Item { id: root required property FileSystemEntry modelData - required property PersistentProperties visibilities + required property DrawerVisibilities visibilities scale: 0.5 opacity: 0 - z: PathView.z ?? 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 + implicitWidth: image.width + Tokens.padding.larger * 2 + implicitHeight: image.height + label.height + Tokens.spacing.small / 2 + Tokens.padding.large + Tokens.padding.normal StateLayer { - radius: Appearance.rounding.normal - - function onClicked(): void { + radius: Tokens.rounding.normal + onClicked: { Wallpapers.setWallpaper(root.modelData.path); root.visibilities.launcher = false; } @@ -49,27 +48,29 @@ Item { id: image anchors.horizontalCenter: parent.horizontalCenter - y: Appearance.padding.large + y: Tokens.padding.large color: Colours.tPalette.m3surfaceContainer - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal - implicitWidth: Config.launcher.sizes.wallpaperWidth + implicitWidth: Tokens.sizes.launcher.wallpaperWidth implicitHeight: implicitWidth / 16 * 9 MaterialIcon { anchors.centerIn: parent text: "image" color: Colours.tPalette.m3outline - font.pointSize: Appearance.font.size.extraLarge * 2 + font.pointSize: Tokens.font.size.extraLarge * 2 font.weight: 600 } CachingImage { + anchors.fill: parent path: root.modelData.path smooth: !root.PathView.view.moving - cache: true - - anchors.fill: parent + sourceSize: { + const dpr = (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1; + return Qt.size(image.implicitWidth * dpr, image.implicitHeight * dpr); + } } } @@ -77,15 +78,15 @@ Item { id: label anchors.top: image.bottom - anchors.topMargin: Appearance.spacing.small / 2 + anchors.topMargin: Tokens.spacing.small / 2 anchors.horizontalCenter: parent.horizontalCenter - width: image.width - Appearance.padding.normal * 2 + width: image.width - Tokens.padding.normal * 2 horizontalAlignment: Text.AlignHCenter elide: Text.ElideRight renderType: Text.QtRendering text: root.modelData.relativePath - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.font.size.normal } Behavior on scale { diff --git a/modules/launcher/services/Actions.qml b/modules/launcher/services/Actions.qml index 5c1cb6bb8..634b3e6ad 100644 --- a/modules/launcher/services/Actions.qml +++ b/modules/launcher/services/Actions.qml @@ -1,26 +1,26 @@ pragma Singleton import ".." +import QtQuick +import Quickshell +import Caelestia.Config 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); + return search.slice(GlobalConfig.launcher.actionPrefix.length); } list: variants.instances - useFuzzy: Config.launcher.useFuzzy.actions + useFuzzy: GlobalConfig.launcher.useFuzzy.actions Variants { id: variants - model: Config.launcher.actions.filter(a => (a.enabled ?? true) && (Config.launcher.enableDangerousActions || !(a.dangerous ?? false))) + model: GlobalConfig.launcher.actions.filter(a => (a.enabled ?? true) && (GlobalConfig.launcher.enableDangerousActions || !(a.dangerous ?? false))) Action {} } @@ -39,7 +39,7 @@ Searcher { return; if (command[0] === "autocomplete" && command.length > 1) { - list.search.text = `${Config.launcher.actionPrefix}${command[1]} `; + list.search.text = `${GlobalConfig.launcher.actionPrefix}${command[1]} `; } else if (command[0] === "setMode" && command.length > 1) { list.visibilities.launcher = false; Colours.setMode(command[1]); diff --git a/modules/launcher/services/Apps.qml b/modules/launcher/services/Apps.qml index c409a7bb0..168d9209d 100644 --- a/modules/launcher/services/Apps.qml +++ b/modules/launcher/services/Apps.qml @@ -1,9 +1,9 @@ pragma Singleton -import qs.config -import qs.utils -import Caelestia import Quickshell +import Caelestia +import Caelestia.Config +import qs.utils Searcher { id: root @@ -13,7 +13,7 @@ Searcher { if (entry.runInTerminal) Quickshell.execDetached({ - command: ["app2unit", "--", ...Config.general.apps.terminal, `${Quickshell.shellDir}/assets/wrap_term_launch.sh`, ...entry.command], + command: ["app2unit", "--", ...GlobalConfig.general.apps.terminal, `${Quickshell.shellDir}/assets/wrap_term_launch.sh`, ...entry.command], workingDirectory: entry.workingDirectory }); else @@ -24,7 +24,7 @@ Searcher { } function search(search: string): list { - const prefix = Config.launcher.specialPrefix; + const prefix = GlobalConfig.launcher.specialPrefix; if (search.startsWith(`${prefix}i `)) { keys = ["id", "name"]; @@ -66,12 +66,13 @@ Searcher { } list: appDb.apps - useFuzzy: Config.launcher.useFuzzy.apps + useFuzzy: GlobalConfig.launcher.useFuzzy.apps AppDb { id: appDb path: `${Paths.state}/apps.sqlite` - entries: DesktopEntries.applications.values.filter(a => !Config.launcher.hiddenApps.includes(a.id)) + favouriteApps: GlobalConfig.launcher.favouriteApps + entries: DesktopEntries.applications.values.filter(a => !Strings.testRegexList(GlobalConfig.launcher.hiddenApps, a.id)) } } diff --git a/modules/launcher/services/M3Variants.qml b/modules/launcher/services/M3Variants.qml index 963a4d435..4517d02cb 100644 --- a/modules/launcher/services/M3Variants.qml +++ b/modules/launcher/services/M3Variants.qml @@ -1,16 +1,16 @@ pragma Singleton import ".." -import qs.config -import qs.utils -import Quickshell import QtQuick +import Quickshell +import Caelestia.Config +import qs.utils Searcher { id: root function transformSearch(search: string): string { - return search.slice(`${Config.launcher.actionPrefix}variant `.length); + return search.slice(`${GlobalConfig.launcher.actionPrefix}variant `.length); } list: [ @@ -69,7 +69,7 @@ Searcher { description: qsTr("All colours are grayscale, no chroma.") } ] - useFuzzy: Config.launcher.useFuzzy.variants + useFuzzy: GlobalConfig.launcher.useFuzzy.variants component Variant: QtObject { required property string variant diff --git a/modules/launcher/services/Schemes.qml b/modules/launcher/services/Schemes.qml index dbb2dac0a..cc555f294 100644 --- a/modules/launcher/services/Schemes.qml +++ b/modules/launcher/services/Schemes.qml @@ -1,11 +1,11 @@ pragma Singleton import ".." -import qs.config -import qs.utils +import QtQuick import Quickshell import Quickshell.Io -import QtQuick +import Caelestia.Config +import qs.utils Searcher { id: root @@ -14,7 +14,7 @@ Searcher { property string currentVariant function transformSearch(search: string): string { - return search.slice(`${Config.launcher.actionPrefix}scheme `.length); + return search.slice(`${GlobalConfig.launcher.actionPrefix}scheme `.length); } function selector(item: var): string { @@ -26,7 +26,7 @@ Searcher { } list: schemes.instances - useFuzzy: Config.launcher.useFuzzy.schemes + useFuzzy: GlobalConfig.launcher.useFuzzy.schemes keys: ["name", "flavour"] weights: [0.9, 0.1] @@ -55,7 +55,7 @@ Searcher { for (const f of s) flat.push(f); - schemes.model = flat.sort((a, b) => (a.name + a.flavour).localeCompare((b.name + b.flavour))); + schemes.model = flat.sort((a, b) => String(a.name + a.flavour).localeCompare((b.name + b.flavour))); } } } diff --git a/modules/lock/Center.qml b/modules/lock/Center.qml index 19cf9d290..a987d4eb9 100644 --- a/modules/lock/Center.qml +++ b/modules/lock/Center.qml @@ -1,37 +1,37 @@ pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Caelestia.Config 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 + readonly property int centerWidth: Tokens.sizes.lock.centerWidth * centerScale Layout.preferredWidth: centerWidth Layout.fillWidth: false Layout.fillHeight: true - spacing: Appearance.spacing.large * 2 + spacing: Tokens.spacing.large * 2 RowLayout { Layout.alignment: Qt.AlignHCenter - spacing: Appearance.spacing.small + spacing: Tokens.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.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale) + font.family: Tokens.font.family.clock font.bold: true } @@ -39,8 +39,8 @@ ColumnLayout { 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.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale) + font.family: Tokens.font.family.clock font.bold: true } @@ -48,23 +48,24 @@ ColumnLayout { 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.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale) + font.family: Tokens.font.family.clock font.bold: true } Loader { - Layout.leftMargin: Appearance.spacing.small + asynchronous: true + Layout.leftMargin: Tokens.spacing.small Layout.alignment: Qt.AlignVCenter - active: Config.services.useTwelveHourClock + active: GlobalConfig.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.pointSize: Math.floor(Tokens.font.size.extraLarge * 2 * root.centerScale) + font.family: Tokens.font.family.clock font.bold: true } } @@ -72,24 +73,24 @@ ColumnLayout { StyledText { Layout.alignment: Qt.AlignHCenter - Layout.topMargin: -Appearance.padding.large * 2 + Layout.topMargin: -Tokens.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.pointSize: Math.floor(Tokens.font.size.extraLarge * root.centerScale) + font.family: Tokens.font.family.mono font.bold: true } StyledClippingRect { - Layout.topMargin: Appearance.spacing.large * 2 + Layout.topMargin: Tokens.spacing.large * 2 Layout.alignment: Qt.AlignHCenter implicitWidth: root.centerWidth / 2 implicitHeight: root.centerWidth / 2 color: Colours.tPalette.m3surfaceContainer - radius: Appearance.rounding.full + radius: Tokens.rounding.full MaterialIcon { anchors.centerIn: parent @@ -97,6 +98,7 @@ ColumnLayout { text: "person" color: Colours.palette.m3onSurfaceVariant font.pointSize: Math.floor(root.centerWidth / 4) + visible: pfp.status !== Image.Ready } CachingImage { @@ -111,10 +113,10 @@ ColumnLayout { Layout.alignment: Qt.AlignHCenter implicitWidth: root.centerWidth * 0.8 - implicitHeight: input.implicitHeight + Appearance.padding.small * 2 + implicitHeight: input.implicitHeight + Tokens.padding.small * 2 color: Colours.tPalette.m3surfaceContainer - radius: Appearance.rounding.full + radius: Tokens.rounding.full focus: true onActiveFocusChanged: { @@ -133,24 +135,24 @@ ColumnLayout { } StateLayer { - hoverEnabled: false - cursorShape: Qt.IBeamCursor - - function onClicked(): void { + onClicked: { parent.forceActiveFocus(); } + + hoverEnabled: false + cursorShape: Qt.IBeamCursor } RowLayout { id: input anchors.fill: parent - anchors.margins: Appearance.padding.small - spacing: Appearance.spacing.normal + anchors.margins: Tokens.padding.small + spacing: Tokens.spacing.normal Item { implicitWidth: implicitHeight - implicitHeight: fprintIcon.implicitHeight + Appearance.padding.small * 2 + implicitHeight: fprintIcon.implicitHeight + Tokens.padding.small * 2 MaterialIcon { id: fprintIcon @@ -158,13 +160,13 @@ ColumnLayout { anchors.centerIn: parent animate: true text: { - if (root.lock.pam.fprint.tries >= Config.lock.maxFprintTries) + if (root.lock.pam.fprint.tries >= GlobalConfig.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 + color: root.lock.pam.fprint.tries >= GlobalConfig.lock.maxFprintTries ? Colours.palette.m3error : Colours.palette.m3onSurface opacity: root.lock.pam.passwd.active ? 0 : 1 Behavior on opacity { @@ -186,17 +188,14 @@ ColumnLayout { StyledRect { implicitWidth: implicitHeight - implicitHeight: enterIcon.implicitHeight + Appearance.padding.small * 2 + implicitHeight: enterIcon.implicitHeight + Tokens.padding.small * 2 color: root.lock.pam.buffer ? Colours.palette.m3primary : Colours.layer(Colours.palette.m3surfaceContainerHigh, 2) - radius: Appearance.rounding.full + radius: Tokens.rounding.full StateLayer { color: root.lock.pam.buffer ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface - - function onClicked(): void { - root.lock.pam.passwd.start(); - } + onClicked: root.lock.pam.passwd.start() } MaterialIcon { @@ -213,7 +212,7 @@ ColumnLayout { Item { Layout.fillWidth: true - Layout.topMargin: -Appearance.spacing.large + Layout.topMargin: -Tokens.spacing.large implicitHeight: Math.max(message.implicitHeight, stateMessage.implicitHeight) @@ -270,7 +269,7 @@ ColumnLayout { color: Colours.palette.m3onSurfaceVariant animateProp: "opacity" - font.family: Appearance.font.family.mono + font.family: Tokens.font.family.mono horizontalAlignment: Qt.AlignHCenter wrapMode: Text.WrapAtWordBoundaryOrAnywhere lineHeight: 1.2 @@ -325,8 +324,8 @@ ColumnLayout { opacity: 0 color: Colours.palette.m3error - font.pointSize: Appearance.font.size.small - font.family: Appearance.font.family.mono + font.pointSize: Tokens.font.size.small + font.family: Tokens.font.family.mono horizontalAlignment: Qt.AlignHCenter wrapMode: Text.WrapAtWordBoundaryOrAnywhere @@ -355,8 +354,6 @@ ColumnLayout { } Connections { - target: root.lock.pam - function onFlashMsg(): void { exitAnim.stop(); if (message.scale < 1) @@ -364,6 +361,8 @@ ColumnLayout { else flashAnim.restart(); } + + target: root.lock.pam } Anim { @@ -395,13 +394,13 @@ ColumnLayout { target: message property: "scale" to: 0.7 - duration: Appearance.anim.durations.large + type: Anim.StandardLarge } Anim { target: message property: "opacity" to: 0 - duration: Appearance.anim.durations.large + type: Anim.StandardLarge } } } @@ -410,7 +409,7 @@ ColumnLayout { component FlashAnim: NumberAnimation { target: message property: "opacity" - duration: Appearance.anim.durations.small + duration: Tokens.anim.durations.small easing.type: Easing.Linear } } diff --git a/modules/lock/Content.qml b/modules/lock/Content.qml index a024ddc23..0ff7daad4 100644 --- a/modules/lock/Content.qml +++ b/modules/lock/Content.qml @@ -1,26 +1,26 @@ -import qs.components -import qs.services -import qs.config import QtQuick import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.services RowLayout { id: root required property var lock - spacing: Appearance.spacing.large * 2 + spacing: Tokens.spacing.large * 2 ColumnLayout { Layout.fillWidth: true - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal StyledRect { Layout.fillWidth: true implicitHeight: weather.implicitHeight - topLeftRadius: Appearance.rounding.large - radius: Appearance.rounding.small + topLeftRadius: Tokens.rounding.large + radius: Tokens.rounding.small color: Colours.tPalette.m3surfaceContainer WeatherInfo { @@ -34,7 +34,7 @@ RowLayout { Layout.fillWidth: true Layout.fillHeight: true - radius: Appearance.rounding.small + radius: Tokens.rounding.small color: Colours.tPalette.m3surfaceContainer Fetch {} @@ -44,8 +44,8 @@ RowLayout { Layout.fillWidth: true implicitHeight: media.implicitHeight - bottomLeftRadius: Appearance.rounding.large - radius: Appearance.rounding.small + bottomLeftRadius: Tokens.rounding.large + radius: Tokens.rounding.small color: Colours.tPalette.m3surfaceContainer Media { @@ -62,14 +62,14 @@ RowLayout { ColumnLayout { Layout.fillWidth: true - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal StyledRect { Layout.fillWidth: true implicitHeight: resources.implicitHeight - topRightRadius: Appearance.rounding.large - radius: Appearance.rounding.small + topRightRadius: Tokens.rounding.large + radius: Tokens.rounding.small color: Colours.tPalette.m3surfaceContainer Resources { @@ -81,8 +81,8 @@ RowLayout { Layout.fillWidth: true Layout.fillHeight: true - bottomRightRadius: Appearance.rounding.large - radius: Appearance.rounding.small + bottomRightRadius: Tokens.rounding.large + radius: Tokens.rounding.small color: Colours.tPalette.m3surfaceContainer NotifDock { diff --git a/modules/lock/Fetch.qml b/modules/lock/Fetch.qml index ded56084b..0afbe4557 100644 --- a/modules/lock/Fetch.qml +++ b/modules/lock/Fetch.qml @@ -1,41 +1,41 @@ pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Quickshell.Services.UPower +import Caelestia.Config 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 + anchors.margins: Tokens.padding.large * 2 + anchors.topMargin: Tokens.padding.large - spacing: Appearance.spacing.small + spacing: Tokens.spacing.small RowLayout { Layout.fillWidth: true Layout.fillHeight: false - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal StyledRect { - implicitWidth: prompt.implicitWidth + Appearance.padding.normal * 2 - implicitHeight: prompt.implicitHeight + Appearance.padding.normal * 2 + implicitWidth: prompt.implicitWidth + Tokens.padding.normal * 2 + implicitHeight: prompt.implicitHeight + Tokens.padding.normal * 2 color: Colours.palette.m3primary - radius: Appearance.rounding.small + radius: Tokens.rounding.small MonoText { id: prompt anchors.centerIn: parent text: ">" - font.pointSize: root.width > 400 ? Appearance.font.size.larger : Appearance.font.size.normal + font.pointSize: root.width > 400 ? Tokens.font.size.larger : Tokens.font.size.normal color: Colours.palette.m3onPrimary } } @@ -43,7 +43,7 @@ ColumnLayout { MonoText { Layout.fillWidth: true text: "caelestiafetch.sh" - font.pointSize: root.width > 400 ? Appearance.font.size.larger : Appearance.font.size.normal + font.pointSize: root.width > 400 ? Tokens.font.size.larger : Tokens.font.size.normal elide: Text.ElideRight } @@ -51,7 +51,7 @@ ColumnLayout { Layout.fillHeight: true active: !iconLoader.active - sourceComponent: OsLogo {} + sourceComponent: SysInfo.isDefaultLogo ? caelestiaLogo : distroIcon } } @@ -66,15 +66,15 @@ ColumnLayout { Layout.fillHeight: true active: root.width > 320 - sourceComponent: OsLogo {} + sourceComponent: SysInfo.isDefaultLogo ? caelestiaLogo : distroIcon } ColumnLayout { Layout.fillWidth: true - Layout.topMargin: Appearance.padding.normal - Layout.bottomMargin: Appearance.padding.normal + Layout.topMargin: Tokens.padding.normal + Layout.bottomMargin: Tokens.padding.normal Layout.leftMargin: iconLoader.active ? 0 : width * 0.1 - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal WrappedLoader { Layout.fillWidth: true @@ -125,41 +125,54 @@ ColumnLayout { active: root.height > 180 sourceComponent: RowLayout { - spacing: Appearance.spacing.large + spacing: Tokens.spacing.large Repeater { - model: Math.max(0, Math.min(8, root.width / (Appearance.font.size.larger * 2 + Appearance.spacing.large))) + model: Math.max(0, Math.min(8, root.width / (Tokens.font.size.larger * 2 + Tokens.spacing.large))) StyledRect { required property int index implicitWidth: implicitHeight - implicitHeight: Appearance.font.size.larger * 2 + implicitHeight: Tokens.font.size.larger * 2 color: Colours.palette[`term${index}`] - radius: Appearance.rounding.small + radius: Tokens.rounding.small } } } } - component WrappedLoader: Loader { - visible: active + 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 OsLogo: ColouredIcon { - source: SysInfo.osLogo - implicitSize: height - colour: Colours.palette.m3primary - layer.enabled: Config.lock.recolourLogo || SysInfo.isDefaultLogo + 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 + font.pointSize: root.width > 400 ? Tokens.font.size.larger : Tokens.font.size.normal elide: Text.ElideRight } component MonoText: StyledText { - font.family: Appearance.font.family.mono + font.family: Tokens.font.family.mono } } diff --git a/modules/lock/InputField.qml b/modules/lock/InputField.qml index 358093f39..1b6c34a73 100644 --- a/modules/lock/InputField.qml +++ b/modules/lock/InputField.qml @@ -1,11 +1,11 @@ pragma ComponentBehavior: Bound -import qs.components -import qs.services -import qs.config -import Quickshell import QtQuick import QtQuick.Layouts +import Quickshell +import Caelestia.Config +import qs.components +import qs.services Item { id: root @@ -20,8 +20,6 @@ Item { clip: true Connections { - target: root.pam - function onBufferChanged(): void { if (root.pam.buffer.length > root.buffer.length) { charList.bindImWidth(); @@ -32,6 +30,8 @@ Item { root.buffer = root.pam.buffer; } + + target: root.pam } StyledText { @@ -49,8 +49,8 @@ Item { 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 + font.pointSize: Tokens.font.size.normal + font.family: Tokens.font.family.mono opacity: root.buffer ? 0 : 1 @@ -74,10 +74,10 @@ Item { anchors.horizontalCenterOffset: implicitWidth > root.width ? -(implicitWidth - root.width) / 2 : 0 implicitWidth: fullWidth - implicitHeight: Appearance.font.size.normal + implicitHeight: Tokens.font.size.normal orientation: Qt.Horizontal - spacing: Appearance.spacing.small / 2 + spacing: Tokens.spacing.small / 2 interactive: false model: ScriptModel { @@ -91,7 +91,7 @@ Item { implicitHeight: charList.implicitHeight color: Colours.palette.m3onSurface - radius: Appearance.rounding.small / 2 + radius: Tokens.rounding.small / 2 opacity: 0 scale: 0 @@ -134,8 +134,7 @@ Item { Behavior on scale { Anim { - duration: Appearance.anim.durations.expressiveFastSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + type: Anim.FastSpatial } } } diff --git a/modules/lock/Lock.qml b/modules/lock/Lock.qml index 6fd5277fe..f852cb7ff 100644 --- a/modules/lock/Lock.qml +++ b/modules/lock/Lock.qml @@ -1,9 +1,10 @@ pragma ComponentBehavior: Bound -import qs.components.misc +import QtQuick import Quickshell import Quickshell.Io import Quickshell.Wayland +import qs.components.misc Scope { property alias lock: lock @@ -25,21 +26,37 @@ Scope { lock: lock } + Loader { + asynchronous: true + active: true + onLoaded: active = false + + // Force a load of a screencopy so the one in the lock works + // My guess is the ICC backend loads async on first request, which if the lock is + // the first request it fails to capture (because it's async and the compositor + // refuses capture when locked) + sourceComponent: ScreencopyView { + captureSource: Quickshell.screens[0] + } + } + + // qmllint disable unresolved-type CustomShortcut { + // qmllint enable unresolved-type name: "lock" description: "Lock the current session" onPressed: lock.locked = true } + // qmllint disable unresolved-type CustomShortcut { + // qmllint enable unresolved-type name: "unlock" description: "Unlock the current session" onPressed: lock.unlock() } IpcHandler { - target: "lock" - function lock(): void { lock.locked = true; } @@ -51,5 +68,7 @@ Scope { function isLocked(): bool { return lock.locked; } + + target: "lock" } } diff --git a/modules/lock/LockSurface.qml b/modules/lock/LockSurface.qml index 279c55138..322773fd8 100644 --- a/modules/lock/LockSurface.qml +++ b/modules/lock/LockSurface.qml @@ -1,11 +1,11 @@ pragma ComponentBehavior: Bound -import qs.components -import qs.services -import qs.config -import Quickshell.Wayland import QtQuick import QtQuick.Effects +import Quickshell.Wayland +import Caelestia.Config +import qs.components +import qs.services WlSessionLockSurface { id: root @@ -15,14 +15,17 @@ WlSessionLockSurface { readonly property alias unlocking: unlockAnim.running + contentItem.Config.screen: screen.name + contentItem.Tokens.screen: screen.name + color: "transparent" Connections { - target: root.lock - function onUnlock(): void { unlockAnim.start(); } + + target: root.lock } SequentialAnimation { @@ -33,8 +36,7 @@ WlSessionLockSurface { target: lockContent properties: "implicitWidth,implicitHeight" to: lockContent.size - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + type: Anim.DefaultSpatial } Anim { target: lockBg @@ -45,30 +47,29 @@ WlSessionLockSurface { target: content property: "scale" to: 0 - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + type: Anim.DefaultSpatial } Anim { target: content property: "opacity" to: 0 - duration: Appearance.anim.durations.small + type: Anim.StandardSmall } Anim { target: lockIcon property: "opacity" to: 1 - duration: Appearance.anim.durations.large + type: Anim.StandardLarge } Anim { target: background property: "opacity" to: 0 - duration: Appearance.anim.durations.large + type: Anim.StandardLarge } SequentialAnimation { PauseAnimation { - duration: Appearance.anim.durations.small + duration: Tokens.anim.durations.small } Anim { target: lockContent @@ -93,7 +94,7 @@ WlSessionLockSurface { target: background property: "opacity" to: 1 - duration: Appearance.anim.durations.large + type: Anim.StandardLarge } SequentialAnimation { ParallelAnimation { @@ -101,15 +102,14 @@ WlSessionLockSurface { target: lockContent property: "scale" to: 1 - duration: Appearance.anim.durations.expressiveFastSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + type: Anim.FastSpatial } Anim { target: lockContent property: "rotation" to: 360 - duration: Appearance.anim.durations.expressiveFastSpatial - easing.bezierCurve: Appearance.anim.curves.standardAccel + duration: Tokens.anim.durations.expressiveFastSpatial + easing: Tokens.anim.standardAccel } } ParallelAnimation { @@ -117,7 +117,7 @@ WlSessionLockSurface { target: lockIcon property: "rotation" to: 360 - easing.bezierCurve: Appearance.anim.curves.standardDecel + easing: Tokens.anim.standardDecel } Anim { target: lockIcon @@ -133,27 +133,24 @@ WlSessionLockSurface { target: content property: "scale" to: 1 - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + type: Anim.DefaultSpatial } Anim { target: lockBg property: "radius" - to: Appearance.rounding.large * 1.5 + to: lockContent.Tokens.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 + to: (root.screen?.height ?? 0) * lockContent.Tokens.sizes.lock.heightMult * lockContent.Tokens.sizes.lock.ratio + type: Anim.DefaultSpatial } 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 + to: (root.screen?.height ?? 0) * lockContent.Tokens.sizes.lock.heightMult + type: Anim.DefaultSpatial } } } @@ -179,8 +176,8 @@ WlSessionLockSurface { Item { id: lockContent - readonly property int size: lockIcon.implicitHeight + Appearance.padding.large * 4 - readonly property int radius: size / 4 * Appearance.rounding.scale + readonly property int size: lockIcon.implicitHeight + Tokens.padding.large * 4 + readonly property int radius: size / 4 * Tokens.rounding.scale anchors.centerIn: parent implicitWidth: size @@ -210,7 +207,7 @@ WlSessionLockSurface { anchors.centerIn: parent text: "lock" - font.pointSize: Appearance.font.size.extraLarge * 4 + font.pointSize: Tokens.font.size.extraLarge * 4 font.bold: true rotation: 180 } @@ -219,8 +216,8 @@ WlSessionLockSurface { 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 + width: (root.screen?.height ?? 0) * Tokens.sizes.lock.heightMult * Tokens.sizes.lock.ratio - Tokens.padding.large * 2 + height: (root.screen?.height ?? 0) * Tokens.sizes.lock.heightMult - Tokens.padding.large * 2 lock: root opacity: 0 diff --git a/modules/lock/Media.qml b/modules/lock/Media.qml index b7e58bbcb..c55333c7f 100644 --- a/modules/lock/Media.qml +++ b/modules/lock/Media.qml @@ -1,11 +1,12 @@ pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Quickshell +import Caelestia.Config import qs.components import qs.components.effects import qs.services -import qs.config -import QtQuick -import QtQuick.Layouts Item { id: root @@ -18,12 +19,14 @@ Item { Image { anchors.fill: parent - source: Players.active?.trackArtUrl ?? "" + source: Players.getArtUrl(Players.active) asynchronous: true fillMode: Image.PreserveAspectCrop - sourceSize.width: width - sourceSize.height: height + sourceSize: { + const dpr = (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1; + return Qt.size(width * dpr, height * dpr); + } layer.enabled: true layer.effect: OpacityMask { @@ -34,7 +37,7 @@ Item { Behavior on opacity { Anim { - duration: Appearance.anim.durations.extraLarge + type: Anim.StandardExtraLarge } } } @@ -69,14 +72,14 @@ Item { anchors.left: parent.left anchors.right: parent.right - anchors.margins: Appearance.padding.large + anchors.margins: Tokens.padding.large StyledText { - Layout.topMargin: Appearance.padding.large - Layout.bottomMargin: Appearance.spacing.larger + Layout.topMargin: Tokens.padding.large + Layout.bottomMargin: Tokens.spacing.larger text: qsTr("Now playing") color: Colours.palette.m3onSurfaceVariant - font.family: Appearance.font.family.mono + font.family: Tokens.font.family.mono font.weight: 500 } @@ -86,8 +89,8 @@ Item { 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.pointSize: Tokens.font.size.large + font.family: Tokens.font.family.mono font.weight: 600 elide: Text.ElideRight } @@ -97,22 +100,21 @@ Item { animate: true text: Players.active?.trackTitle ?? qsTr("No media") horizontalAlignment: Text.AlignHCenter - font.pointSize: Appearance.font.size.larger - font.family: Appearance.font.family.mono + font.pointSize: Tokens.font.size.larger + font.family: Tokens.font.family.mono elide: Text.ElideRight } RowLayout { Layout.alignment: Qt.AlignHCenter - Layout.topMargin: Appearance.spacing.large * 1.2 - Layout.bottomMargin: Appearance.padding.large + Layout.topMargin: Tokens.spacing.large * 1.2 + Layout.bottomMargin: Tokens.padding.large - spacing: Appearance.spacing.large + spacing: Tokens.spacing.large PlayerControl { icon: "skip_previous" - - function onClicked(): void { + onClicked: { if (Players.active?.canGoPrevious) Players.active.previous(); } @@ -124,8 +126,7 @@ Item { colour: "Primary" level: active ? 2 : 1 active: Players.active?.isPlaying ?? false - - function onClicked(): void { + onClicked: { if (Players.active?.canTogglePlaying) Players.active.togglePlaying(); } @@ -133,8 +134,7 @@ Item { PlayerControl { icon: "skip_next" - - function onClicked(): void { + onClicked: { if (Players.active?.canGoNext) Players.active.next(); } @@ -151,15 +151,14 @@ Item { property string colour: "Secondary" property int level: 1 - function onClicked(): void { - } + signal clicked - 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 + Layout.preferredWidth: implicitWidth + (controlState.pressed ? Tokens.padding.normal * 2 : active ? Tokens.padding.small * 2 : 0) + implicitWidth: controlIcon.implicitWidth + Tokens.padding.large * 2 + implicitHeight: controlIcon.implicitHeight + Tokens.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) + radius: active || controlState.pressed ? Tokens.rounding.normal : Math.min(implicitWidth, implicitHeight) / 2 * Math.min(1, Tokens.rounding.scale) Elevation { anchors.fill: parent @@ -172,10 +171,7 @@ Item { id: controlState color: control.active ? Colours.palette[`m3on${control.colour}`] : Colours.palette[`m3on${control.colour}Container`] - - function onClicked(): void { - control.onClicked(); - } + onClicked: control.clicked() } MaterialIcon { @@ -183,7 +179,7 @@ Item { anchors.centerIn: parent color: control.active ? Colours.palette[`m3on${control.colour}`] : Colours.palette[`m3on${control.colour}Container`] - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large fill: control.active ? 1 : 0 Behavior on fill { @@ -193,15 +189,13 @@ Item { Behavior on Layout.preferredWidth { Anim { - duration: Appearance.anim.durations.expressiveFastSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + type: Anim.FastSpatial } } Behavior on radius { Anim { - duration: Appearance.anim.durations.expressiveFastSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + type: Anim.FastSpatial } } } diff --git a/modules/lock/NotifDock.qml b/modules/lock/NotifDock.qml index 01f7e4b47..2254dfe0b 100644 --- a/modules/lock/NotifDock.qml +++ b/modules/lock/NotifDock.qml @@ -1,14 +1,15 @@ pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Caelestia.Config 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 +import qs.utils ColumnLayout { id: root @@ -16,15 +17,15 @@ ColumnLayout { required property var lock anchors.fill: parent - anchors.margins: Appearance.padding.large + anchors.margins: Tokens.padding.large - spacing: Appearance.spacing.smaller + spacing: Tokens.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.family: Tokens.font.family.mono font.weight: 500 elide: Text.ElideRight } @@ -35,22 +36,23 @@ ColumnLayout { Layout.fillWidth: true Layout.fillHeight: true - radius: Appearance.rounding.small + radius: Tokens.rounding.small color: "transparent" Loader { + asynchronous: true anchors.centerIn: parent active: opacity > 0 - opacity: Notifs.list.length > 0 ? 0 : 1 + opacity: Notifs.list.length > 0 && !Config.lock.hideNotifs ? 0 : 1 sourceComponent: ColumnLayout { - spacing: Appearance.spacing.large + spacing: Tokens.spacing.large Image { asynchronous: true - source: Qt.resolvedUrl(`${Quickshell.shellDir}/assets/dino.png`) + source: Paths.absolutePath(Config.paths.lockNoNotifsPic) fillMode: Image.PreserveAspectFit - sourceSize.width: clipRect.width * 0.8 + sourceSize.width: clipRect.width * 0.8 * ((QsWindow.window as QsWindow)?.devicePixelRatio ?? 1) layer.enabled: true layer.effect: Colouriser { @@ -61,25 +63,25 @@ ColumnLayout { StyledText { Layout.alignment: Qt.AlignHCenter - text: qsTr("No Notifications") + 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.pointSize: Tokens.font.size.large + font.family: Tokens.font.family.mono font.weight: 500 } } Behavior on opacity { Anim { - duration: Appearance.anim.durations.extraLarge + type: Anim.StandardExtraLarge } } } StyledListView { anchors.fill: parent - - spacing: Appearance.spacing.small + visible: !Config.lock.hideNotifs + spacing: Tokens.spacing.small clip: true model: ScriptModel { @@ -101,8 +103,7 @@ ColumnLayout { property: "scale" from: 0 to: 1 - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + type: Anim.DefaultSpatial } } @@ -124,8 +125,7 @@ ColumnLayout { } Anim { property: "y" - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + type: Anim.DefaultSpatial } } @@ -136,8 +136,7 @@ ColumnLayout { } Anim { property: "y" - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + type: Anim.DefaultSpatial } } } diff --git a/modules/lock/NotifGroup.qml b/modules/lock/NotifGroup.qml index 779609065..971b921a0 100644 --- a/modules/lock/NotifGroup.qml +++ b/modules/lock/NotifGroup.qml @@ -1,15 +1,15 @@ pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Quickshell.Services.Notifications +import Caelestia.Config 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 @@ -17,18 +17,39 @@ StyledRect { required property string modelData readonly property list notifs: Notifs.list.filter(notif => notif.appName === modelData) - readonly property string image: notifs.find(n => n.image.length > 0)?.image ?? "" - readonly property string appIcon: notifs.find(n => n.appIcon.length > 0)?.appIcon ?? "" - readonly property string urgency: notifs.some(n => n.urgency === NotificationUrgency.Critical) ? "critical" : notifs.some(n => n.urgency === NotificationUrgency.Normal) ? "normal" : "low" + 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 + implicitHeight: content.implicitHeight + Tokens.padding.normal * 2 clip: true - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal color: root.urgency === "critical" ? Colours.palette.m3secondaryContainer : Colours.layer(Colours.palette.m3surfaceContainerHigh, 2) RowLayout { @@ -37,14 +58,14 @@ StyledRect { anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top - anchors.margins: Appearance.padding.normal + anchors.margins: Tokens.padding.normal - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal Item { Layout.alignment: Qt.AlignLeft | Qt.AlignTop - implicitWidth: Config.notifs.sizes.image - implicitHeight: Config.notifs.sizes.image + implicitWidth: TokenConfig.sizes.notifs.image + implicitHeight: TokenConfig.sizes.notifs.image Component { id: imageComp @@ -52,10 +73,14 @@ StyledRect { Image { source: Qt.resolvedUrl(root.image) fillMode: Image.PreserveAspectCrop + sourceSize: { + const size = TokenConfig.sizes.notifs.image * ((QsWindow.window as QsWindow)?.devicePixelRatio ?? 1); + return Qt.size(size, size); + } cache: false asynchronous: true - width: Config.notifs.sizes.image - height: Config.notifs.sizes.image + width: TokenConfig.sizes.notifs.image + height: TokenConfig.sizes.notifs.image } } @@ -63,7 +88,7 @@ StyledRect { id: appIconComp ColouredIcon { - implicitSize: Math.round(Config.notifs.sizes.image * 0.6) + implicitSize: Math.round(TokenConfig.sizes.notifs.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") @@ -76,36 +101,38 @@ StyledRect { 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 + font.pointSize: Tokens.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 + radius: Tokens.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 + implicitWidth: Tokens.sizes.notifs.badge + implicitHeight: Tokens.sizes.notifs.badge color: root.urgency === "critical" ? Colours.palette.m3error : root.urgency === "low" ? Colours.palette.m3surfaceContainerHighest : Colours.palette.m3secondaryContainer - radius: Appearance.rounding.full + radius: Tokens.rounding.full ColouredIcon { anchors.centerIn: parent - implicitSize: Math.round(Config.notifs.sizes.badge * 0.6) + implicitSize: Math.round(Tokens.sizes.notifs.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") @@ -115,21 +142,21 @@ StyledRect { } ColumnLayout { - Layout.topMargin: -Appearance.padding.small - Layout.bottomMargin: -Appearance.padding.small / 2 - (root.expanded ? 0 : spacing) + Layout.topMargin: -Tokens.padding.small + Layout.bottomMargin: -Tokens.padding.small / 2 - (root.expanded ? 0 : spacing) Layout.fillWidth: true - spacing: Math.round(Appearance.spacing.small / 2) + spacing: Math.round(Tokens.spacing.small / 2) RowLayout { Layout.bottomMargin: -parent.spacing Layout.fillWidth: true - spacing: Appearance.spacing.smaller + spacing: Tokens.spacing.smaller StyledText { Layout.fillWidth: true text: root.modelData color: Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small elide: Text.ElideRight } @@ -137,45 +164,42 @@ StyledRect { animate: true text: root.notifs[0]?.timeStr ?? "" color: Colours.palette.m3outline - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small } StyledRect { - implicitWidth: expandBtn.implicitWidth + Appearance.padding.smaller * 2 - implicitHeight: groupCount.implicitHeight + Appearance.padding.small + implicitWidth: expandBtn.implicitWidth + Tokens.padding.smaller * 2 + implicitHeight: groupCount.implicitHeight + Tokens.padding.small color: root.urgency === "critical" ? Colours.palette.m3error : Colours.layer(Colours.palette.m3surfaceContainerHighest, 2) - radius: Appearance.rounding.full + radius: Tokens.rounding.full opacity: root.notifs.length > Config.notifs.groupPreviewNum ? 1 : 0 Layout.preferredWidth: root.notifs.length > Config.notifs.groupPreviewNum ? implicitWidth : 0 StateLayer { color: root.urgency === "critical" ? Colours.palette.m3onError : Colours.palette.m3onSurface - - function onClicked(): void { - root.expanded = !root.expanded; - } + onClicked: root.expanded = !root.expanded } RowLayout { id: expandBtn anchors.centerIn: parent - spacing: Appearance.spacing.small / 2 + spacing: Tokens.spacing.small / 2 StyledText { id: groupCount - Layout.leftMargin: Appearance.padding.small / 2 + Layout.leftMargin: Tokens.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 + font.pointSize: Tokens.font.size.small } MaterialIcon { - Layout.rightMargin: -Appearance.padding.small / 2 + Layout.rightMargin: -Tokens.padding.small / 2 animate: true text: root.expanded ? "expand_less" : "expand_more" color: root.urgency === "critical" ? Colours.palette.m3onError : Colours.palette.m3onSurface @@ -194,7 +218,7 @@ StyledRect { Repeater { model: ScriptModel { - values: root.notifs.slice(0, Config.notifs.groupPreviewNum) + values: root.notifs.slice(0, root.Config.notifs.groupPreviewNum) } NotifLine { @@ -247,6 +271,7 @@ StyledRect { } Loader { + asynchronous: true Layout.fillWidth: true opacity: root.expanded ? 1 : 0 @@ -256,7 +281,7 @@ StyledRect { sourceComponent: ColumnLayout { Repeater { model: ScriptModel { - values: root.notifs.slice(Config.notifs.groupPreviewNum) + values: root.notifs.slice(root.Config.notifs.groupPreviewNum) } NotifLine {} @@ -272,15 +297,14 @@ StyledRect { Behavior on implicitHeight { Anim { - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + type: Anim.DefaultSpatial } } component NotifLine: StyledText { id: notifLine - required property Notifs.Notif modelData + required property NotifData modelData Layout.fillWidth: true textFormat: Text.MarkdownText diff --git a/modules/lock/Pam.qml b/modules/lock/Pam.qml index 0186c2f84..f4c617473 100644 --- a/modules/lock/Pam.qml +++ b/modules/lock/Pam.qml @@ -1,9 +1,9 @@ -import qs.config +import QtQuick import Quickshell import Quickshell.Io import Quickshell.Wayland import Quickshell.Services.Pam -import QtQuick +import Caelestia.Config Scope { id: root @@ -31,8 +31,8 @@ Scope { } 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) + } else if (/^[^\x00-\x1F\x7F-\x9F]+$/.test(event.text)) { + // Allow anything except control characters buffer += event.text; } } @@ -82,7 +82,7 @@ Scope { property int errorTries function checkAvail(): void { - if (!available || !Config.lock.enableFprint || !root.lock.secure) { + if (!available || !GlobalConfig.lock.enableFprint || !root.lock.secure) { abort(); return; } @@ -113,7 +113,7 @@ Scope { // Isn't actually the real max tries as pam only reports completed // when max tries is reached. tries++; - if (tries < Config.lock.maxFprintTries) { + if (tries < GlobalConfig.lock.maxFprintTries) { // Restart if not actually real max tries root.fprintState = "fail"; start(); @@ -132,7 +132,7 @@ Scope { id: availProc command: ["sh", "-c", "fprintd-list $USER"] - onExited: code => { + onExited: code => { // qmllint disable signal-handler-parameters fprint.available = code === 0; fprint.checkAvail(); } @@ -166,8 +166,6 @@ Scope { } Connections { - target: root.lock - function onSecureChanged(): void { if (root.lock.secure) { availProc.running = true; @@ -181,13 +179,15 @@ Scope { function onUnlock(): void { fprint.abort(); } + + target: root.lock } Connections { - target: Config.lock - function onEnableFprintChanged(): void { fprint.checkAvail(); } + + target: GlobalConfig.lock } } diff --git a/modules/lock/Resources.qml b/modules/lock/Resources.qml index 82c004c22..1f38aa00d 100644 --- a/modules/lock/Resources.qml +++ b/modules/lock/Resources.qml @@ -1,20 +1,20 @@ +import QtQuick +import QtQuick.Layouts +import Caelestia.Config 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 + anchors.margins: Tokens.padding.large - rowSpacing: Appearance.spacing.large - columnSpacing: Appearance.spacing.large + rowSpacing: Tokens.spacing.large + columnSpacing: Tokens.spacing.large rows: 2 columns: 2 @@ -23,28 +23,28 @@ GridLayout { } Resource { - Layout.topMargin: Appearance.padding.large + Layout.topMargin: Tokens.padding.large icon: "memory" value: SystemUsage.cpuPerc colour: Colours.palette.m3primary } Resource { - Layout.topMargin: Appearance.padding.large + Layout.topMargin: Tokens.padding.large icon: "thermostat" value: Math.min(1, SystemUsage.cpuTemp / 90) colour: Colours.palette.m3secondary } Resource { - Layout.bottomMargin: Appearance.padding.large + Layout.bottomMargin: Tokens.padding.large icon: "memory_alt" value: SystemUsage.memPerc colour: Colours.palette.m3secondary } Resource { - Layout.bottomMargin: Appearance.padding.large + Layout.bottomMargin: Tokens.padding.large icon: "hard_disk" value: SystemUsage.storagePerc colour: Colours.palette.m3tertiary @@ -61,17 +61,17 @@ GridLayout { implicitHeight: width color: Colours.layer(Colours.palette.m3surfaceContainerHigh, 2) - radius: Appearance.rounding.large + radius: Tokens.rounding.large CircularProgress { id: circ anchors.fill: parent value: res.value - padding: Appearance.padding.large * 3 + padding: Tokens.padding.large * 3 fgColour: res.colour bgColour: Colours.layer(Colours.palette.m3surfaceContainerHighest, 3) - strokeWidth: width < 200 ? Appearance.padding.smaller : Appearance.padding.normal + strokeWidth: width < 200 ? Tokens.padding.smaller : Tokens.padding.normal } MaterialIcon { @@ -86,7 +86,7 @@ GridLayout { Behavior on value { Anim { - duration: Appearance.anim.durations.large + type: Anim.StandardLarge } } } diff --git a/modules/lock/WeatherInfo.qml b/modules/lock/WeatherInfo.qml index d6c25af29..d96a54dfb 100644 --- a/modules/lock/WeatherInfo.qml +++ b/modules/lock/WeatherInfo.qml @@ -1,11 +1,10 @@ pragma ComponentBehavior: Bound -import qs.components -import qs.services -import qs.config -import qs.utils import QtQuick import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.services ColumnLayout { id: root @@ -14,13 +13,14 @@ ColumnLayout { anchors.left: parent.left anchors.right: parent.right - anchors.margins: Appearance.padding.large * 2 + anchors.margins: Tokens.padding.large * 2 - spacing: Appearance.spacing.small + spacing: Tokens.spacing.small Loader { - Layout.topMargin: Appearance.padding.large * 2 - Layout.bottomMargin: -Appearance.padding.large + asynchronous: true + Layout.topMargin: Tokens.padding.large * 2 + Layout.bottomMargin: -Tokens.padding.large Layout.alignment: Qt.AlignHCenter active: root.rootHeight > 610 @@ -29,24 +29,24 @@ ColumnLayout { sourceComponent: StyledText { text: qsTr("Weather") color: Colours.palette.m3primary - font.pointSize: Appearance.font.size.extraLarge + font.pointSize: Tokens.font.size.extraLarge font.weight: 500 } } RowLayout { Layout.fillWidth: true - spacing: Appearance.spacing.large + spacing: Tokens.spacing.large MaterialIcon { animate: true text: Weather.icon color: Colours.palette.m3secondary - font.pointSize: Appearance.font.size.extraLarge * 2.5 + font.pointSize: Tokens.font.size.extraLarge * 2.5 } ColumnLayout { - spacing: Appearance.spacing.small + spacing: Tokens.spacing.small StyledText { Layout.fillWidth: true @@ -54,7 +54,7 @@ ColumnLayout { animate: true text: Weather.description color: Colours.palette.m3secondary - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large font.weight: 500 elide: Text.ElideRight } @@ -65,18 +65,19 @@ ColumnLayout { animate: true text: qsTr("Humidity: %1%").arg(Weather.humidity) color: Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.font.size.normal elide: Text.ElideRight } } Loader { - Layout.rightMargin: Appearance.padding.smaller + asynchronous: true + Layout.rightMargin: Tokens.padding.smaller active: root.width > 400 visible: active sourceComponent: ColumnLayout { - spacing: Appearance.spacing.small + spacing: Tokens.spacing.small StyledText { Layout.fillWidth: true @@ -85,7 +86,7 @@ ColumnLayout { text: Weather.temp color: Colours.palette.m3primary horizontalAlignment: Text.AlignRight - font.pointSize: Appearance.font.size.extraLarge + font.pointSize: Tokens.font.size.extraLarge font.weight: 500 elide: Text.ElideLeft } @@ -97,7 +98,7 @@ ColumnLayout { text: qsTr("Feels like: %1").arg(Weather.feelsLike) color: Colours.palette.m3outline horizontalAlignment: Text.AlignRight - font.pointSize: Appearance.font.size.smaller + font.pointSize: Tokens.font.size.smaller elide: Text.ElideLeft } } @@ -107,15 +108,16 @@ ColumnLayout { Loader { id: forecastLoader - Layout.topMargin: Appearance.spacing.smaller - Layout.bottomMargin: Appearance.padding.large * 2 + asynchronous: true + Layout.topMargin: Tokens.spacing.smaller + Layout.bottomMargin: Tokens.padding.large * 2 Layout.fillWidth: true active: root.rootHeight > 820 visible: active sourceComponent: RowLayout { - spacing: Appearance.spacing.large + spacing: Tokens.spacing.large Repeater { model: { @@ -135,7 +137,7 @@ ColumnLayout { required property var modelData Layout.fillWidth: true - spacing: Appearance.spacing.small + spacing: Tokens.spacing.small StyledText { Layout.fillWidth: true @@ -145,21 +147,21 @@ ColumnLayout { } color: Colours.palette.m3outline horizontalAlignment: Text.AlignHCenter - font.pointSize: Appearance.font.size.larger + font.pointSize: Tokens.font.size.larger } MaterialIcon { Layout.alignment: Qt.AlignHCenter text: forecastHour.modelData?.icon ?? "cloud_alert" - font.pointSize: Appearance.font.size.extraLarge * 1.5 + font.pointSize: Tokens.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` + text: GlobalConfig.services.useFahrenheit ? `${forecastHour.modelData?.tempF ?? 0}°F` : `${forecastHour.modelData?.tempC ?? 0}°C` color: Colours.palette.m3secondary - font.pointSize: Appearance.font.size.larger + font.pointSize: Tokens.font.size.larger } } } diff --git a/modules/notifications/Background.qml b/modules/notifications/Background.qml deleted file mode 100644 index a44cb19b7..000000000 --- a/modules/notifications/Background.qml +++ /dev/null @@ -1,54 +0,0 @@ -import qs.components -import qs.services -import qs.config -import QtQuick -import QtQuick.Shapes - -ShapePath { - id: root - - required property Wrapper wrapper - required property var sidebar - readonly property real rounding: Config.border.rounding - readonly property bool flatten: wrapper.height < rounding * 2 - readonly property real roundingY: flatten ? wrapper.height / 2 : rounding - - strokeWidth: -1 - fillColor: Colours.palette.m3surface - - PathLine { - relativeX: -(root.wrapper.width + root.rounding) - relativeY: 0 - } - PathArc { - relativeX: root.rounding - relativeY: root.roundingY - radiusX: root.rounding - radiusY: Math.min(root.rounding, root.wrapper.height) - } - PathLine { - relativeX: 0 - relativeY: root.wrapper.height - root.roundingY * 2 - } - PathArc { - relativeX: root.sidebar.notifsRoundingX - relativeY: root.roundingY - radiusX: root.sidebar.notifsRoundingX - radiusY: Math.min(root.rounding, root.wrapper.height) - direction: PathArc.Counterclockwise - } - PathLine { - relativeX: root.wrapper.height > 0 ? root.wrapper.width - root.rounding - root.sidebar.notifsRoundingX : root.wrapper.width - relativeY: 0 - } - PathArc { - relativeX: root.rounding - relativeY: root.rounding - radiusX: root.rounding - radiusY: root.rounding - } - - Behavior on fillColor { - CAnim {} - } -} diff --git a/modules/notifications/Content.qml b/modules/notifications/Content.qml index 2d4590e0e..aeaa7deaf 100644 --- a/modules/notifications/Content.qml +++ b/modules/notifications/Content.qml @@ -1,47 +1,47 @@ +import QtQuick +import Quickshell +import Quickshell.Widgets +import Caelestia.Config +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 PersistentProperties visibilities - required property Item panels - readonly property int padding: Appearance.padding.large + required property DrawerVisibilities visibilities + required property Item osdPanel + required property Item sessionPanel + readonly property int padding: Tokens.padding.large anchors.top: parent.top anchors.bottom: parent.bottom anchors.right: parent.right - implicitWidth: Config.notifs.sizes.width + padding * 2 + implicitWidth: Tokens.sizes.notifs.width + padding * 2 implicitHeight: { const count = list.count; if (count === 0) return 0; - let height = (count - 1) * Appearance.spacing.smaller; + let height = (count - 1) * Tokens.spacing.smaller; for (let i = 0; i < count; i++) - height += list.itemAtIndex(i)?.nonAnimHeight ?? 0; + height += (list.itemAtIndex(i) as NotifWrapper)?.nonAnimHeight ?? 0; - if (visibilities && panels) { - if (visibilities.osd) { - const h = panels.osd.y - Config.border.rounding * 2 - padding * 2; - if (height > h) - height = h; - } + if (visibilities.osd) { + const h = osdPanel.y - Config.border.rounding * 2 - padding * 2; + if (height > h) + height = h; + } - if (visibilities.session) { - const h = panels.session.y - Config.border.rounding * 2 - padding * 2; - if (height > h) - height = h; - } + if (visibilities.session) { + const h = sessionPanel.y - Config.border.rounding * 2 - padding * 2; + if (height > h) + height = h; } - return Math.min((QsWindow.window?.screen?.height ?? 0) - Config.border.thickness * 2, height + padding * 2); + return Math.min(((QsWindow.window as QsWindow)?.screen?.height ?? 0) - Config.border.thickness * 2, height + padding * 2); } ClippingWrapperRectangle { @@ -49,7 +49,7 @@ Item { anchors.margins: root.padding color: "transparent" - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal StyledListView { id: list @@ -62,79 +62,9 @@ Item { orientation: Qt.Vertical spacing: 0 - cacheBuffer: QsWindow.window?.screen.height ?? 0 - - delegate: Item { - id: wrapper - - required property Notifs.Notif modelData - required property int index - readonly property alias nonAnimHeight: notif.nonAnimHeight - property int idx - - onIndexChanged: { - if (index !== -1) - idx = index; - } - - implicitWidth: notif.implicitWidth - implicitHeight: notif.implicitHeight + (idx === 0 ? 0 : Appearance.spacing.smaller) - - ListView.onRemove: removeAnim.start() - - SequentialAnimation { - id: removeAnim - - PropertyAction { - target: wrapper - property: "ListView.delayRemove" - value: true - } - PropertyAction { - target: wrapper - property: "enabled" - value: false - } - PropertyAction { - target: wrapper - property: "implicitHeight" - value: 0 - } - PropertyAction { - target: wrapper - property: "z" - value: 1 - } - Anim { - target: notif - property: "x" - to: (notif.x >= 0 ? Config.notifs.sizes.width : -Config.notifs.sizes.width) * 2 - duration: Appearance.anim.durations.normal - easing.bezierCurve: Appearance.anim.curves.emphasized - } - PropertyAction { - target: wrapper - property: "ListView.delayRemove" - value: false - } - } - - ClippingRectangle { - anchors.top: parent.top - anchors.topMargin: wrapper.idx === 0 ? 0 : Appearance.spacing.smaller - - color: "transparent" - radius: notif.radius - implicitWidth: notif.implicitWidth - implicitHeight: notif.implicitHeight + cacheBuffer: (QsWindow.window as QsWindow)?.screen.height ?? 0 - Notification { - id: notif - - modelData: wrapper.modelData - } - } - } + delegate: NotifWrapper {} move: Transition { Anim { @@ -159,9 +89,9 @@ Item { let height = 0; for (let i = 0; i < count; i++) { - height += (list.itemAtIndex(i)?.nonAnimHeight ?? 0) + Appearance.spacing.smaller; + height += ((list.itemAtIndex(i) as NotifWrapper)?.nonAnimHeight ?? 0) + Tokens.spacing.smaller; - if (height - Appearance.spacing.smaller >= scrollY) + if (height - Tokens.spacing.smaller >= scrollY) return i; } @@ -180,9 +110,9 @@ Item { let height = 0; for (let i = count - 1; i >= 0; i--) { - height += (list.itemAtIndex(i)?.nonAnimHeight ?? 0) + Appearance.spacing.smaller; + height += ((list.itemAtIndex(i) as NotifWrapper)?.nonAnimHeight ?? 0) + Tokens.spacing.smaller; - if (height - Appearance.spacing.smaller >= scrollY) + if (height - Tokens.spacing.smaller >= scrollY) return count - i - 1; } @@ -196,9 +126,80 @@ Item { 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 : Tokens.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 ? wrapper.Tokens.sizes.notifs.width : -wrapper.Tokens.sizes.notifs.width) * 2 + duration: Tokens.anim.durations.normal + easing: Tokens.anim.emphasized + } + PropertyAction { + target: wrapper + property: "ListView.delayRemove" + value: false + } + } + + ClippingRectangle { + anchors.top: parent.top + anchors.topMargin: wrapper.idx === 0 ? 0 : Tokens.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 + duration: Tokens.anim.durations.expressiveDefaultSpatial + easing: Tokens.anim.expressiveDefaultSpatial } } diff --git a/modules/notifications/Notification.qml b/modules/notifications/Notification.qml index 8c2d3ec22..fe6fd0567 100644 --- a/modules/notifications/Notification.qml +++ b/modules/notifications/Notification.qml @@ -1,31 +1,32 @@ pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import QtQuick.Shapes +import Quickshell +import Quickshell.Services.Notifications +import Caelestia.Config import qs.components import qs.components.effects import qs.services -import qs.config import qs.utils -import Quickshell -import Quickshell.Widgets -import Quickshell.Services.Notifications -import QtQuick -import QtQuick.Layouts StyledRect { id: root - required property Notifs.Notif modelData + 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 + radius: Tokens.rounding.normal + implicitWidth: Tokens.sizes.notifs.width implicitHeight: inner.implicitHeight - x: Config.notifs.sizes.width + x: Tokens.sizes.notifs.width Component.onCompleted: { x = 0; modelData.lock(this); @@ -34,7 +35,7 @@ StyledRect { Behavior on x { Anim { - easing.bezierCurve: Appearance.anim.curves.emphasizedDecel + easing: Tokens.anim.emphasizedDecel } } @@ -66,7 +67,7 @@ StyledRect { if (!containsMouse) root.modelData.timer.start(); - if (Math.abs(root.x) < Config.notifs.sizes.width * Config.notifs.clearThreshold) + if (Math.abs(root.x) < Tokens.sizes.notifs.width * Config.notifs.clearThreshold) root.x = 0; else root.modelData.popup = false; @@ -79,11 +80,11 @@ StyledRect { } } onClicked: event => { - if (!Config.notifs.actionOnClick || event.button !== Qt.LeftButton) + if (!GlobalConfig.notifs.actionOnClick || event.button !== Qt.LeftButton) return; const actions = root.modelData.actions; - if (actions?.length === 1) + if (actions.length === 1) actions[0].invoke(); } @@ -93,37 +94,42 @@ StyledRect { anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top - anchors.margins: Appearance.padding.normal + anchors.margins: Tokens.padding.normal implicitHeight: root.nonAnimHeight Behavior on implicitHeight { Anim { - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + type: Anim.DefaultSpatial } } 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 + width: TokenConfig.sizes.notifs.image + height: TokenConfig.sizes.notifs.image visible: root.hasImage || root.hasAppIcon - sourceComponent: ClippingRectangle { - radius: Appearance.rounding.full - implicitWidth: Config.notifs.sizes.image - implicitHeight: Config.notifs.sizes.image + sourceComponent: StyledClippingRect { + radius: Tokens.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: TokenConfig.sizes.notifs.image + implicitHeight: TokenConfig.sizes.notifs.image Image { anchors.fill: parent source: Qt.resolvedUrl(root.modelData.image) fillMode: Image.PreserveAspectCrop + sourceSize: { + const size = TokenConfig.sizes.notifs.image * ((QsWindow.window as QsWindow)?.devicePixelRatio ?? 1); + return Qt.size(size, size); + } cache: false asynchronous: true } @@ -133,6 +139,7 @@ StyledRect { Loader { id: appIcon + asynchronous: true active: root.hasAppIcon || !root.hasImage anchors.horizontalCenter: root.hasImage ? undefined : image.horizontalCenter @@ -141,14 +148,15 @@ StyledRect { anchors.bottom: root.hasImage ? image.bottom : undefined sourceComponent: StyledRect { - radius: Appearance.rounding.full + radius: Tokens.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 + implicitWidth: root.hasImage ? Tokens.sizes.notifs.badge : TokenConfig.sizes.notifs.image + implicitHeight: root.hasImage ? Tokens.sizes.notifs.badge : TokenConfig.sizes.notifs.image Loader { id: icon + asynchronous: true active: root.hasAppIcon anchors.centerIn: parent @@ -165,16 +173,53 @@ StyledRect { } 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 + anchors.horizontalCenterOffset: -Tokens.font.size.large * 0.02 + anchors.verticalCenterOffset: Tokens.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 + font.pointSize: Tokens.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 - root.Tokens.padding.small / 2 + centerX: progressIndicator.width / 2 + radiusY: progressIndicator.height / 2 - root.Tokens.padding.small / 2 + centerY: progressIndicator.height / 2 + + startAngle: -90 + sweepAngle: ((root.modelData.hints.value ?? 0) / 100) * 360 + + Behavior on sweepAngle { + Anim { + easing: Tokens.anim.emphasizedDecel + } } } } @@ -185,13 +230,13 @@ StyledRect { anchors.top: parent.top anchors.left: image.right - anchors.leftMargin: Appearance.spacing.smaller + anchors.leftMargin: Tokens.spacing.smaller animate: true text: appNameMetrics.elidedText maximumLineCount: 1 color: Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small opacity: root.expanded ? 1 : 0 @@ -207,7 +252,7 @@ StyledRect { 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 + elideWidth: expandBtn.x - time.width - timeSep.width - summary.x - root.Tokens.spacing.small * 3 } StyledText { @@ -215,7 +260,7 @@ StyledRect { anchors.top: parent.top anchors.left: image.right - anchors.leftMargin: Appearance.spacing.smaller + anchors.leftMargin: Tokens.spacing.smaller animate: true text: summaryMetrics.elidedText @@ -241,10 +286,8 @@ StyledRect { target: summary property: "maximumLineCount" } - AnchorAnimation { - duration: Appearance.anim.durations.normal - easing.type: Easing.BezierSpline - easing.bezierCurve: Appearance.anim.curves.standard + AnchorAnim { + type: AnchorAnim.Standard } } @@ -260,7 +303,7 @@ StyledRect { 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 + elideWidth: expandBtn.x - time.width - timeSep.width - summary.x - root.Tokens.spacing.small * 3 } StyledText { @@ -268,11 +311,11 @@ StyledRect { anchors.top: parent.top anchors.left: summary.right - anchors.leftMargin: Appearance.spacing.small + anchors.leftMargin: Tokens.spacing.small text: "•" color: Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small states: State { name: "expanded" @@ -285,10 +328,8 @@ StyledRect { } transitions: Transition { - AnchorAnimation { - duration: Appearance.anim.durations.normal - easing.type: Easing.BezierSpline - easing.bezierCurve: Appearance.anim.curves.standard + AnchorAnim { + type: AnchorAnim.Standard } } } @@ -298,13 +339,13 @@ StyledRect { anchors.top: parent.top anchors.left: timeSep.right - anchors.leftMargin: Appearance.spacing.small + anchors.leftMargin: Tokens.spacing.small animate: true horizontalAlignment: Text.AlignLeft text: root.modelData.timeStr color: Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small } Item { @@ -317,12 +358,9 @@ StyledRect { implicitHeight: expandIcon.height StateLayer { - radius: Appearance.rounding.full + radius: Tokens.rounding.full color: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface - - function onClicked() { - root.expanded = !root.expanded; - } + onClicked: root.expanded = !root.expanded } MaterialIcon { @@ -332,7 +370,7 @@ StyledRect { animate: true text: root.expanded ? "expand_less" : "expand_more" - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.font.size.normal } } @@ -342,13 +380,13 @@ StyledRect { anchors.left: summary.left anchors.right: expandBtn.left anchors.top: summary.bottom - anchors.rightMargin: Appearance.spacing.small + anchors.rightMargin: Tokens.spacing.small animate: true - textFormat: Text.MarkdownText + textFormat: root.bodyTextFormat text: bodyPreviewMetrics.elidedText color: Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small opacity: root.expanded ? 0 : 1 @@ -373,13 +411,13 @@ StyledRect { anchors.left: summary.left anchors.right: expandBtn.left anchors.top: summary.bottom - anchors.rightMargin: Appearance.spacing.small + anchors.rightMargin: Tokens.spacing.small animate: true - textFormat: Text.MarkdownText + textFormat: root.bodyTextFormat text: root.modelData.body color: Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small wrapMode: Text.WrapAtWordBoundaryOrAnywhere height: text ? implicitHeight : 0 @@ -403,9 +441,9 @@ StyledRect { anchors.horizontalCenter: parent.horizontalCenter anchors.top: body.bottom - anchors.topMargin: Appearance.spacing.small + anchors.topMargin: Tokens.spacing.small - spacing: Appearance.spacing.smaller + spacing: Tokens.spacing.smaller opacity: root.expanded ? 1 : 0 @@ -416,6 +454,7 @@ StyledRect { Action { modelData: QtObject { readonly property string text: qsTr("Close") + function invoke(): void { root.modelData.close(); } @@ -438,21 +477,18 @@ StyledRect { required property var modelData - radius: Appearance.rounding.full + radius: Tokens.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 + Layout.preferredWidth: actionText.width + Tokens.padding.normal * 2 + Layout.preferredHeight: actionText.height + Tokens.padding.small * 2 + implicitWidth: actionText.width + Tokens.padding.normal * 2 + implicitHeight: actionText.height + Tokens.padding.small * 2 StateLayer { - radius: Appearance.rounding.full + radius: Tokens.rounding.full color: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3onSecondary : Colours.palette.m3onSurface - - function onClicked(): void { - action.modelData.invoke(); - } + onClicked: action.modelData.invoke() } StyledText { @@ -461,7 +497,7 @@ StyledRect { anchors.centerIn: parent text: actionTextMetrics.elidedText color: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3onSecondary : Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small } TextMetrics { @@ -473,7 +509,7 @@ StyledRect { elide: Text.ElideRight elideWidth: { const numActions = root.modelData.actions.length + 1; - return (inner.width - actions.spacing * (numActions - 1)) / numActions - Appearance.padding.normal * 2; + return (inner.width - actions.spacing * (numActions - 1)) / numActions - root.Tokens.padding.normal * 2; } } } diff --git a/modules/notifications/Wrapper.qml b/modules/notifications/Wrapper.qml index 61acc56e1..97417e9cf 100644 --- a/modules/notifications/Wrapper.qml +++ b/modules/notifications/Wrapper.qml @@ -1,39 +1,22 @@ -import qs.components -import qs.config import QtQuick +import qs.components Item { id: root - required property var visibilities - required property Item panels + required property DrawerVisibilities visibilities + required property Item sidebarPanel + property alias osdPanel: content.osdPanel + property alias sessionPanel: content.sessionPanel visible: height > 0 - implicitWidth: Math.max(panels.sidebar.width, content.implicitWidth) + anchors.topMargin: -5 + 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 - panels: root.panels } } diff --git a/modules/osd/Background.qml b/modules/osd/Background.qml deleted file mode 100644 index 78955c7a8..000000000 --- a/modules/osd/Background.qml +++ /dev/null @@ -1,60 +0,0 @@ -import qs.components -import qs.services -import qs.config -import QtQuick -import QtQuick.Shapes - -ShapePath { - id: root - - required property Wrapper wrapper - readonly property real rounding: Config.border.rounding - readonly property bool flatten: wrapper.width < rounding * 2 - readonly property real roundingX: flatten ? wrapper.width / 2 : rounding - - strokeWidth: -1 - fillColor: Colours.palette.m3surface - - PathArc { - relativeX: -root.roundingX - relativeY: root.rounding - radiusX: Math.min(root.rounding, root.wrapper.width) - radiusY: root.rounding - } - PathLine { - relativeX: -(root.wrapper.width - root.roundingX * 2) - relativeY: 0 - } - PathArc { - relativeX: -root.roundingX - relativeY: root.rounding - radiusX: Math.min(root.rounding, root.wrapper.width) - radiusY: root.rounding - direction: PathArc.Counterclockwise - } - PathLine { - relativeX: 0 - relativeY: root.wrapper.height - root.rounding * 2 - } - PathArc { - relativeX: root.roundingX - relativeY: root.rounding - radiusX: Math.min(root.rounding, root.wrapper.width) - radiusY: root.rounding - direction: PathArc.Counterclockwise - } - PathLine { - relativeX: root.wrapper.width - root.roundingX * 2 - relativeY: 0 - } - PathArc { - relativeX: root.roundingX - relativeY: root.rounding - radiusX: Math.min(root.rounding, root.wrapper.width) - radiusY: root.rounding - } - - Behavior on fillColor { - CAnim {} - } -} diff --git a/modules/osd/Content.qml b/modules/osd/Content.qml index 770fb6968..3ad3ef886 100644 --- a/modules/osd/Content.qml +++ b/modules/osd/Content.qml @@ -1,18 +1,18 @@ pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Caelestia.Config import qs.components import qs.components.controls import qs.services -import qs.config import qs.utils -import QtQuick -import QtQuick.Layouts Item { id: root required property Brightness.Monitor monitor - required property var visibilities + required property DrawerVisibilities visibilities required property real volume required property bool muted @@ -20,20 +20,17 @@ Item { required property bool sourceMuted required property real brightness - implicitWidth: layout.implicitWidth + Appearance.padding.large * 2 - implicitHeight: layout.implicitHeight + Appearance.padding.large * 2 + implicitWidth: layout.implicitWidth + Tokens.padding.large * 2 + implicitHeight: layout.implicitHeight + Tokens.padding.large * 2 ColumnLayout { id: layout anchors.centerIn: parent - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal // Speaker volume CustomMouseArea { - implicitWidth: Config.osd.sizes.sliderWidth - implicitHeight: Config.osd.sizes.sliderHeight - function onWheel(event: WheelEvent) { if (event.angleDelta.y > 0) Audio.incrementVolume(); @@ -41,12 +38,15 @@ Item { Audio.decrementVolume(); } + implicitWidth: Tokens.sizes.osd.sliderWidth + implicitHeight: Tokens.sizes.osd.sliderHeight + FilledSlider { anchors.fill: parent icon: Icons.getVolumeIcon(value, root.muted) value: root.volume - to: Config.services.maxVolume + to: GlobalConfig.services.maxVolume onMoved: Audio.setVolume(value) } } @@ -56,9 +56,6 @@ Item { shouldBeActive: Config.osd.enableMicrophone && (!Config.osd.enableBrightness || !root.visibilities.session) sourceComponent: CustomMouseArea { - implicitWidth: Config.osd.sizes.sliderWidth - implicitHeight: Config.osd.sizes.sliderHeight - function onWheel(event: WheelEvent) { if (event.angleDelta.y > 0) Audio.incrementSourceVolume(); @@ -66,12 +63,15 @@ Item { Audio.decrementSourceVolume(); } + implicitWidth: Tokens.sizes.osd.sliderWidth + implicitHeight: Tokens.sizes.osd.sliderHeight + FilledSlider { anchors.fill: parent icon: Icons.getMicVolumeIcon(value, root.sourceMuted) value: root.sourceVolume - to: Config.services.maxVolume + to: GlobalConfig.services.maxVolume onMoved: Audio.setSourceVolume(value) } } @@ -82,19 +82,19 @@ Item { shouldBeActive: Config.osd.enableBrightness sourceComponent: CustomMouseArea { - implicitWidth: Config.osd.sizes.sliderWidth - implicitHeight: Config.osd.sizes.sliderHeight - function onWheel(event: WheelEvent) { const monitor = root.monitor; if (!monitor) return; if (event.angleDelta.y > 0) - monitor.setBrightness(monitor.brightness + Config.services.brightnessIncrement); + monitor.setBrightness(monitor.brightness + GlobalConfig.services.brightnessIncrement); else if (event.angleDelta.y < 0) - monitor.setBrightness(monitor.brightness - Config.services.brightnessIncrement); + monitor.setBrightness(monitor.brightness - GlobalConfig.services.brightnessIncrement); } + implicitWidth: Tokens.sizes.osd.sliderWidth + implicitHeight: Tokens.sizes.osd.sliderHeight + FilledSlider { anchors.fill: parent @@ -109,14 +109,15 @@ Item { component WrappedLoader: Loader { required property bool shouldBeActive - Layout.preferredHeight: shouldBeActive ? Config.osd.sizes.sliderHeight : 0 + asynchronous: true + Layout.preferredHeight: shouldBeActive ? Tokens.sizes.osd.sliderHeight : 0 opacity: shouldBeActive ? 1 : 0 active: opacity > 0 visible: active Behavior on Layout.preferredHeight { Anim { - easing.bezierCurve: Appearance.anim.curves.emphasized + type: Anim.Emphasized } } diff --git a/modules/osd/Wrapper.qml b/modules/osd/Wrapper.qml index 2519609dd..3ea0e1a36 100644 --- a/modules/osd/Wrapper.qml +++ b/modules/osd/Wrapper.qml @@ -1,19 +1,23 @@ pragma ComponentBehavior: Bound +import QtQuick +import Quickshell +import Caelestia.Config import qs.components import qs.services -import qs.config -import Quickshell -import QtQuick Item { id: root required property ShellScreen screen - required property var visibilities + required property DrawerVisibilities visibilities + required property bool sidebarOrSessionVisible + 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 offsetScale: shouldBeActive ? 0 : 1 + property real sidebarOffset: sidebarOrSessionVisible ? 12 : 0 property real volume property bool muted @@ -34,45 +38,19 @@ Item { brightness = root.monitor?.brightness ?? 0; } - visible: width > 0 - implicitWidth: 0 + visible: offsetScale < 1 + anchors.rightMargin: (-implicitWidth - 5 - sidebarOffset) * offsetScale + implicitWidth: content.implicitWidth implicitHeight: content.implicitHeight + opacity: 1 - offsetScale - states: State { - name: "visible" - when: root.shouldBeActive - - PropertyChanges { - root.implicitWidth: content.implicitWidth + Behavior on offsetScale { + Anim { + type: Anim.DefaultSpatial } } - transitions: [ - Transition { - from: "" - to: "visible" - - Anim { - target: root - property: "implicitWidth" - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial - } - }, - Transition { - from: "visible" - to: "" - - Anim { - target: root - property: "implicitWidth" - easing.bezierCurve: Appearance.anim.curves.emphasized - } - } - ] - Connections { - target: Audio - function onMutedChanged(): void { root.show(); root.muted = Audio.muted; @@ -92,21 +70,23 @@ Item { root.show(); root.sourceVolume = Audio.sourceVolume; } + + target: Audio } Connections { - target: root.monitor - function onBrightnessChanged(): void { root.show(); root.brightness = root.monitor?.brightness ?? 0; } + + target: root.monitor } Timer { id: timer - interval: Config.osd.hideDelay + interval: root.Config.osd.hideDelay onTriggered: { if (!root.hovered) root.visibilities.osd = false; @@ -119,7 +99,8 @@ Item { anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left - Component.onCompleted: active = Qt.binding(() => root.shouldBeActive || root.visible) + asynchronous: true + active: root.shouldBeActive || root.visible sourceComponent: Content { monitor: root.monitor diff --git a/modules/session/Background.qml b/modules/session/Background.qml deleted file mode 100644 index 78955c7a8..000000000 --- a/modules/session/Background.qml +++ /dev/null @@ -1,60 +0,0 @@ -import qs.components -import qs.services -import qs.config -import QtQuick -import QtQuick.Shapes - -ShapePath { - id: root - - required property Wrapper wrapper - readonly property real rounding: Config.border.rounding - readonly property bool flatten: wrapper.width < rounding * 2 - readonly property real roundingX: flatten ? wrapper.width / 2 : rounding - - strokeWidth: -1 - fillColor: Colours.palette.m3surface - - PathArc { - relativeX: -root.roundingX - relativeY: root.rounding - radiusX: Math.min(root.rounding, root.wrapper.width) - radiusY: root.rounding - } - PathLine { - relativeX: -(root.wrapper.width - root.roundingX * 2) - relativeY: 0 - } - PathArc { - relativeX: -root.roundingX - relativeY: root.rounding - radiusX: Math.min(root.rounding, root.wrapper.width) - radiusY: root.rounding - direction: PathArc.Counterclockwise - } - PathLine { - relativeX: 0 - relativeY: root.wrapper.height - root.rounding * 2 - } - PathArc { - relativeX: root.roundingX - relativeY: root.rounding - radiusX: Math.min(root.rounding, root.wrapper.width) - radiusY: root.rounding - direction: PathArc.Counterclockwise - } - PathLine { - relativeX: root.wrapper.width - root.roundingX * 2 - relativeY: 0 - } - PathArc { - relativeX: root.roundingX - relativeY: root.rounding - radiusX: Math.min(root.rounding, root.wrapper.width) - radiusY: root.rounding - } - - Behavior on fillColor { - CAnim {} - } -} diff --git a/modules/session/Content.qml b/modules/session/Content.qml index 6c56d4429..e1ba28098 100644 --- a/modules/session/Content.qml +++ b/modules/session/Content.qml @@ -1,24 +1,24 @@ pragma ComponentBehavior: Bound +import QtQuick +import Quickshell +import Caelestia.Config import qs.components import qs.services -import qs.config import qs.utils -import Quickshell -import QtQuick Column { id: root - required property PersistentProperties visibilities + required property DrawerVisibilities visibilities - padding: Appearance.padding.large - spacing: Appearance.spacing.large + padding: Tokens.padding.large + spacing: Tokens.spacing.large SessionButton { id: logout - icon: "logout" + icon: Config.session.icons.logout command: Config.session.commands.logout KeyNavigation.down: shutdown @@ -26,19 +26,19 @@ Column { Component.onCompleted: forceActiveFocus() Connections { - target: root.visibilities - function onLauncherChanged(): void { if (!root.visibilities.launcher) logout.forceActiveFocus(); } + + target: root.visibilities } } SessionButton { id: shutdown - icon: "power_settings_new" + icon: Config.session.icons.shutdown command: Config.session.commands.shutdown KeyNavigation.up: logout @@ -46,21 +46,21 @@ Column { } AnimatedImage { - width: Config.session.sizes.button - height: Config.session.sizes.button - sourceSize.width: width - sourceSize.height: height + width: Tokens.sizes.session.button + height: Tokens.sizes.session.button + sourceSize.width: width * ((QsWindow.window as QsWindow)?.devicePixelRatio ?? 1) playing: visible asynchronous: true - speed: 0.7 + speed: Config.general.sessionGifSpeed source: Paths.absolutePath(Config.paths.sessionGif) + fillMode: AnimatedImage.PreserveAspectFit } SessionButton { id: hibernate - icon: "downloading" + icon: Config.session.icons.hibernate command: Config.session.commands.hibernate KeyNavigation.up: shutdown @@ -70,7 +70,7 @@ Column { SessionButton { id: reboot - icon: "cached" + icon: Config.session.icons.reboot command: Config.session.commands.reboot KeyNavigation.up: hibernate @@ -82,10 +82,10 @@ Column { required property string icon required property list command - implicitWidth: Config.session.sizes.button - implicitHeight: Config.session.sizes.button + implicitWidth: Tokens.sizes.session.button + implicitHeight: Tokens.sizes.session.button - radius: Appearance.rounding.large + radius: Tokens.rounding.large color: button.activeFocus ? Colours.palette.m3secondaryContainer : Colours.tPalette.m3surfaceContainer Keys.onEnterPressed: Quickshell.execDetached(button.command) @@ -96,10 +96,10 @@ Column { return; if (event.modifiers & Qt.ControlModifier) { - if (event.key === Qt.Key_J && KeyNavigation.down) { + if ((event.key === Qt.Key_J || event.key === Qt.Key_N) && KeyNavigation.down) { KeyNavigation.down.focus = true; event.accepted = true; - } else if (event.key === Qt.Key_K && KeyNavigation.up) { + } else if ((event.key === Qt.Key_K || event.key === Qt.Key_P) && KeyNavigation.up) { KeyNavigation.up.focus = true; event.accepted = true; } @@ -117,10 +117,7 @@ Column { StateLayer { radius: parent.radius color: button.activeFocus ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface - - function onClicked(): void { - Quickshell.execDetached(button.command); - } + onClicked: Quickshell.execDetached(button.command) } MaterialIcon { @@ -128,7 +125,7 @@ Column { text: button.icon color: button.activeFocus ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface - font.pointSize: Appearance.font.size.extraLarge + font.pointSize: Tokens.font.size.extraLarge font.weight: 500 } } diff --git a/modules/session/Wrapper.qml b/modules/session/Wrapper.qml index 14b03a809..41d9ba1ae 100644 --- a/modules/session/Wrapper.qml +++ b/modules/session/Wrapper.qml @@ -1,60 +1,39 @@ pragma ComponentBehavior: Bound -import qs.components -import qs.config -import Quickshell import QtQuick +import Caelestia.Config +import qs.components Item { id: root - required property PersistentProperties visibilities - required property var panels + required property DrawerVisibilities visibilities + required property bool sidebarVisible readonly property real nonAnimWidth: content.implicitWidth - visible: width > 0 - implicitWidth: 0 - implicitHeight: content.implicitHeight + readonly property bool shouldBeActive: visibilities.session && Config.session.enabled + property real offsetScale: shouldBeActive ? 0 : 1 + property real sidebarOffset: sidebarVisible ? 14 : 0 - states: State { - name: "visible" - when: root.visibilities.session && Config.session.enabled + visible: offsetScale < 1 + anchors.rightMargin: (-implicitWidth - 5 - sidebarOffset) * offsetScale + implicitWidth: content.implicitWidth + implicitHeight: content.implicitHeight || 510 // Hard coded fallback for first open + opacity: 1 - offsetScale - PropertyChanges { - root.implicitWidth: root.nonAnimWidth + Behavior on offsetScale { + Anim { + type: Anim.DefaultSpatial } } - 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) + active: root.shouldBeActive || root.visible sourceComponent: Content { visibilities: root.visibilities diff --git a/modules/sidebar/Background.qml b/modules/sidebar/Background.qml deleted file mode 100644 index beefdf5c4..000000000 --- a/modules/sidebar/Background.qml +++ /dev/null @@ -1,52 +0,0 @@ -import qs.components -import qs.services -import qs.config -import QtQuick -import QtQuick.Shapes - -ShapePath { - id: root - - required property Wrapper wrapper - required property var panels - - readonly property real rounding: Config.border.rounding - - readonly property real notifsWidthDiff: panels.notifications.width - wrapper.width - readonly property real notifsRoundingX: panels.notifications.height > 0 && notifsWidthDiff < rounding * 2 ? notifsWidthDiff / 2 : rounding - - readonly property real utilsWidthDiff: panels.utilities.width - wrapper.width - readonly property real utilsRoundingX: utilsWidthDiff < rounding * 2 ? utilsWidthDiff / 2 : rounding - - strokeWidth: -1 - fillColor: Colours.palette.m3surface - - PathLine { - relativeX: -root.wrapper.width - root.notifsRoundingX - relativeY: 0 - } - PathArc { - relativeX: root.notifsRoundingX - relativeY: root.rounding - radiusX: root.notifsRoundingX - radiusY: root.rounding - } - PathLine { - relativeX: 0 - relativeY: root.wrapper.height - root.rounding * 2 - } - PathArc { - relativeX: -root.utilsRoundingX - relativeY: root.rounding - radiusX: root.utilsRoundingX - radiusY: root.rounding - } - PathLine { - relativeX: root.wrapper.width + root.utilsRoundingX - relativeY: 0 - } - - Behavior on fillColor { - CAnim {} - } -} diff --git a/modules/sidebar/Content.qml b/modules/sidebar/Content.qml index 1b7feed66..f6b8eb051 100644 --- a/modules/sidebar/Content.qml +++ b/modules/sidebar/Content.qml @@ -1,26 +1,26 @@ -import qs.components -import qs.services -import qs.config import QtQuick import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.services Item { id: root required property Props props - required property var visibilities + required property DrawerVisibilities visibilities ColumnLayout { id: layout anchors.fill: parent - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal StyledRect { Layout.fillWidth: true Layout.fillHeight: true - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal color: Colours.tPalette.m3surfaceContainerLow NotifDock { @@ -30,7 +30,7 @@ Item { } StyledRect { - Layout.topMargin: Appearance.padding.large - layout.spacing + Layout.topMargin: Tokens.padding.large - layout.spacing Layout.fillWidth: true implicitHeight: 1 diff --git a/modules/sidebar/Notif.qml b/modules/sidebar/Notif.qml index 5a317640f..2a390926d 100644 --- a/modules/sidebar/Notif.qml +++ b/modules/sidebar/Notif.qml @@ -1,42 +1,43 @@ pragma ComponentBehavior: Bound -import qs.components -import qs.services -import qs.config -import Quickshell import QtQuick import QtQuick.Layouts +import Quickshell +import Caelestia.Config +import qs.components +import qs.services StyledRect { id: root - required property Notifs.Notif modelData + required property NotifData modelData required property Props props required property bool expanded - required property var visibilities + required property DrawerVisibilities visibilities - readonly property StyledText body: expandedContent.item?.body ?? null - readonly property real nonAnimHeight: expanded ? summary.implicitHeight + expandedContent.implicitHeight + expandedContent.anchors.topMargin + Appearance.padding.normal * 2 : summaryHeightMetrics.height + readonly property StyledText body: (expandedContent.item as ExpandedBody)?.body ?? null + readonly property real nonAnimHeight: expanded ? summary.implicitHeight + expandedContent.implicitHeight + expandedContent.anchors.topMargin + Tokens.padding.normal * 2 : summaryHeightMetrics.height implicitHeight: nonAnimHeight - radius: Appearance.rounding.small + radius: Tokens.rounding.small color: { - const c = root.modelData.urgency === "critical" ? Colours.palette.m3secondaryContainer : Colours.layer(Colours.palette.m3surfaceContainerHigh, 2); + const c = root.modelData?.urgency === "critical" ? Colours.palette.m3secondaryContainer : Colours.layer(Colours.palette.m3surfaceContainerHigh, 2); return expanded ? c : Qt.alpha(c, 0); } + state: expanded ? "expanded" : "" + 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.anchors.margins: root.Tokens.padding.normal + dummySummary.anchors.margins: root.Tokens.padding.normal + compactBody.anchors.margins: root.Tokens.padding.normal + timeStr.anchors.margins: root.Tokens.padding.normal + expandedContent.anchors.margins: root.Tokens.padding.normal + summary.width: root.width - root.Tokens.padding.normal * 2 - timeStr.implicitWidth - root.Tokens.spacing.small summary.maximumLineCount: Number.MAX_SAFE_INTEGER } } @@ -61,8 +62,8 @@ StyledRect { anchors.left: parent.left width: parent.width - text: root.modelData.summary - color: root.modelData.urgency === "critical" ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface + text: root.modelData?.summary ?? "" + color: root.modelData?.urgency === "critical" ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface elide: Text.ElideRight wrapMode: Text.WordWrap maximumLineCount: 1 @@ -75,7 +76,7 @@ StyledRect { anchors.left: parent.left visible: false - text: root.modelData.summary + text: root.modelData?.summary ?? "" } WrappedLoader { @@ -85,11 +86,11 @@ StyledRect { anchors.top: parent.top anchors.left: dummySummary.right anchors.right: parent.right - anchors.leftMargin: Appearance.spacing.small + anchors.leftMargin: Tokens.spacing.small sourceComponent: StyledText { - text: root.modelData.body.replace(/\n/g, " ") - color: root.modelData.urgency === "critical" ? Colours.palette.m3secondary : Colours.palette.m3outline + text: String(root.modelData?.body ?? "").replace(/\n/g, " ") + color: root.modelData?.urgency === "critical" ? Colours.palette.m3secondary : Colours.palette.m3outline elide: Text.ElideRight } } @@ -103,9 +104,9 @@ StyledRect { sourceComponent: StyledText { animate: true - text: root.modelData.timeStr + text: root.modelData?.timeStr ?? "" color: Colours.palette.m3outline - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small } } @@ -116,49 +117,88 @@ StyledRect { anchors.top: summary.bottom anchors.left: parent.left anchors.right: parent.right - anchors.topMargin: Appearance.spacing.small / 2 + anchors.topMargin: Tokens.spacing.small / 2 - sourceComponent: ColumnLayout { - readonly property alias body: body + sourceComponent: ExpandedBody {} + } - spacing: Appearance.spacing.smaller + Behavior on implicitHeight { + Anim { + type: Anim.DefaultSpatial + } + } - StyledText { - id: body + component ExpandedBody: ColumnLayout { + readonly property alias body: 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 + spacing: Tokens.spacing.smaller - onLinkActivated: link => { - Quickshell.execDetached(["app2unit", "-O", "--", link]); - root.visibilities.sidebar = false; - } - } + StyledText { + id: bodyText + + Layout.fillWidth: true + textFormat: Text.MarkdownText + text: String(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 - NotifActionList { - notif: root.modelData + onLinkActivated: link => { + Quickshell.execDetached(["app2unit", "-O", "--", link]); + root.visibilities.sidebar = false; } } - } - Behavior on implicitHeight { - Anim { - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + NotifActionList { + notif: root.modelData } } component WrappedLoader: Loader { + id: comp + required property bool shouldBeActive - opacity: shouldBeActive ? 1 : 0 - active: opacity > 0 + active: false + opacity: 0 - Behavior on opacity { - Anim {} + // Makes the loader load on the same frame shouldBeActive becomes true, which ensures size is set + states: State { + name: "active" + when: comp.shouldBeActive + + PropertyChanges { + comp.opacity: 1 + comp.active: true + } } + + transitions: [ + Transition { + from: "" + to: "active" + + SequentialAnimation { + PropertyAction { + property: "active" + } + Anim { + property: "opacity" + } + } + }, + Transition { + from: "active" + to: "" + + SequentialAnimation { + Anim { + property: "opacity" + } + PropertyAction { + property: "active" + } + } + } + ] } } diff --git a/modules/sidebar/NotifActionList.qml b/modules/sidebar/NotifActionList.qml index d1f1e1f51..084fe6781 100644 --- a/modules/sidebar/NotifActionList.qml +++ b/modules/sidebar/NotifActionList.qml @@ -1,19 +1,19 @@ pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Caelestia.Config import qs.components import qs.components.containers import qs.components.effects import qs.services -import qs.config -import Quickshell -import Quickshell.Widgets -import QtQuick -import QtQuick.Layouts Item { id: root - required property Notifs.Notif notif + required property NotifData notif Layout.fillWidth: true implicitHeight: flickable.contentHeight @@ -94,14 +94,14 @@ Item { id: actionList anchors.fill: parent - spacing: Appearance.spacing.small + spacing: Tokens.spacing.small Repeater { model: [ { isClose: true }, - ...root.notif.actions, + ...(root.notif?.actions ?? []), { isCopy: true } @@ -114,11 +114,11 @@ Item { Layout.fillWidth: true Layout.fillHeight: true - implicitWidth: actionInner.implicitWidth + Appearance.padding.normal * 2 - implicitHeight: actionInner.implicitHeight + Appearance.padding.small * 2 + implicitWidth: actionInner.implicitWidth + Tokens.padding.normal * 2 + implicitHeight: actionInner.implicitHeight + Tokens.padding.small * 2 - Layout.preferredWidth: implicitWidth + (actionStateLayer.pressed ? Appearance.padding.large : 0) - radius: actionStateLayer.pressed ? Appearance.rounding.small / 2 : Appearance.rounding.small + Layout.preferredWidth: implicitWidth + (actionStateLayer.pressed ? Tokens.padding.large : 0) + radius: actionStateLayer.pressed ? Tokens.rounding.small / 2 : Tokens.rounding.small color: Colours.layer(Colours.palette.m3surfaceContainerHighest, 4) Timer { @@ -131,7 +131,7 @@ Item { StateLayer { id: actionStateLayer - function onClicked(): void { + onClicked: { if (action.modelData.isClose) { root.notif.close(); } else if (action.modelData.isCopy) { @@ -150,7 +150,7 @@ Item { id: actionInner anchors.centerIn: parent - sourceComponent: action.modelData.isClose || action.modelData.isCopy ? iconBtn : root.notif.hasActionIcons ? iconComp : textComp + sourceComponent: action.modelData.isClose || action.modelData.isCopy ? iconBtn : root.notif?.hasActionIcons ? iconComp : textComp } Component { @@ -167,6 +167,7 @@ Item { id: iconComp IconImage { + asynchronous: true source: Quickshell.iconPath(action.modelData.identifier) } } @@ -182,15 +183,13 @@ Item { Behavior on Layout.preferredWidth { Anim { - duration: Appearance.anim.durations.expressiveFastSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + type: Anim.FastSpatial } } Behavior on radius { Anim { - duration: Appearance.anim.durations.expressiveFastSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + type: Anim.FastSpatial } } } diff --git a/modules/sidebar/NotifDock.qml b/modules/sidebar/NotifDock.qml index d039d15d6..4509ce26b 100644 --- a/modules/sidebar/NotifDock.qml +++ b/modules/sidebar/NotifDock.qml @@ -1,25 +1,26 @@ pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Caelestia.Config import qs.components -import qs.components.controls import qs.components.containers +import qs.components.controls import qs.components.effects import qs.services -import qs.config -import Quickshell -import Quickshell.Widgets -import QtQuick -import QtQuick.Layouts +import qs.utils Item { id: root required property Props props - required property var visibilities + 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 + anchors.margins: Tokens.padding.normal Component.onCompleted: Notifs.list.forEach(n => n.popup = false) @@ -29,7 +30,7 @@ Item { anchors.top: parent.top anchors.left: parent.left anchors.right: parent.right - anchors.margins: Appearance.padding.small + anchors.margins: Tokens.padding.small implicitHeight: Math.max(count.implicitHeight, titleText.implicitHeight) @@ -43,8 +44,8 @@ Item { text: root.notifCount color: Colours.palette.m3outline - font.pointSize: Appearance.font.size.normal - font.family: Appearance.font.family.mono + font.pointSize: Tokens.font.size.normal + font.family: Tokens.font.family.mono font.weight: 500 Behavior on anchors.leftMargin { @@ -62,12 +63,12 @@ Item { anchors.verticalCenter: parent.verticalCenter anchors.left: count.right anchors.right: parent.right - anchors.leftMargin: Appearance.spacing.small + anchors.leftMargin: Tokens.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.pointSize: Tokens.font.size.normal + font.family: Tokens.font.family.mono font.weight: 500 elide: Text.ElideRight } @@ -80,24 +81,25 @@ Item { anchors.right: parent.right anchors.top: title.bottom anchors.bottom: parent.bottom - anchors.topMargin: Appearance.spacing.smaller + anchors.topMargin: Tokens.spacing.smaller - radius: Appearance.rounding.small + radius: Tokens.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 + spacing: Tokens.spacing.large Image { asynchronous: true - source: Qt.resolvedUrl(`${Quickshell.shellDir}/assets/dino.png`) + source: Paths.absolutePath(Config.paths.noNotifsPic) fillMode: Image.PreserveAspectFit - sourceSize.width: clipRect.width * 0.8 + sourceSize.width: clipRect.width * 0.8 * ((QsWindow.window as QsWindow)?.devicePixelRatio ?? 1) layer.enabled: true layer.effect: Colouriser { @@ -110,15 +112,15 @@ Item { 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.pointSize: Tokens.font.size.large + font.family: Tokens.font.family.mono font.weight: 500 } } Behavior on opacity { Anim { - duration: Appearance.anim.durations.extraLarge + type: Anim.StandardExtraLarge } } } @@ -150,25 +152,33 @@ Item { id: clearTimer repeat: true - interval: 50 + triggeredOnStart: true + interval: Math.max(15, Math.min(80, 69.8 - 12.3 * Math.log(Notifs.notClosed.length))) onTriggered: { - let next = null; - for (let i = 0; i < notifList.repeater.count; i++) { - next = notifList.repeater.itemAt(i); - if (!next?.closed) - break; - } - if (next) - next.closeAll(); - else + const first = Notifs.notClosed[0]; + if (!first) { stop(); + return; + } + + const appName = first.appName; + let cleared = 0; + for (const n of Notifs.notClosed.filter(n => n.appName === appName)) { + n.close(); + cleared++; + if (cleared > 30) { + interval = 5; + return; + } + } } } Loader { + asynchronous: true anchors.right: parent.right anchors.bottom: parent.bottom - anchors.margins: Appearance.padding.normal + anchors.margins: Tokens.padding.normal scale: root.notifCount > 0 ? 1 : 0.5 opacity: root.notifCount > 0 ? 1 : 0 @@ -178,9 +188,9 @@ Item { id: clearBtn icon: "clear_all" - radius: Appearance.rounding.normal - padding: Appearance.padding.normal - font.pointSize: Math.round(Appearance.font.size.large * 1.2) + radius: Tokens.rounding.normal + padding: Tokens.padding.normal + font.pointSize: Math.round(Tokens.font.size.large * 1.2) onClicked: clearTimer.start() Elevation { @@ -193,14 +203,13 @@ Item { Behavior on scale { Anim { - duration: Appearance.anim.durations.expressiveFastSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + type: Anim.FastSpatial } } Behavior on opacity { Anim { - duration: Appearance.anim.durations.expressiveFastSpatial + duration: Tokens.anim.durations.expressiveFastSpatial } } } diff --git a/modules/sidebar/NotifDockList.qml b/modules/sidebar/NotifDockList.qml index b927e91a7..2677698b8 100644 --- a/modules/sidebar/NotifDockList.qml +++ b/modules/sidebar/NotifDockList.qml @@ -1,44 +1,52 @@ pragma ComponentBehavior: Bound +import QtQuick +import Quickshell +import Caelestia.Components +import Caelestia.Config import qs.components import qs.services -import qs.config -import Quickshell -import QtQuick -Item { +LazyListView { id: root required property Props props required property Flickable container - required property var visibilities + required property DrawerVisibilities visibilities + + anchors.left: parent?.left + anchors.right: parent?.right + implicitHeight: contentHeight + + spacing: Tokens.spacing.small + readyDelay: 1 + cacheBuffer: 400 + asynchronous: true + + onViewportAdjustNeeded: d => { + if (contentYAnim.running) + contentYAnim.complete(); + contentYAnim.to = Math.max(0, container.contentY + d); + contentYAnim.start(); + } - readonly property alias repeater: repeater - readonly property int spacing: Appearance.spacing.small - property bool flag + useCustomViewport: true + viewport: Qt.rect(0, container.contentY, width, container.height) - anchors.left: parent.left - anchors.right: parent.right - implicitHeight: { - const item = repeater.itemAt(repeater.count - 1); - return item ? item.y + item.implicitHeight : 0; - } + removeDuration: Tokens.anim.durations.normal - 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() + 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()]; } + } + delegate: Component { MouseArea { id: notif @@ -46,36 +54,20 @@ Item { required property string modelData readonly property bool closed: notifInner.notifCount === 0 - readonly property alias nonAnimHeight: notifInner.nonAnimHeight property int startY function closeAll(): void { - for (const n of Notifs.notClosed.filter(n => n.appName === modelData)) - n.close(); - } - - y: { - root.flag; // Force update - let y = 0; - for (let i = 0; i < index; i++) { - const item = repeater.itemAt(i); - if (!item.closed) - y += item.nonAnimHeight + root.spacing; - } - return y; + clearTimer.start(); } - 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 + LazyListView.trackViewport: !notifInner.expanded && notifInner.nonAnimHeight < notifInner.implicitHeight + LazyListView.preferredHeight: closed ? 0 : notifInner.nonAnimHeight + LazyListView.visibleHeight: notifInner.implicitHeight implicitHeight: notifInner.implicitHeight + opacity: LazyListView.removing || closed || LazyListView.adding ? 0 : 1 + scale: LazyListView.removing || closed ? 0.6 : LazyListView.adding ? 0 : 1 + hoverEnabled: true cursorShape: pressed ? Qt.ClosedHandCursor : undefined acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton @@ -106,37 +98,21 @@ Item { 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 + Timer { + id: clearTimer + + interval: 15 + repeat: true + triggeredOnStart: true + onTriggered: { + const notifs = Notifs.notClosed.filter(n => n.appName === notif.modelData); + if (notifs.length === 0) { + stop(); + return; + } + + for (const n of notifs.slice(0, 30)) + n.close(); } } @@ -149,19 +125,37 @@ Item { visibilities: root.visibilities } - Behavior on x { + Behavior on y { + enabled: notif.LazyListView.ready + Anim { - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + type: Anim.DefaultSpatial } } - Behavior on y { + Behavior on opacity { + Anim {} + } + + Behavior on scale { + Anim { + type: Anim.DefaultSpatial + } + } + + Behavior on x { Anim { - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + type: Anim.DefaultSpatial } } } } + + Anim { + id: contentYAnim + + target: root.container + property: "contentY" + type: Anim.DefaultSpatial + } } diff --git a/modules/sidebar/NotifGroup.qml b/modules/sidebar/NotifGroup.qml index 16aac33ba..2920af743 100644 --- a/modules/sidebar/NotifGroup.qml +++ b/modules/sidebar/NotifGroup.qml @@ -1,14 +1,14 @@ pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.Notifications +import Caelestia.Config 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 @@ -16,18 +16,25 @@ StyledRect { required property string modelData required property Props props required property Flickable container - required property var visibilities + required property DrawerVisibilities visibilities readonly property list notifs: Notifs.list.filter(n => n.appName === modelData) - readonly property int notifCount: notifs.reduce((acc, n) => n.closed ? acc : acc + 1, 0) - readonly property string image: notifs.find(n => !n.closed && n.image.length > 0)?.image ?? "" - readonly property string appIcon: notifs.find(n => !n.closed && n.appIcon.length > 0)?.appIcon ?? "" - readonly property int urgency: notifs.some(n => !n.closed && n.urgency === NotificationUrgency.Critical) ? NotificationUrgency.Critical : notifs.some(n => n.urgency === NotificationUrgency.Normal) ? NotificationUrgency.Normal : NotificationUrgency.Low + readonly property list activeNotifs: notifs.filter(n => !n.closed) + readonly property int notifCount: activeNotifs.length + readonly property string image: activeNotifs.find(n => n.image.length > 0)?.image ?? "" + readonly property string appIcon: activeNotifs.find(n => n.appIcon.length > 0)?.appIcon ?? "" + readonly property int urgency: { + if (activeNotifs.find(n => n.urgency === NotificationUrgency.Critical)) + return NotificationUrgency.Critical; + if (activeNotifs.find(n => n.urgency === NotificationUrgency.Normal)) + return NotificationUrgency.Normal; + return NotificationUrgency.Low; + } readonly property int nonAnimHeight: { - const headerHeight = header.implicitHeight + (root.expanded ? Math.round(Appearance.spacing.small / 2) : 0); - const columnHeight = headerHeight + notifList.nonAnimHeight + column.Layout.topMargin + column.Layout.bottomMargin; - return Math.round(Math.max(Config.notifs.sizes.image, columnHeight) + Appearance.padding.normal * 2); + const headerHeight = header.implicitHeight + (root.expanded ? Math.round(Tokens.spacing.small / 2) : 0); + const columnHeight = headerHeight + notifList.layoutHeight; + return Math.round(Math.max(TokenConfig.sizes.notifs.image, columnHeight) + Tokens.padding.normal * 2); } readonly property bool expanded: props.expandedNotifs.includes(modelData) @@ -47,26 +54,32 @@ StyledRect { anchors.left: parent?.left anchors.right: parent?.right - implicitHeight: content.implicitHeight + Appearance.padding.normal * 2 + implicitHeight: nonAnimHeight clip: true - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal color: Colours.layer(Colours.palette.m3surfaceContainer, 2) + Behavior on implicitHeight { + Anim { + type: Anim.DefaultSpatial + } + } + RowLayout { id: content anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top - anchors.margins: Appearance.padding.normal + anchors.margins: Tokens.padding.normal - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal Item { Layout.alignment: Qt.AlignLeft | Qt.AlignTop - implicitWidth: Config.notifs.sizes.image - implicitHeight: Config.notifs.sizes.image + implicitWidth: TokenConfig.sizes.notifs.image + implicitHeight: TokenConfig.sizes.notifs.image Component { id: imageComp @@ -74,10 +87,14 @@ StyledRect { Image { source: Qt.resolvedUrl(root.image) fillMode: Image.PreserveAspectCrop + sourceSize: { + const size = TokenConfig.sizes.notifs.image * ((QsWindow.window as QsWindow)?.devicePixelRatio ?? 1); + return Qt.size(size, size); + } cache: false asynchronous: true - width: Config.notifs.sizes.image - height: Config.notifs.sizes.image + width: TokenConfig.sizes.notifs.image + height: TokenConfig.sizes.notifs.image } } @@ -85,7 +102,7 @@ StyledRect { id: appIconComp ColouredIcon { - implicitSize: Math.round(Config.notifs.sizes.image * 0.6) + implicitSize: Math.round(TokenConfig.sizes.notifs.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") @@ -96,38 +113,40 @@ StyledRect { id: materialIconComp MaterialIcon { - text: Icons.getNotifIcon(root.notifs[0]?.summary, root.urgency) + text: Icons.getNotifIcon(root.activeNotifs[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 + font.pointSize: Tokens.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 + radius: Tokens.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 + implicitWidth: Tokens.sizes.notifs.badge + implicitHeight: Tokens.sizes.notifs.badge color: root.urgency === NotificationUrgency.Critical ? Colours.palette.m3error : root.urgency === NotificationUrgency.Low ? Colours.palette.m3surfaceContainerHigh : Colours.palette.m3secondaryContainer - radius: Appearance.rounding.full + radius: Tokens.rounding.full ColouredIcon { anchors.centerIn: parent - implicitSize: Math.round(Config.notifs.sizes.badge * 0.6) + implicitSize: Math.round(Tokens.sizes.notifs.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") @@ -136,94 +155,87 @@ StyledRect { } } - ColumnLayout { + Column { id: column - Layout.topMargin: -Appearance.padding.small - Layout.bottomMargin: -Appearance.padding.small / 2 Layout.fillWidth: true - spacing: 0 + spacing: root.expanded ? Math.round(Tokens.spacing.small / 2) : 0 + + Behavior on spacing { + Anim {} + } RowLayout { id: header - Layout.bottomMargin: root.expanded ? Math.round(Appearance.spacing.small / 2) : 0 - Layout.fillWidth: true - spacing: Appearance.spacing.smaller + anchors.left: parent.left + anchors.right: parent.right + spacing: Tokens.spacing.smaller StyledText { Layout.fillWidth: true text: root.modelData color: Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small elide: Text.ElideRight } StyledText { animate: true - text: root.notifs.find(n => !n.closed)?.timeStr ?? "" + text: root.activeNotifs[0]?.timeStr ?? "" color: Colours.palette.m3outline - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small } StyledRect { - implicitWidth: expandBtn.implicitWidth + Appearance.padding.smaller * 2 - implicitHeight: groupCount.implicitHeight + Appearance.padding.small + implicitWidth: expandBtn.implicitWidth + Tokens.padding.smaller * 2 + implicitHeight: groupCount.implicitHeight + Tokens.padding.small color: root.urgency === NotificationUrgency.Critical ? Colours.palette.m3error : Colours.layer(Colours.palette.m3surfaceContainerHigh, 3) - radius: Appearance.rounding.full + radius: Tokens.rounding.full StateLayer { color: root.urgency === NotificationUrgency.Critical ? Colours.palette.m3onError : Colours.palette.m3onSurface - - function onClicked(): void { - root.toggleExpand(!root.expanded); - } + onClicked: root.toggleExpand(!root.expanded) } RowLayout { id: expandBtn anchors.centerIn: parent - spacing: Appearance.spacing.small / 2 + spacing: Tokens.spacing.small / 2 StyledText { id: groupCount - Layout.leftMargin: Appearance.padding.small / 2 + Layout.leftMargin: Tokens.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 + font.pointSize: Tokens.font.size.small } MaterialIcon { - Layout.rightMargin: -Appearance.padding.small / 2 + Layout.rightMargin: -Tokens.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 + Layout.topMargin: root.expanded ? -Math.floor(Tokens.padding.smaller / 2) : 0 Behavior on rotation { Anim { - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + type: Anim.DefaultSpatial } } Behavior on Layout.topMargin { Anim { - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + type: Anim.DefaultSpatial } } } } } - - Behavior on Layout.bottomMargin { - Anim {} - } } NotifGroupList { diff --git a/modules/sidebar/NotifGroupList.qml b/modules/sidebar/NotifGroupList.qml index e586b5f7a..72b8b3a8e 100644 --- a/modules/sidebar/NotifGroupList.qml +++ b/modules/sidebar/NotifGroupList.qml @@ -1,114 +1,82 @@ pragma ComponentBehavior: Bound +import QtQuick +import Quickshell +import Caelestia.Components +import Caelestia.Config import qs.components import qs.services -import qs.config -import Quickshell -import QtQuick -import QtQuick.Layouts -Item { +LazyListView { id: root required property Props props required property list notifs required property bool expanded required property Flickable container - required property var visibilities - - readonly property real nonAnimHeight: { - let h = -root.spacing; - for (let i = 0; i < repeater.count; i++) { - const item = repeater.itemAt(i); - if (!item.modelData.closed && !item.previewHidden) - h += item.nonAnimHeight + root.spacing; - } - return h; - } - - readonly property int spacing: Math.round(Appearance.spacing.small / 2) - property bool showAllNotifs - property bool flag + required property DrawerVisibilities visibilities signal requestToggleExpand(expand: bool) - onExpandedChanged: { - if (expanded) { - clearTimer.stop(); - showAllNotifs = true; - } else { - clearTimer.start(); - } - } + anchors.left: parent.left + anchors.right: parent.right + implicitHeight: contentHeight - Layout.fillWidth: true - implicitHeight: nonAnimHeight + spacing: Math.round(Tokens.spacing.small / 2) + asynchronous: true - Timer { - id: clearTimer + readyDelay: 1 + cacheBuffer: 400 + removeDuration: Tokens.anim.durations.normal - interval: Appearance.anim.durations.normal - onTriggered: root.showAllNotifs = false + useCustomViewport: true + viewport: { + tWatcher.transform; // mapToItem is not reactive so use this to trigger updates + return Qt.rect(0, container.contentY - mapToItem(container.contentItem, 0, 0).y, width, container.height); } - Repeater { - id: repeater + model: ScriptModel { + values: { + if (root.expanded) + return root.notifs; + + let count = 0; + let i = 0; + const previewNum = root.Config.notifs.groupPreviewNum; + while (i < root.notifs.length && count < previewNum) { + if (!(root.notifs[i]?.closed ?? true)) + count++; + i++; + } - model: ScriptModel { - values: root.showAllNotifs ? root.notifs : root.notifs.slice(0, Config.notifs.groupPreviewNum + 1) - onValuesChanged: root.flagChanged() + return root.notifs.slice(0, i); } + } + delegate: Component { MouseArea { id: notif required property int index - required property Notifs.Notif modelData - - readonly property alias nonAnimHeight: notifInner.nonAnimHeight - readonly property bool previewHidden: { - if (root.expanded) - return false; + required property NotifData modelData - let extraHidden = 0; - for (let i = 0; i < index; i++) - if (root.notifs[i].closed) - extraHidden++; - - return index >= Config.notifs.groupPreviewNum + extraHidden; - } property int startY - y: { - root.flag; // Force update - let y = 0; - for (let i = 0; i < index; i++) { - const item = repeater.itemAt(i); - if (!item.modelData.closed && !item.previewHidden) - y += item.nonAnimHeight + root.spacing; - } - return y; - } + Component.onCompleted: modelData?.lock(this) + Component.onDestruction: modelData?.unlock(this) - 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 + LazyListView.preferredHeight: modelData?.closed || LazyListView.removing ? 0 : notifInner.nonAnimHeight + LazyListView.visibleHeight: modelData?.closed || LazyListView.removing ? 0 : notifInner.implicitHeight implicitHeight: notifInner.implicitHeight + opacity: LazyListView.removing || LazyListView.adding ? 0 : 1 + scale: LazyListView.removing || LazyListView.adding ? 0.7 : 1 + 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 + enabled: !(modelData?.closed ?? true) drag.target: this drag.axis: Drag.XAxis @@ -118,7 +86,7 @@ Item { if (event.button === Qt.RightButton) root.requestToggleExpand(!root.expanded); else if (event.button === Qt.MiddleButton) - modelData.close(); + modelData?.close(); } onPositionChanged: event => { if (pressed && !root.expanded) { @@ -131,32 +99,12 @@ Item { 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 - } + modelData?.close(); } ParallelAnimation { - running: notif.modelData.closed - onFinished: notif.modelData.unlock(notif) + running: notif.modelData?.closed ?? false + onFinished: notif.modelData?.unlock(notif) Anim { target: notif @@ -180,6 +128,14 @@ Item { visibilities: root.visibilities } + Behavior on y { + enabled: notif.LazyListView.ready + + Anim { + type: Anim.DefaultSpatial + } + } + Behavior on opacity { Anim {} } @@ -190,24 +146,16 @@ Item { 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 + type: Anim.DefaultSpatial } } } } - Behavior on implicitHeight { - Anim { - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial - } + TransformWatcher { + id: tWatcher + + a: root.container.contentItem + b: root } } diff --git a/modules/sidebar/Wrapper.qml b/modules/sidebar/Wrapper.qml index 9303c6b94..108c51a26 100644 --- a/modules/sidebar/Wrapper.qml +++ b/modules/sidebar/Wrapper.qml @@ -1,66 +1,42 @@ pragma ComponentBehavior: Bound -import qs.components -import qs.config import QtQuick +import Caelestia.Config +import qs.components Item { id: root - required property var visibilities - required property var panels + required property DrawerVisibilities visibilities readonly property Props props: Props {} - visible: width > 0 - implicitWidth: 0 + readonly property bool shouldBeActive: visibilities.sidebar && Config.sidebar.enabled + property real offsetScale: shouldBeActive ? 0 : 1 - states: State { - name: "visible" - when: root.visibilities.sidebar && Config.sidebar.enabled + visible: offsetScale < 1 + anchors.rightMargin: (-implicitWidth - 5) * offsetScale + implicitWidth: Tokens.sizes.sidebar.width + opacity: 1 - offsetScale - PropertyChanges { - root.implicitWidth: Config.sidebar.sizes.width + Behavior on offsetScale { + Anim { + type: Anim.DefaultSpatial } } - 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.margins: Tokens.padding.large anchors.bottomMargin: 0 - active: true - Component.onCompleted: active = Qt.binding(() => (root.visibilities.sidebar && Config.sidebar.enabled) || root.visible) + active: root.shouldBeActive || root.visible sourceComponent: Content { - implicitWidth: Config.sidebar.sizes.width - Appearance.padding.large * 2 + implicitWidth: Tokens.sizes.sidebar.width - Tokens.padding.large * 2 props: root.props visibilities: root.visibilities } diff --git a/modules/utilities/Background.qml b/modules/utilities/Background.qml index fbce89616..5b58b41e0 100644 --- a/modules/utilities/Background.qml +++ b/modules/utilities/Background.qml @@ -1,15 +1,14 @@ -import qs.components -import qs.services -import qs.config import QtQuick import QtQuick.Shapes +import qs.components +import qs.services ShapePath { id: root required property Wrapper wrapper required property var sidebar - readonly property real rounding: Config.border.rounding + required property real rounding readonly property bool flatten: wrapper.height < rounding * 2 readonly property real roundingY: flatten ? wrapper.height / 2 : rounding diff --git a/modules/utilities/Content.qml b/modules/utilities/Content.qml index 902656de5..e03c546fd 100644 --- a/modules/utilities/Content.qml +++ b/modules/utilities/Content.qml @@ -1,14 +1,17 @@ import "cards" -import qs.config import QtQuick import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.modules.bar.popouts as BarPopouts Item { id: root required property var props - required property var visibilities - required property Item popouts + required property DrawerVisibilities visibilities + required property BarPopouts.Wrapper popouts + required property matrix4x4 deformMatrix implicitWidth: layout.implicitWidth implicitHeight: layout.implicitHeight @@ -17,7 +20,7 @@ Item { id: layout anchors.fill: parent - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal IdleInhibit {} @@ -35,5 +38,6 @@ Item { RecordingDeleteModal { props: root.props + deformMatrix: root.deformMatrix } } diff --git a/modules/utilities/RecordingDeleteModal.qml b/modules/utilities/RecordingDeleteModal.qml index 127afe93b..13125f2c3 100644 --- a/modules/utilities/RecordingDeleteModal.qml +++ b/modules/utilities/RecordingDeleteModal.qml @@ -1,20 +1,22 @@ pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import QtQuick.Shapes +import Caelestia +import Caelestia.Config 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 + required property matrix4x4 deformMatrix + asynchronous: true anchors.fill: parent opacity: root.props.recordingConfirmDelete ? 1 : 0 @@ -32,14 +34,16 @@ Loader { 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 + anchors.margins: -Tokens.padding.large + anchors.rightMargin: -Tokens.padding.large - Config.border.thickness + anchors.bottomMargin: -Tokens.padding.large - Config.border.thickness opacity: 0.5 StyledRect { anchors.fill: parent - topLeftRadius: Config.border.rounding + anchors.rightMargin: -parent.width * (1 - root.deformMatrix.m11) / 2 // Additional bit to account for deform + anchors.bottomMargin: -parent.height * 0.1 // Additional bit to account for overshoot + topLeftRadius: Tokens.rounding.large color: Colours.palette.m3scrim } @@ -50,13 +54,14 @@ Loader { preferredRendererType: Shape.CurveRenderer asynchronous: true + // Bottom left ShapePath { - startX: -Config.border.rounding * 2 - startY: shape.height - Config.border.thickness + startX: -root.Config.border.smoothing * 2 + startY: shape.height - root.Config.border.thickness strokeWidth: 0 fillGradient: LinearGradient { orientation: LinearGradient.Horizontal - x1: -Config.border.rounding * 2 + x1: -root.Config.border.smoothing * 2 GradientStop { position: 0 @@ -69,31 +74,34 @@ Loader { } PathLine { - relativeX: Config.border.rounding + relativeX: root.Config.border.smoothing relativeY: 0 } - PathArc { - relativeY: -Config.border.rounding - radiusX: Config.border.rounding - radiusY: Config.border.rounding - direction: PathArc.Counterclockwise + PathCubic { + relativeX: root.Config.border.smoothing + relativeY: -root.Config.border.smoothing + relativeControl1X: root.Config.border.smoothing * 0.93 + relativeControl1Y: -root.Config.border.smoothing * 0.07 + relativeControl2X: root.Config.border.smoothing * 0.93 + relativeControl2Y: -root.Config.border.smoothing * 0.07 } PathLine { relativeX: 0 - relativeY: Config.border.rounding + Config.border.thickness + relativeY: root.Config.border.smoothing + root.Config.border.thickness } PathLine { - relativeX: -Config.border.rounding * 2 + relativeX: -root.Config.border.smoothing * 2 relativeY: 0 } } + // Top right curve ShapePath { - startX: shape.width - Config.border.rounding - Config.border.thickness + startX: shape.width - root.Config.border.smoothing - root.Config.border.thickness + (1 - root.deformMatrix.m11) * shape.width / 2 strokeWidth: 0 fillGradient: LinearGradient { orientation: LinearGradient.Vertical - y1: -Config.border.rounding * 2 + y1: -root.Config.border.smoothing * 2 GradientStop { position: 0 @@ -105,19 +113,20 @@ Loader { } } - PathArc { - relativeX: Config.border.rounding - relativeY: -Config.border.rounding - radiusX: Config.border.rounding - radiusY: Config.border.rounding - direction: PathArc.Counterclockwise + PathCubic { + relativeX: root.Config.border.smoothing + relativeY: -root.Config.border.smoothing + relativeControl1X: root.Config.border.smoothing * 0.93 + relativeControl1Y: -root.Config.border.smoothing * 0.07 + relativeControl2X: root.Config.border.smoothing * 0.93 + relativeControl2Y: -root.Config.border.smoothing * 0.07 } PathLine { relativeX: 0 - relativeY: -Config.border.rounding + relativeY: -root.Config.border.smoothing } PathLine { - relativeX: Config.border.thickness + relativeX: root.Config.border.thickness relativeY: 0 } PathLine { @@ -129,15 +138,15 @@ Loader { StyledRect { anchors.centerIn: parent - radius: Appearance.rounding.large + radius: Tokens.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 + width: Math.min(parent.width - Tokens.padding.large * 2, implicitWidth) + implicitWidth: deleteConfirmationLayout.implicitWidth + Tokens.padding.large * 3 + implicitHeight: deleteConfirmationLayout.implicitHeight + Tokens.padding.large * 3 MouseArea { anchors.fill: parent @@ -154,26 +163,26 @@ Loader { id: deleteConfirmationLayout anchors.fill: parent - anchors.margins: Appearance.padding.large * 1.5 - spacing: Appearance.spacing.normal + anchors.margins: Tokens.padding.large * 1.5 + spacing: Tokens.spacing.normal StyledText { text: qsTr("Delete recording?") - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.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 + font.pointSize: Tokens.font.size.small wrapMode: Text.WrapAtWordBoundaryOrAnywhere } RowLayout { - Layout.topMargin: Appearance.spacing.normal + Layout.topMargin: Tokens.spacing.normal Layout.alignment: Qt.AlignRight - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal TextButton { text: qsTr("Cancel") @@ -194,8 +203,7 @@ Loader { Behavior on scale { Anim { - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + type: Anim.DefaultSpatial } } } diff --git a/modules/utilities/Wrapper.qml b/modules/utilities/Wrapper.qml index 77178e36e..cace56852 100644 --- a/modules/utilities/Wrapper.qml +++ b/modules/utilities/Wrapper.qml @@ -1,78 +1,72 @@ pragma ComponentBehavior: Bound -import qs.components -import qs.config -import Quickshell import QtQuick +import Quickshell +import Caelestia.Config +import qs.components +import qs.modules.sidebar as Sidebar +import qs.modules.bar.popouts as BarPopouts Item { id: root - required property var visibilities - required property Item sidebar - required property Item popouts + required property DrawerVisibilities visibilities + required property Sidebar.Wrapper sidebar + required property BarPopouts.Wrapper popouts + property real horizontalStretch + property matrix4x4 deformMatrix readonly property PersistentProperties props: PersistentProperties { property bool recordingListExpanded: false + property bool recordingAudioExpanded: 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)) + property real offsetScale: shouldBeActive ? 0 : 1 + property real sidebarLerp - visible: height > 0 - implicitHeight: 0 - implicitWidth: sidebar.visible ? sidebar.width : Config.utilities.sizes.width - - onStateChanged: { - if (state === "visible" && timer.running) { - timer.triggered(); - timer.stop(); - } - } + visible: offsetScale < 1 + anchors.bottomMargin: (-implicitHeight - 5) * offsetScale + implicitHeight: content.implicitHeight + content.anchors.margins * 2 + implicitWidth: sidebar.width * (1 - sidebar.offsetScale) * horizontalStretch * sidebarLerp + Tokens.sizes.utilities.width * (1 - sidebarLerp) + opacity: 1 - offsetScale states: State { - name: "visible" - when: root.shouldBeActive + name: "attachedToSidebar" + when: root.visibilities.sidebar PropertyChanges { - root.implicitHeight: content.implicitHeight + Appearance.padding.large * 2 + root.sidebarLerp: 1 } } transitions: [ Transition { from: "" - to: "visible" Anim { - target: root - property: "implicitHeight" - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + property: "sidebarLerp" + duration: Tokens.anim.durations.expressiveDefaultSpatial / 2 + easing: Tokens.anim.standardAccel } }, Transition { - from: "visible" to: "" Anim { - target: root - property: "implicitHeight" - easing.bezierCurve: Appearance.anim.curves.emphasized + property: "sidebarLerp" + duration: Tokens.anim.durations.expressiveDefaultSpatial / 2 + easing: Tokens.anim.standardDecel } } ] - Timer { - id: timer - - running: true - interval: Appearance.anim.durations.extraLarge - onTriggered: { - content.active = Qt.binding(() => root.shouldBeActive || root.visible); - content.visible = true; + Behavior on offsetScale { + Anim { + type: Anim.DefaultSpatial } } @@ -81,16 +75,17 @@ Item { anchors.top: parent.top anchors.left: parent.left - anchors.margins: Appearance.padding.large + anchors.margins: Tokens.padding.large - visible: false - active: true + asynchronous: true + active: root.shouldBeActive || root.visible sourceComponent: Content { - implicitWidth: root.implicitWidth - Appearance.padding.large * 2 + implicitWidth: root.implicitWidth - content.anchors.margins * 2 props: root.props visibilities: root.visibilities popouts: root.popouts + deformMatrix: root.deformMatrix } } } diff --git a/modules/utilities/cards/IdleInhibit.qml b/modules/utilities/cards/IdleInhibit.qml index 0344e3ad2..c37ef3c52 100644 --- a/modules/utilities/cards/IdleInhibit.qml +++ b/modules/utilities/cards/IdleInhibit.qml @@ -1,17 +1,17 @@ +import QtQuick +import QtQuick.Layouts +import Caelestia.Config 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 + implicitHeight: layout.implicitHeight + (IdleInhibitor.enabled ? activeChip.implicitHeight + activeChip.anchors.topMargin : 0) + Tokens.padding.large * 2 - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal color: Colours.tPalette.m3surfaceContainer clip: true @@ -21,14 +21,14 @@ StyledRect { anchors.top: parent.top anchors.left: parent.left anchors.right: parent.right - anchors.margins: Appearance.padding.large - spacing: Appearance.spacing.normal + anchors.margins: Tokens.padding.large + spacing: Tokens.spacing.normal StyledRect { implicitWidth: implicitHeight - implicitHeight: icon.implicitHeight + Appearance.padding.smaller * 2 + implicitHeight: icon.implicitHeight + Tokens.padding.smaller * 2 - radius: Appearance.rounding.full + radius: Tokens.rounding.full color: IdleInhibitor.enabled ? Colours.palette.m3secondary : Colours.palette.m3secondaryContainer MaterialIcon { @@ -37,7 +37,7 @@ StyledRect { anchors.centerIn: parent text: "coffee" color: IdleInhibitor.enabled ? Colours.palette.m3onSecondary : Colours.palette.m3onSecondaryContainer - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large } } @@ -48,7 +48,7 @@ StyledRect { StyledText { Layout.fillWidth: true text: qsTr("Keep Awake") - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.font.size.normal elide: Text.ElideRight } @@ -56,7 +56,7 @@ StyledRect { Layout.fillWidth: true text: IdleInhibitor.enabled ? qsTr("Preventing sleep mode") : qsTr("Normal power management") color: Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small elide: Text.ElideRight } } @@ -70,11 +70,12 @@ StyledRect { 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 + anchors.topMargin: Tokens.spacing.larger + anchors.bottomMargin: IdleInhibitor.enabled ? Tokens.padding.large : -implicitHeight + anchors.leftMargin: Tokens.padding.large opacity: IdleInhibitor.enabled ? 1 : 0 scale: IdleInhibitor.enabled ? 1 : 0.5 @@ -82,32 +83,31 @@ StyledRect { Component.onCompleted: active = Qt.binding(() => opacity > 0) sourceComponent: StyledRect { - implicitWidth: activeText.implicitWidth + Appearance.padding.normal * 2 - implicitHeight: activeText.implicitHeight + Appearance.padding.small * 2 + implicitWidth: activeText.implicitWidth + Tokens.padding.normal * 2 + implicitHeight: activeText.implicitHeight + Tokens.padding.small * 2 - radius: Appearance.rounding.full + radius: Tokens.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")) + text: qsTr("Active since %1").arg(Qt.formatTime(IdleInhibitor.enabledSince, GlobalConfig.services.useTwelveHourClock ? "hh:mm a" : "hh:mm")) color: Colours.palette.m3onPrimary - font.pointSize: Math.round(Appearance.font.size.small * 0.9) + font.pointSize: Math.round(Tokens.font.size.small * 0.9) } } Behavior on anchors.bottomMargin { Anim { - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + type: Anim.DefaultSpatial } } Behavior on opacity { Anim { - duration: Appearance.anim.durations.small + type: Anim.StandardSmall } } @@ -118,8 +118,7 @@ StyledRect { Behavior on implicitHeight { Anim { - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + type: Anim.DefaultSpatial } } } diff --git a/modules/utilities/cards/Record.qml b/modules/utilities/cards/Record.qml index 273c64002..495e7de50 100644 --- a/modules/utilities/cards/Record.qml +++ b/modules/utilities/cards/Record.qml @@ -1,44 +1,59 @@ pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Caelestia.Config import qs.components import qs.components.controls import qs.services -import qs.config -import QtQuick -import QtQuick.Layouts StyledRect { id: root required property var props - required property var visibilities + required property DrawerVisibilities visibilities Layout.fillWidth: true implicitHeight: layout.implicitHeight + layout.anchors.margins * 2 - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal color: Colours.tPalette.m3surfaceContainer + property bool actuallyRecording: Recorder.running + readonly property bool recordingBusy: Recorder.running || Recorder.starting + property string lastError: "" + property string currentVideoMode: Recorder.videoMode || Config.utilities.recording.videoMode || "fullscreen" + + // Computed audio mode based on settings + readonly property string currentAudioMode: { + const recordSystem = Config.utilities.recording.recordSystem; + const recordMic = Config.utilities.recording.recordMicrophone; + if (recordSystem && recordMic) return "combined"; + if (recordSystem) return "system"; + if (recordMic) return "mic"; + return "none"; + } + ColumnLayout { id: layout anchors.fill: parent - anchors.margins: Appearance.padding.large - spacing: Appearance.spacing.normal + anchors.margins: Tokens.padding.large + spacing: Tokens.spacing.normal RowLayout { - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal z: 1 StyledRect { implicitWidth: implicitHeight implicitHeight: { - const h = icon.implicitHeight + Appearance.padding.smaller * 2; + const h = icon.implicitHeight + Tokens.padding.smaller * 2; return h - (h % 2); } radius: Appearance.rounding.full - color: Recorder.running ? Colours.palette.m3secondary : Colours.palette.m3secondaryContainer + color: root.recordingBusy ? Colours.palette.m3secondary : Colours.palette.m3secondaryContainer MaterialIcon { id: icon @@ -47,7 +62,7 @@ StyledRect { anchors.horizontalCenterOffset: -0.5 anchors.verticalCenterOffset: 1.5 text: "screen_record" - color: Recorder.running ? Colours.palette.m3onSecondary : Colours.palette.m3onSecondaryContainer + color: root.recordingBusy ? Colours.palette.m3onSecondary : Colours.palette.m3onSecondaryContainer font.pointSize: Appearance.font.size.large } } @@ -59,67 +74,263 @@ StyledRect { StyledText { Layout.fillWidth: true text: qsTr("Screen Recorder") - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.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 + text: { + if (root.lastError !== "") return qsTr("Error: %1").arg(root.lastError); + if (Recorder.starting) return root.startingText(Recorder.videoMode || root.currentVideoMode); + if (Recorder.paused) return qsTr("Recording paused"); + if (root.actuallyRecording) { + const videoText = root.videoModeLabel(Recorder.videoMode || root.currentVideoMode); + const audioText = root.audioModeLabel(Recorder.audioMode || root.currentAudioMode); + return qsTr("Recording %1 with %2").arg(videoText).arg(audioText); + } + return qsTr("Recording off"); + } + color: root.lastError !== "" ? Colours.palette.m3error : 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 + disabled: root.recordingBusy + active: menuItems.find(m => m.mode === Config.utilities.recording.videoMode) ?? menuItems[0] + menu.onItemSelected: item => { + Config.utilities.recording.videoMode = item.mode; + root.currentVideoMode = item.mode; + Config.save(); + } menuItems: [ MenuItem { + property string mode: "fullscreen" icon: "fullscreen" text: qsTr("Record fullscreen") activeText: qsTr("Fullscreen") - onClicked: Recorder.start() + onClicked: startRecording(mode) }, MenuItem { + property string mode: "region" 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"]) + onClicked: startRecording(mode) }, MenuItem { - icon: "volume_up" - text: qsTr("Record region with sound") - activeText: qsTr("Region") - onClicked: Recorder.start(["-sr"]) + property string mode: "window" + icon: "web_asset" + text: qsTr("Record window") + activeText: qsTr("Window") + onClicked: startRecording(mode) } ] } } + StyledRect { + id: errorBanner + Layout.fillWidth: true + visible: root.lastError !== "" + implicitHeight: visible ? errorText.implicitHeight + Appearance.padding.normal * 2 : 0 + radius: Appearance.rounding.small + color: Colours.palette.m3errorContainer + + StyledText { + id: errorText + anchors.fill: parent + anchors.margins: Appearance.padding.normal + text: root.lastError + color: Colours.palette.m3onErrorContainer + wrapMode: Text.Wrap + font.pointSize: Appearance.font.size.small + } + + Behavior on implicitHeight { + Anim { duration: Appearance.anim.durations.small } + } + } + + // Audio Sources Section + ColumnLayout { + Layout.fillWidth: true + visible: !root.recordingBusy + spacing: Appearance.spacing.small + + RowLayout { + spacing: Appearance.spacing.small + + StyledText { + text: qsTr("Audio Sources") + font.pointSize: Appearance.font.size.small + color: Colours.palette.m3onSurfaceVariant + } + + Item { Layout.fillWidth: true } + + IconButton { + icon: root.props.recordingAudioExpanded ? "unfold_less" : "unfold_more" + type: IconButton.Text + label.animate: true + onClicked: { + root.props.recordingAudioExpanded = !root.props.recordingAudioExpanded; + } + } + } + + Item { + id: audioSourcesContainer + + Layout.fillWidth: true + Layout.preferredHeight: root.props.recordingAudioExpanded ? audioSourcesLayout.implicitHeight : 0 + clip: true + enabled: root.props.recordingAudioExpanded + opacity: root.props.recordingAudioExpanded ? 1 : 0 + visible: root.props.recordingAudioExpanded || height > 0 + + ColumnLayout { + id: audioSourcesLayout + + width: parent.width + y: root.props.recordingAudioExpanded ? 0 : -Appearance.spacing.small + spacing: Appearance.spacing.smaller + + // System Audio (Default Sink) + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + StyledSwitch { + checked: Config.utilities.recording.recordSystem + onToggled: { + Config.utilities.recording.recordSystem = checked; + Config.save(); + } + } + + StyledText { + Layout.preferredWidth: 85 + text: qsTr("System") + font.pointSize: Appearance.font.size.small + elide: Text.ElideRight + } + + StyledSlider { + id: systemVolumeSlider + Layout.fillWidth: true + implicitHeight: 24 + opacity: Config.utilities.recording.recordSystem ? 1.0 : 0.5 + from: 0 + to: 1 + value: Audio.volume + onMoved: { + Audio.setVolume(value); + } + } + + StyledText { + text: Math.round(Audio.volume * 100) + "%" + font.pointSize: Appearance.font.size.small + color: Colours.palette.m3onSurfaceVariant + Layout.preferredWidth: 40 + } + + IconButton { + icon: Audio.muted ? "volume_off" : "volume_up" + type: Audio.muted ? IconButton.Filled : IconButton.Tonal + font.pointSize: Appearance.font.size.small + onClicked: { + if (Audio.sink?.audio) { + Audio.sink.audio.muted = !Audio.sink.audio.muted; + } + } + } + } + + // Microphone (Default Source) + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + StyledSwitch { + checked: Config.utilities.recording.recordMicrophone + onToggled: { + Config.utilities.recording.recordMicrophone = checked; + Config.save(); + } + } + + StyledText { + Layout.preferredWidth: 85 + text: qsTr("Microphone") + font.pointSize: Appearance.font.size.small + elide: Text.ElideRight + } + + StyledSlider { + id: micVolumeSlider + Layout.fillWidth: true + implicitHeight: 24 + opacity: Config.utilities.recording.recordMicrophone ? 1.0 : 0.5 + from: 0 + to: 1 + value: Audio.sourceVolume + onMoved: { + Audio.setSourceVolume(value); + } + } + + StyledText { + text: Math.round(Audio.sourceVolume * 100) + "%" + font.pointSize: Appearance.font.size.small + color: Colours.palette.m3onSurfaceVariant + Layout.preferredWidth: 40 + } + + IconButton { + icon: Audio.sourceMuted ? "mic_off" : "mic" + type: Audio.sourceMuted ? IconButton.Filled : IconButton.Tonal + font.pointSize: Appearance.font.size.small + onClicked: { + if (Audio.source?.audio) { + Audio.source.audio.muted = !Audio.source.audio.muted; + } + } + } + } + + Behavior on y { + Anim { duration: Appearance.anim.durations.small } + } + } + + Behavior on Layout.preferredHeight { + Anim { type: Anim.DefaultSpatial } + } + + Behavior on opacity { + Anim { duration: Appearance.anim.durations.small } + } + } + } + Loader { id: listOrControls - property bool running: Recorder.running + property bool running: root.recordingBusy + asynchronous: true Layout.fillWidth: true Layout.preferredHeight: implicitHeight sourceComponent: running ? recordingControls : recordingList Behavior on Layout.preferredHeight { id: locHeightAnim - enabled: false - Anim {} } @@ -130,15 +341,15 @@ StyledRect { target: listOrControls property: "scale" to: 0.7 - duration: Appearance.anim.durations.small - easing.bezierCurve: Appearance.anim.curves.standardAccel + duration: Tokens.anim.durations.small + easing: Tokens.anim.standardAccel } Anim { target: listOrControls property: "opacity" to: 0 - duration: Appearance.anim.durations.small - easing.bezierCurve: Appearance.anim.curves.standardAccel + duration: Tokens.anim.durations.small + easing: Tokens.anim.standardAccel } } PropertyAction { @@ -157,15 +368,15 @@ StyledRect { target: listOrControls property: "scale" to: 1 - duration: Appearance.anim.durations.small - easing.bezierCurve: Appearance.anim.curves.standardDecel + duration: Tokens.anim.durations.small + easing: Tokens.anim.standardDecel } Anim { target: listOrControls property: "opacity" to: 1 - duration: Appearance.anim.durations.small - easing.bezierCurve: Appearance.anim.curves.standardDecel + duration: Tokens.anim.durations.small + easing: Tokens.anim.standardDecel } } } @@ -175,7 +386,6 @@ StyledRect { Component { id: recordingList - RecordingList { props: root.props visibilities: root.visibilities @@ -184,24 +394,22 @@ StyledRect { Component { id: recordingControls - RowLayout { - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal StyledRect { radius: Appearance.rounding.full - color: Recorder.paused ? Colours.palette.m3tertiary : Colours.palette.m3error + color: Recorder.starting ? Colours.palette.m3secondary : Recorder.paused ? Colours.palette.m3tertiary : Colours.palette.m3error - implicitWidth: recText.implicitWidth + Appearance.padding.normal * 2 - implicitHeight: recText.implicitHeight + Appearance.padding.smaller * 2 + implicitWidth: recText.implicitWidth + Tokens.padding.normal * 2 + implicitHeight: recText.implicitHeight + Tokens.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 + text: Recorder.starting ? "WAIT" : Recorder.paused ? "PAUSED" : "REC" + color: Recorder.starting ? Colours.palette.m3onSecondary : Recorder.paused ? Colours.palette.m3onTertiary : Colours.palette.m3onError font.family: Appearance.font.family.mono } @@ -210,42 +418,41 @@ StyledRect { } SequentialAnimation on opacity { - running: !Recorder.paused + running: !Recorder.starting && !Recorder.paused && root.actuallyRecording alwaysRunToEnd: true loops: Animation.Infinite - Anim { from: 1 to: 0 - duration: Appearance.anim.durations.large - easing.bezierCurve: Appearance.anim.curves.emphasizedAccel + duration: Tokens.anim.durations.large + easing: Tokens.anim.emphasizedAccel } Anim { from: 0 to: 1 - duration: Appearance.anim.durations.extraLarge - easing.bezierCurve: Appearance.anim.curves.emphasizedDecel + duration: Tokens.anim.durations.extraLarge + easing: Tokens.anim.emphasizedDecel } } } StyledText { text: { - const elapsed = Recorder.elapsed; + if (Recorder.starting) + return root.startingText(Recorder.videoMode || root.currentVideoMode); + 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 + font.pointSize: Tokens.font.size.normal } Item { @@ -253,15 +460,15 @@ StyledRect { } IconButton { + disabled: Recorder.starting label.animate: true icon: Recorder.paused ? "play_arrow" : "pause" toggle: true checked: Recorder.paused type: IconButton.Tonal - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large onClicked: { Recorder.togglePause(); - internalChecked = Recorder.paused; } } @@ -270,8 +477,103 @@ StyledRect { inactiveColour: Colours.palette.m3error inactiveOnColour: Colours.palette.m3onError font.pointSize: Appearance.font.size.large - onClicked: Recorder.stop() + onClicked: stopRecording() + } + } + } + + function videoModeLabel(mode) { + switch (mode) { + case "region": return qsTr("Region"); + case "window": return qsTr("Window"); + default: return qsTr("Fullscreen"); + } + } + + function audioModeLabel(mode) { + switch (mode) { + case "combined": return qsTr("system audio + microphone"); + case "system": return qsTr("system audio"); + case "mic": return qsTr("microphone"); + default: return qsTr("no audio"); + } + } + + function startingText(mode) { + switch (mode) { + case "region": return qsTr("Select a recording region"); + case "window": return qsTr("Select a window to record"); + default: return qsTr("Starting fullscreen recording"); + } + } + + function startRecording(videoMode) { + // Clear any previous errors + root.lastError = ""; + + const selectedVideoMode = videoMode || Config.utilities.recording.videoMode || "fullscreen"; + const audioMode = root.currentAudioMode; + + Config.utilities.recording.videoMode = selectedVideoMode; + root.currentVideoMode = selectedVideoMode; + + console.log("Starting recording - Video:", selectedVideoMode, "Audio:", audioMode); + + // Call Recorder service + const success = Recorder.start(selectedVideoMode, audioMode); + + if (!success) { + root.lastError = "Failed to start recording"; + } + } + + function stopRecording() { + root.lastError = ""; + Recorder.stop(); + } + + // Clear error after timeout + Timer { + id: errorTimeout + interval: 10000 + repeat: false + running: root.lastError !== "" + onTriggered: { + root.lastError = ""; + } + } + + Connections { + target: Recorder + + function onRunningChanged() { + // Sync actuallyRecording with Recorder.running + root.actuallyRecording = Recorder.running; + + if (!Recorder.running) { + console.log("Recording stopped"); } } + + function onErrorOccurred(errorMsg) { + console.error("Recorder error:", errorMsg); + root.lastError = errorMsg; + errorTimeout.restart(); + } + + function onRecordingStarted() { + console.log("Recording started successfully"); + root.lastError = ""; + } + + function onRecordingStopped() { + console.log("Recording stopped successfully"); + } + } + + Component.onCompleted: { + // Sync initial state + root.actuallyRecording = Recorder.running; + root.currentVideoMode = Recorder.videoMode || Config.utilities.recording.videoMode || "fullscreen"; } } diff --git a/modules/utilities/cards/RecordingList.qml b/modules/utilities/cards/RecordingList.qml index b9d757a4d..951f7f09e 100644 --- a/modules/utilities/cards/RecordingList.qml +++ b/modules/utilities/cards/RecordingList.qml @@ -1,23 +1,22 @@ pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Caelestia.Config +import Caelestia.Models import qs.components -import qs.components.controls import qs.components.containers +import qs.components.controls import qs.services -import qs.config import qs.utils -import Caelestia -import Caelestia.Models -import Quickshell -import Quickshell.Widgets -import QtQuick -import QtQuick.Layouts ColumnLayout { id: root required property var props - required property var visibilities + required property DrawerVisibilities visibilities spacing: 0 @@ -28,19 +27,19 @@ ColumnLayout { onClicked: root.props.recordingListExpanded = !root.props.recordingListExpanded RowLayout { - spacing: Appearance.spacing.smaller + spacing: Tokens.spacing.smaller MaterialIcon { Layout.alignment: Qt.AlignVCenter text: "list" - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large } StyledText { Layout.alignment: Qt.AlignVCenter Layout.fillWidth: true text: qsTr("Recordings") - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.font.size.normal } IconButton { @@ -62,8 +61,8 @@ ColumnLayout { } Layout.fillWidth: true - Layout.rightMargin: -Appearance.spacing.small - implicitHeight: (Appearance.font.size.larger + Appearance.padding.small) * (root.props.recordingListExpanded ? 10 : 3) + Layout.rightMargin: -Tokens.spacing.small + implicitHeight: (Tokens.font.size.larger + Tokens.padding.small) * (root.props.recordingListExpanded ? 10 : 3) clip: true StyledScrollBar.vertical: StyledScrollBar { @@ -78,14 +77,14 @@ ColumnLayout { anchors.left: list.contentItem.left anchors.right: list.contentItem.right - anchors.rightMargin: Appearance.spacing.small - spacing: Appearance.spacing.small / 2 + anchors.rightMargin: Tokens.spacing.small + spacing: Tokens.spacing.small / 2 Component.onCompleted: baseName = modelData.baseName StyledText { Layout.fillWidth: true - Layout.rightMargin: Appearance.spacing.small / 2 + Layout.rightMargin: Tokens.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})/); @@ -105,7 +104,7 @@ ColumnLayout { onClicked: { root.visibilities.utilities = false; root.visibilities.sidebar = false; - Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.playback, recording.modelData.path]); + Quickshell.execDetached(["app2unit", "--", ...GlobalConfig.general.apps.playback, recording.modelData.path]); } } @@ -115,7 +114,7 @@ ColumnLayout { onClicked: { root.visibilities.utilities = false; root.visibilities.sidebar = false; - Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.explorer, recording.modelData.path]); + Quickshell.execDetached(["app2unit", "--", ...GlobalConfig.general.apps.explorer, recording.modelData.path]); } } @@ -163,19 +162,20 @@ ColumnLayout { } Loader { + asynchronous: true anchors.centerIn: parent opacity: list.count === 0 ? 1 : 0 active: opacity > 0 sourceComponent: ColumnLayout { - spacing: Appearance.spacing.small + spacing: Tokens.spacing.small MaterialIcon { Layout.alignment: Qt.AlignHCenter text: "scan_delete" color: Colours.palette.m3outline - font.pointSize: Appearance.font.size.extraLarge + font.pointSize: Tokens.font.size.extraLarge opacity: root.props.recordingListExpanded ? 1 : 0 scale: root.props.recordingListExpanded ? 1 : 0 @@ -195,7 +195,7 @@ ColumnLayout { } RowLayout { - spacing: Appearance.spacing.smaller + spacing: Tokens.spacing.smaller MaterialIcon { Layout.alignment: Qt.AlignHCenter @@ -233,8 +233,7 @@ ColumnLayout { Behavior on implicitHeight { Anim { - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + type: Anim.DefaultSpatial } } } diff --git a/modules/utilities/cards/Toggles.qml b/modules/utilities/cards/Toggles.qml index 5b57528bc..8c294eb38 100644 --- a/modules/utilities/cards/Toggles.qml +++ b/modules/utilities/cards/Toggles.qml @@ -1,112 +1,169 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell.Bluetooth +import Caelestia.Config import qs.components import qs.components.controls import qs.services -import qs.config -import qs.modules.controlcenter -import Quickshell -import Quickshell.Bluetooth -import QtQuick -import QtQuick.Layouts +import qs.modules.bar.popouts as BarPopouts StyledRect { id: root - required property var visibilities - required property Item popouts + 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 ?? true)) + return false; + + if (seenIds.has(item.id)) { + return false; + } + + if (item.id === "vpn") { + return GlobalConfig.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 + implicitHeight: layout.implicitHeight + Tokens.padding.large * 2 - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal color: Colours.tPalette.m3surfaceContainer ColumnLayout { id: layout anchors.fill: parent - anchors.margins: Appearance.padding.large - spacing: Appearance.spacing.normal + anchors.margins: Tokens.padding.large + spacing: Tokens.spacing.normal StyledText { text: qsTr("Quick Toggles") - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.font.size.normal } - RowLayout { - Layout.alignment: Qt.AlignHCenter - spacing: Appearance.spacing.small - - Toggle { - icon: "wifi" - checked: Network.wifiEnabled - onClicked: Network.toggleWifi() - } + QuickToggleRow { + rowModel: root.needExtraRow ? root.quickToggles.slice(0, root.splitIndex) : root.quickToggles + } - Toggle { - icon: "bluetooth" - checked: Bluetooth.defaultAdapter?.enabled ?? false - onClicked: { - const adapter = Bluetooth.defaultAdapter; - if (adapter) - adapter.enabled = !adapter.enabled; - } - } + QuickToggleRow { + visible: root.needExtraRow + rowModel: root.needExtraRow ? root.quickToggles.slice(root.splitIndex) : [] + } + } - Toggle { - icon: "mic" - checked: !Audio.sourceMuted - onClicked: { - const audio = Audio.source?.audio; - if (audio) - audio.muted = !audio.muted; - } - } + component QuickToggleRow: RowLayout { + property var rowModel: [] - Toggle { - icon: "settings" - inactiveOnColour: Colours.palette.m3onSurfaceVariant - toggle: false - onClicked: { - root.visibilities.utilities = false; - root.popouts.detach("network"); - } - } + Layout.fillWidth: true + spacing: Tokens.spacing.small - Toggle { - icon: "gamepad" - checked: GameMode.enabled - onClicked: GameMode.enabled = !GameMode.enabled - } + Repeater { + model: parent.rowModel - Toggle { - icon: "notifications_off" - checked: Notifs.dnd - onClicked: Notifs.dnd = !Notifs.dnd - } + delegate: DelegateChooser { + role: "id" - Toggle { - icon: "vpn_key" - checked: VPN.connected - enabled: !VPN.connecting - visible: Config.utilities.vpn.provider.some(p => typeof p === "object" ? (p.enabled === true) : false) - onClicked: VPN.toggle() + 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 // qmllint disable unresolved-type + onClicked: { + const adapter = Bluetooth.defaultAdapter; // qmllint disable unresolved-type + 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 && VPN.status.state !== "needs-auth" && VPN.status.state !== "error" + enabled: !VPN.connecting + toggle: VPN.status.state !== "needs-auth" && VPN.status.state !== "error" + inactiveOnColour: Colours.palette.m3onSurfaceVariant + 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 + Layout.preferredWidth: implicitWidth + (stateLayer.pressed ? Tokens.padding.large : internalChecked ? Tokens.padding.smaller : 0) + radius: stateLayer.pressed ? Tokens.rounding.small / 2 : internalChecked ? Tokens.rounding.small : Tokens.rounding.normal inactiveColour: Colours.layer(Colours.palette.m3surfaceContainerHighest, 2) toggle: true - radiusAnim.duration: Appearance.anim.durations.expressiveFastSpatial - radiusAnim.easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + radiusAnim.duration: Tokens.anim.durations.expressiveFastSpatial + radiusAnim.easing: Tokens.anim.expressiveFastSpatial Behavior on Layout.preferredWidth { Anim { - duration: Appearance.anim.durations.expressiveFastSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + type: Anim.FastSpatial } } } diff --git a/modules/utilities/toasts/ToastItem.qml b/modules/utilities/toasts/ToastItem.qml index f47550006..5247e7737 100644 --- a/modules/utilities/toasts/ToastItem.qml +++ b/modules/utilities/toasts/ToastItem.qml @@ -1,10 +1,10 @@ +import QtQuick +import QtQuick.Layouts +import Caelestia +import Caelestia.Config import qs.components import qs.components.effects import qs.services -import qs.config -import Caelestia -import QtQuick -import QtQuick.Layouts StyledRect { id: root @@ -13,9 +13,9 @@ StyledRect { anchors.left: parent.left anchors.right: parent.right - implicitHeight: layout.implicitHeight + Appearance.padding.smaller * 2 + implicitHeight: layout.implicitHeight + Tokens.padding.smaller * 2 - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal color: { if (root.modelData.type === Toast.Success) return Colours.palette.m3successContainer; @@ -50,13 +50,13 @@ StyledRect { id: layout anchors.fill: parent - anchors.margins: Appearance.padding.smaller - anchors.leftMargin: Appearance.padding.normal - anchors.rightMargin: Appearance.padding.normal - spacing: Appearance.spacing.normal + anchors.margins: Tokens.padding.smaller + anchors.leftMargin: Tokens.padding.normal + anchors.rightMargin: Tokens.padding.normal + spacing: Tokens.spacing.normal StyledRect { - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal color: { if (root.modelData.type === Toast.Success) return Colours.palette.m3success; @@ -68,7 +68,7 @@ StyledRect { } implicitWidth: implicitHeight - implicitHeight: icon.implicitHeight + Appearance.padding.smaller * 2 + implicitHeight: icon.implicitHeight + Tokens.padding.smaller * 2 MaterialIcon { id: icon @@ -84,7 +84,7 @@ StyledRect { return Colours.palette.m3onError; return Colours.palette.m3onSurfaceVariant; } - font.pointSize: Math.round(Appearance.font.size.large * 1.2) + font.pointSize: Math.round(Tokens.font.size.large * 1.2) } } @@ -106,7 +106,7 @@ StyledRect { return Colours.palette.m3onErrorContainer; return Colours.palette.m3onSurface; } - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.font.size.normal elide: Text.ElideRight } diff --git a/modules/utilities/toasts/Toasts.qml b/modules/utilities/toasts/Toasts.qml index 2915404e0..21f2934e1 100644 --- a/modules/utilities/toasts/Toasts.qml +++ b/modules/utilities/toasts/Toasts.qml @@ -1,18 +1,29 @@ pragma ComponentBehavior: Bound -import qs.components -import qs.config -import Caelestia -import Quickshell import QtQuick +import Quickshell +import Caelestia +import Caelestia.Config +import qs.components +import qs.services Item { id: root - readonly property int spacing: Appearance.spacing.small + readonly property int spacing: Tokens.spacing.small property bool flag - implicitWidth: Config.utilities.sizes.toastWidth - Appearance.padding.normal * 2 + function shouldShowToast(toast: Toast): bool { + if (!Notifs.hasFullscreen()) + return true; + if (Config.utilities.toasts.fullscreen === "all") + return true; + if (Config.utilities.toasts.fullscreen === "important") + return toast.type === Toast.Warning || toast.type === Toast.Error; + return false; + } + + implicitWidth: Tokens.sizes.utilities.toastWidth - Tokens.padding.normal * 2 implicitHeight: { let h = -spacing; for (let i = 0; i < repeater.count; i++) { @@ -31,10 +42,12 @@ Item { const toasts = []; let count = 0; for (const toast of Toaster.toasts) { + if (!root.shouldShowToast(toast)) + continue; toasts.push(toast); if (!toast.closed) { count++; - if (count > Config.utilities.maxToasts) + if (count > root.Config.utilities.maxToasts) break; } } @@ -98,8 +111,7 @@ Item { properties: "opacity,scale" from: 0 to: 1 - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + type: Anim.DefaultSpatial } ParallelAnimation { @@ -135,8 +147,7 @@ Item { Behavior on anchors.bottomMargin { Anim { - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + type: Anim.DefaultSpatial } } } diff --git a/modules/windowinfo/Buttons.qml b/modules/windowinfo/Buttons.qml index 89acfe6d6..c4fbbd53a 100644 --- a/modules/windowinfo/Buttons.qml +++ b/modules/windowinfo/Buttons.qml @@ -1,9 +1,11 @@ -import qs.components -import qs.services -import qs.config -import Quickshell.Widgets +pragma ComponentBehavior: Bound + import QtQuick import QtQuick.Layouts +import Quickshell.Widgets +import Caelestia.Config +import qs.components +import qs.services ColumnLayout { id: root @@ -12,14 +14,14 @@ ColumnLayout { property bool moveToWsExpanded anchors.fill: parent - spacing: Appearance.spacing.small + spacing: Tokens.spacing.small RowLayout { - Layout.topMargin: Appearance.padding.large - Layout.leftMargin: Appearance.padding.large - Layout.rightMargin: Appearance.padding.large + Layout.topMargin: Tokens.padding.large + Layout.leftMargin: Tokens.padding.large + Layout.rightMargin: Tokens.padding.large - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal StyledText { Layout.fillWidth: true @@ -29,17 +31,14 @@ ColumnLayout { StyledRect { color: Colours.palette.m3primary - radius: Appearance.rounding.small + radius: Tokens.rounding.small - implicitWidth: moveToWsIcon.implicitWidth + Appearance.padding.small * 2 - implicitHeight: moveToWsIcon.implicitHeight + Appearance.padding.small + implicitWidth: moveToWsIcon.implicitWidth + Tokens.padding.small * 2 + implicitHeight: moveToWsIcon.implicitHeight + Tokens.padding.small StateLayer { color: Colours.palette.m3onPrimary - - function onClicked(): void { - root.moveToWsExpanded = !root.moveToWsExpanded; - } + onClicked: root.moveToWsExpanded = !root.moveToWsExpanded } MaterialIcon { @@ -50,27 +49,27 @@ ColumnLayout { animate: true text: root.moveToWsExpanded ? "expand_more" : "keyboard_arrow_right" color: Colours.palette.m3onPrimary - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large } } } WrapperItem { Layout.fillWidth: true - Layout.leftMargin: Appearance.padding.large * 2 - Layout.rightMargin: Appearance.padding.large * 2 + Layout.leftMargin: Tokens.padding.large * 2 + Layout.rightMargin: Tokens.padding.large * 2 Layout.preferredHeight: root.moveToWsExpanded ? implicitHeight : 0 clip: true - topMargin: Appearance.spacing.normal - bottomMargin: Appearance.spacing.normal + topMargin: Tokens.spacing.normal + bottomMargin: Tokens.spacing.normal GridLayout { id: wsGrid - rowSpacing: Appearance.spacing.smaller - columnSpacing: Appearance.spacing.normal + rowSpacing: Tokens.spacing.smaller + columnSpacing: Tokens.spacing.normal columns: 5 Repeater { @@ -81,14 +80,14 @@ ColumnLayout { readonly property int wsId: Math.floor((Hypr.activeWsId - 1) / 10) * 10 + index + 1 readonly property bool isCurrent: root.client?.workspace.id === wsId + onClicked: { + 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 - - function onClicked(): void { - Hypr.dispatch(`movetoworkspace ${wsId},address:0x${root.client?.address}`); - } } } } @@ -100,24 +99,22 @@ ColumnLayout { RowLayout { Layout.fillWidth: true - Layout.leftMargin: Appearance.padding.large - Layout.rightMargin: Appearance.padding.large - Layout.bottomMargin: Appearance.padding.large + Layout.leftMargin: Tokens.padding.large + Layout.rightMargin: Tokens.padding.large + Layout.bottomMargin: Tokens.padding.large - spacing: root.client?.lastIpcObject.floating ? Appearance.spacing.normal : Appearance.spacing.small + spacing: root.client?.lastIpcObject.floating ? Tokens.spacing.normal : Tokens.spacing.small Button { color: Colours.palette.m3secondaryContainer onColor: Colours.palette.m3onSecondaryContainer text: root.client?.lastIpcObject.floating ? qsTr("Tile") : qsTr("Float") - - function onClicked(): void { - Hypr.dispatch(`togglefloating address:0x${root.client?.address}`); - } + onClicked: Hypr.dispatch(`togglefloating address:0x${root.client?.address}`) } Loader { - active: root.client?.lastIpcObject.floating + asynchronous: true + active: root.client?.lastIpcObject.floating ?? false Layout.fillWidth: active Layout.leftMargin: active ? 0 : -parent.spacing Layout.rightMargin: active ? 0 : -parent.spacing @@ -126,10 +123,7 @@ ColumnLayout { color: Colours.palette.m3secondaryContainer onColor: Colours.palette.m3onSecondaryContainer text: root.client?.lastIpcObject.pinned ? qsTr("Unpin") : qsTr("Pin") - - function onClicked(): void { - Hypr.dispatch(`pin address:0x${root.client?.address}`); - } + onClicked: Hypr.dispatch(`pin address:0x${root.client?.address}`) } } @@ -137,10 +131,7 @@ ColumnLayout { color: Colours.palette.m3errorContainer onColor: Colours.palette.m3onErrorContainer text: qsTr("Kill") - - function onClicked(): void { - Hypr.dispatch(`killwindow address:0x${root.client?.address}`); - } + onClicked: Hypr.dispatch(`killwindow address:0x${root.client?.address}`) } } @@ -149,22 +140,18 @@ ColumnLayout { property alias disabled: stateLayer.disabled property alias text: label.text - function onClicked(): void { - } + signal clicked - radius: Appearance.rounding.small + radius: Tokens.rounding.small Layout.fillWidth: true - implicitHeight: label.implicitHeight + Appearance.padding.small * 2 + implicitHeight: label.implicitHeight + Tokens.padding.small * 2 StateLayer { id: stateLayer color: parent.onColor - - function onClicked(): void { - parent.onClicked(); - } + onClicked: parent.clicked() } StyledText { @@ -174,7 +161,7 @@ ColumnLayout { animate: true color: parent.onColor - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.font.size.normal } } } diff --git a/modules/windowinfo/Details.qml b/modules/windowinfo/Details.qml index f9ee66a68..1820409f0 100644 --- a/modules/windowinfo/Details.qml +++ b/modules/windowinfo/Details.qml @@ -1,9 +1,9 @@ -import qs.components -import qs.services -import qs.config -import Quickshell.Hyprland import QtQuick import QtQuick.Layouts +import Quickshell.Hyprland +import Caelestia.Config +import qs.components +import qs.services ColumnLayout { id: root @@ -11,15 +11,15 @@ ColumnLayout { required property HyprlandToplevel client anchors.fill: parent - spacing: Appearance.spacing.small + spacing: Tokens.spacing.small Label { - Layout.topMargin: Appearance.padding.large * 2 + Layout.topMargin: Tokens.padding.large * 2 text: root.client?.title ?? qsTr("No active client") wrapMode: Text.WrapAtWordBoundaryOrAnywhere - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large font.weight: 500 } @@ -27,16 +27,16 @@ ColumnLayout { text: root.client?.lastIpcObject.class ?? qsTr("No active client") color: Colours.palette.m3tertiary - font.pointSize: Appearance.font.size.larger + font.pointSize: Tokens.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 + Layout.leftMargin: Tokens.padding.large * 2 + Layout.rightMargin: Tokens.padding.large * 2 + Layout.topMargin: Tokens.spacing.normal + Layout.bottomMargin: Tokens.spacing.large color: Colours.palette.m3secondary } @@ -130,11 +130,11 @@ ColumnLayout { required property string text property alias color: icon.color - Layout.leftMargin: Appearance.padding.large - Layout.rightMargin: Appearance.padding.large + Layout.leftMargin: Tokens.padding.large + Layout.rightMargin: Tokens.padding.large Layout.fillWidth: true - spacing: Appearance.spacing.smaller + spacing: Tokens.spacing.smaller MaterialIcon { id: icon @@ -149,13 +149,13 @@ ColumnLayout { text: detail.text elide: Text.ElideRight - font.pointSize: Appearance.font.size.normal + font.pointSize: Tokens.font.size.normal } } component Label: StyledText { - Layout.leftMargin: Appearance.padding.large - Layout.rightMargin: Appearance.padding.large + Layout.leftMargin: Tokens.padding.large + Layout.rightMargin: Tokens.padding.large Layout.fillWidth: true elide: Text.ElideRight horizontalAlignment: Text.AlignHCenter diff --git a/modules/windowinfo/Preview.qml b/modules/windowinfo/Preview.qml index 4cc0aab86..fed858915 100644 --- a/modules/windowinfo/Preview.qml +++ b/modules/windowinfo/Preview.qml @@ -1,13 +1,13 @@ 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 +import Quickshell +import Quickshell.Hyprland +import Quickshell.Wayland +import Caelestia.Config +import qs.components +import qs.services Item { id: root @@ -15,7 +15,7 @@ Item { required property ShellScreen screen required property HyprlandToplevel client - Layout.preferredWidth: preview.implicitWidth + Appearance.padding.large * 2 + Layout.preferredWidth: preview.implicitWidth + Tokens.padding.large * 2 Layout.fillHeight: true StyledClippingRect { @@ -24,15 +24,16 @@ Item { anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top anchors.bottom: label.top - anchors.topMargin: Appearance.padding.large - anchors.bottomMargin: Appearance.spacing.normal + anchors.topMargin: Tokens.padding.large + anchors.bottomMargin: Tokens.spacing.normal implicitWidth: view.implicitWidth color: Colours.tPalette.m3surfaceContainer - radius: Appearance.rounding.small + radius: Tokens.rounding.small Loader { + asynchronous: true anchors.centerIn: parent active: !root.client @@ -43,14 +44,14 @@ Item { Layout.alignment: Qt.AlignHCenter text: "web_asset_off" color: Colours.palette.m3outline - font.pointSize: Appearance.font.size.extraLarge * 3 + font.pointSize: Tokens.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.pointSize: Tokens.font.size.extraLarge font.weight: 500 } @@ -58,7 +59,7 @@ Item { Layout.alignment: Qt.AlignHCenter text: qsTr("Try switching to a window") color: Colours.palette.m3outline - font.pointSize: Appearance.font.size.large + font.pointSize: Tokens.font.size.large } } } @@ -68,7 +69,7 @@ Item { anchors.centerIn: parent - captureSource: root.client?.wayland ?? null + captureSource: root.client?.wayland ?? null // qmllint disable unresolved-type 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 @@ -81,7 +82,7 @@ Item { anchors.horizontalCenter: parent.horizontalCenter anchors.bottom: parent.bottom - anchors.bottomMargin: Appearance.padding.large + anchors.bottomMargin: Tokens.padding.large animate: true text: { diff --git a/modules/windowinfo/WindowInfo.qml b/modules/windowinfo/WindowInfo.qml index 919b3fbb1..7ee35c29b 100644 --- a/modules/windowinfo/WindowInfo.qml +++ b/modules/windowinfo/WindowInfo.qml @@ -1,10 +1,10 @@ -import qs.components -import qs.services -import qs.config -import Quickshell -import Quickshell.Hyprland import QtQuick import QtQuick.Layouts +import Quickshell +import Quickshell.Hyprland +import Caelestia.Config +import qs.components +import qs.services Item { id: root @@ -13,15 +13,15 @@ Item { required property HyprlandToplevel client implicitWidth: child.implicitWidth - implicitHeight: screen.height * Config.winfo.sizes.heightMult + implicitHeight: screen.height * Tokens.sizes.winfo.heightMult RowLayout { id: child anchors.fill: parent - anchors.margins: Appearance.padding.large + anchors.margins: Tokens.padding.large - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal Preview { screen: root.screen @@ -29,9 +29,9 @@ Item { } ColumnLayout { - spacing: Appearance.spacing.normal + spacing: Tokens.spacing.normal - Layout.preferredWidth: Config.winfo.sizes.detailsWidth + Layout.preferredWidth: Tokens.sizes.winfo.detailsWidth Layout.fillHeight: true StyledRect { @@ -39,7 +39,7 @@ Item { Layout.fillHeight: true color: Colours.tPalette.m3surfaceContainer - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal Details { client: root.client @@ -51,7 +51,7 @@ Item { Layout.preferredHeight: buttons.implicitHeight color: Colours.tPalette.m3surfaceContainer - radius: Appearance.rounding.normal + radius: Tokens.rounding.normal Buttons { id: buttons diff --git a/nix/app2unit.nix b/nix/app2unit.nix deleted file mode 100644 index 51b4241ce..000000000 --- a/nix/app2unit.nix +++ /dev/null @@ -1,14 +0,0 @@ -{ - pkgs, # To ensure the nixpkgs version of app2unit - fetchFromGitHub, - ... -}: -pkgs.app2unit.overrideAttrs (final: prev: rec { - version = "1.0.3"; # Fix old issue related to missing env var - src = fetchFromGitHub { - owner = "Vladimir-csp"; - repo = "app2unit"; - tag = "v${version}"; - hash = "sha256-7eEVjgs+8k+/NLteSBKgn4gPaPLHC+3Uzlmz6XB0930="; - }; -}) diff --git a/nix/default.nix b/nix/default.nix index 67747b2d9..3a153dd0c 100644 --- a/nix/default.nix +++ b/nix/default.nix @@ -126,8 +126,6 @@ in 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 = '' diff --git a/plugin/src/Caelestia/Blobs/CMakeLists.txt b/plugin/src/Caelestia/Blobs/CMakeLists.txt new file mode 100644 index 000000000..9506f7f44 --- /dev/null +++ b/plugin/src/Caelestia/Blobs/CMakeLists.txt @@ -0,0 +1,20 @@ +qml_module(caelestia-blobs + URI Caelestia.Blobs + SOURCES + blobgroup.cpp + blobshape.cpp + blobrect.cpp + blobinvertedrect.cpp + blobmaterial.cpp + LIBRARIES + Qt::Quick +) + +qt_add_shaders(caelestia-blobs "blob_shaders" + BATCHABLE OPTIMIZED NOHLSL NOMSL + GLSL "300es,330" + PREFIX "/" + FILES + shaders/blob.frag + shaders/blob.vert +) diff --git a/plugin/src/Caelestia/Blobs/blobgroup.cpp b/plugin/src/Caelestia/Blobs/blobgroup.cpp new file mode 100644 index 000000000..a4703c860 --- /dev/null +++ b/plugin/src/Caelestia/Blobs/blobgroup.cpp @@ -0,0 +1,104 @@ +#include "blobgroup.hpp" +#include "blobinvertedrect.hpp" +#include "blobshape.hpp" + +BlobGroup::BlobGroup(QObject* parent) + : QObject(parent) {} + +BlobGroup::~BlobGroup() { + for (auto* shape : std::as_const(m_shapes)) + shape->m_group = nullptr; + if (m_invertedRect) + static_cast(m_invertedRect)->m_group = nullptr; +} + +void BlobGroup::setSmoothing(qreal s) { + if (qFuzzyCompare(m_smoothing, s)) + return; + m_smoothing = s; + emit smoothingChanged(); + markDirty(); +} + +void BlobGroup::setColor(const QColor& c) { + if (m_color == c) + return; + m_color = c; + emit colorChanged(); + markDirty(); +} + +void BlobGroup::addShape(BlobShape* shape) { + if (!shape || m_shapes.contains(shape)) + return; + m_shapes.append(shape); + markDirty(); +} + +void BlobGroup::removeShape(BlobShape* shape) { + m_shapes.removeOne(shape); + markDirty(); +} + +void BlobGroup::setInvertedRect(BlobInvertedRect* rect) { + if (m_invertedRect == rect) + return; + m_invertedRect = rect; + markDirty(); +} + +void BlobGroup::clearInvertedRect(BlobInvertedRect* rect) { + if (m_invertedRect != rect) + return; + m_invertedRect = nullptr; + markDirty(); +} + +void BlobGroup::markDirty() { + m_physicsUpdated = false; + for (auto* shape : std::as_const(m_shapes)) { + shape->polish(); + shape->update(); + } + if (m_invertedRect) { + static_cast(m_invertedRect)->polish(); + static_cast(m_invertedRect)->update(); + } +} + +void BlobGroup::markShapeDirty(BlobShape* source) { + m_physicsUpdated = false; + + source->polish(); + source->update(); + + // Use cached padded rects to find spatial neighbors + const float pad = static_cast(m_smoothing) * 2.0f; + const QRectF srcRect(static_cast(source->m_cachedPaddedX - pad), + static_cast(source->m_cachedPaddedY - pad), static_cast(source->m_cachedPaddedW + pad * 2.0f), + static_cast(source->m_cachedPaddedH + pad * 2.0f)); + + for (auto* shape : std::as_const(m_shapes)) { + if (shape == source) + continue; + const QRectF otherRect(static_cast(shape->m_cachedPaddedX), static_cast(shape->m_cachedPaddedY), + static_cast(shape->m_cachedPaddedW), static_cast(shape->m_cachedPaddedH)); + if (srcRect.intersects(otherRect)) { + shape->polish(); + shape->update(); + } + } + + if (m_invertedRect && static_cast(m_invertedRect) != source) { + static_cast(m_invertedRect)->polish(); + static_cast(m_invertedRect)->update(); + } +} + +void BlobGroup::ensurePhysicsUpdated() { + if (m_physicsUpdated) + return; + m_physicsUpdated = true; + for (auto* shape : std::as_const(m_shapes)) + shape->updatePhysics(); +} diff --git a/plugin/src/Caelestia/Blobs/blobgroup.hpp b/plugin/src/Caelestia/Blobs/blobgroup.hpp new file mode 100644 index 000000000..e09125a96 --- /dev/null +++ b/plugin/src/Caelestia/Blobs/blobgroup.hpp @@ -0,0 +1,53 @@ +#pragma once + +#include +#include +#include +#include + +class BlobShape; +class BlobInvertedRect; + +class BlobGroup : public QObject { + Q_OBJECT + QML_ELEMENT + Q_PROPERTY(qreal smoothing READ smoothing WRITE setSmoothing NOTIFY smoothingChanged) + Q_PROPERTY(QColor color READ color WRITE setColor NOTIFY colorChanged) + +public: + explicit BlobGroup(QObject* parent = nullptr); + ~BlobGroup() override; + + qreal smoothing() const { return m_smoothing; } + + void setSmoothing(qreal s); + + QColor color() const { return m_color; } + + void setColor(const QColor& c); + + void addShape(BlobShape* shape); + void removeShape(BlobShape* shape); + + void setInvertedRect(BlobInvertedRect* rect); + void clearInvertedRect(BlobInvertedRect* rect); + + const QList& shapes() const { return m_shapes; } + + BlobInvertedRect* invertedRect() const { return m_invertedRect; } + + void markDirty(); + void markShapeDirty(BlobShape* source); + void ensurePhysicsUpdated(); + +signals: + void smoothingChanged(); + void colorChanged(); + +private: + qreal m_smoothing = 32.0; + QColor m_color{ 0x44, 0x88, 0xff }; + QList m_shapes; + BlobInvertedRect* m_invertedRect = nullptr; + bool m_physicsUpdated = false; +}; diff --git a/plugin/src/Caelestia/Blobs/blobinvertedrect.cpp b/plugin/src/Caelestia/Blobs/blobinvertedrect.cpp new file mode 100644 index 000000000..46ee73a4a --- /dev/null +++ b/plugin/src/Caelestia/Blobs/blobinvertedrect.cpp @@ -0,0 +1,184 @@ +#include "blobinvertedrect.hpp" +#include "blobgroup.hpp" +#include "blobmaterial.hpp" + +#include +#include + +#include +#include + +BlobInvertedRect::BlobInvertedRect(QQuickItem* parent) + : BlobShape(parent) {} + +static void setFrameIndices(quint16* idx) { + // Top strip: 0-1-4, 1-5-4 + idx[0] = 0; + idx[1] = 1; + idx[2] = 4; + idx[3] = 1; + idx[4] = 5; + idx[5] = 4; + // Right strip: 1-2-5, 2-6-5 + idx[6] = 1; + idx[7] = 2; + idx[8] = 5; + idx[9] = 2; + idx[10] = 6; + idx[11] = 5; + // Bottom strip: 2-3-6, 3-7-6 + idx[12] = 2; + idx[13] = 3; + idx[14] = 6; + idx[15] = 3; + idx[16] = 7; + idx[17] = 6; + // Left strip: 3-0-7, 0-4-7 + idx[18] = 3; + idx[19] = 0; + idx[20] = 7; + idx[21] = 0; + idx[22] = 4; + idx[23] = 7; +} + +QSGNode* BlobInvertedRect::updatePaintNode(QSGNode* oldNode, UpdatePaintNodeData*) { + if (!m_group) { + delete oldNode; + return nullptr; + } + + const float pad = static_cast(m_group->smoothing()); + + // Compute inner hole boundary in local coords + // Inset past the inner border edge by 2x smoothing to cover the blend zone + const float inset = pad * 2.0f; + const float holeLeft = static_cast(m_borderLeft) + inset; + const float holeTop = static_cast(m_borderTop) + inset; + const float holeRight = static_cast(width() - m_borderRight) - inset; + const float holeBot = static_cast(height() - m_borderBottom) - inset; + + // If the hole is too small or invalid, fall back to full quad + if (holeLeft >= holeRight || holeTop >= holeBot) + return BlobShape::updatePaintNode(oldNode, nullptr); + + auto* node = static_cast(oldNode); + + const bool needsRebuild = !node || node->geometry()->vertexCount() != 8; + + if (needsRebuild) { + delete oldNode; + node = new QSGGeometryNode; + + auto* geometry = + new QSGGeometry(QSGGeometry::defaultAttributes_TexturedPoint2D(), 8, 24, QSGGeometry::UnsignedShortType); + geometry->setDrawingMode(QSGGeometry::DrawTriangles); + node->setGeometry(geometry); + node->setFlag(QSGNode::OwnsGeometry); + + setFrameIndices(geometry->indexDataAsUShort()); + + auto* material = new BlobMaterial; + material->setFlag(QSGMaterial::Blending); + node->setMaterial(material); + node->setFlag(QSGNode::OwnsMaterial); + } + + // Outer bounds (local coords) + const float x0 = static_cast(m_localPaddedRect.x()); + const float y0 = static_cast(m_localPaddedRect.y()); + const float x1 = x0 + static_cast(m_localPaddedRect.width()); + const float y1 = y0 + static_cast(m_localPaddedRect.height()); + const float w = x1 - x0; + const float h = y1 - y0; + + // Update vertex positions and texture coordinates + auto* v = node->geometry()->vertexDataAsTexturedPoint2D(); + + // Outer corners + v[0].set(x0, y0, 0.0f, 0.0f); + v[1].set(x1, y0, 1.0f, 0.0f); + v[2].set(x1, y1, 1.0f, 1.0f); + v[3].set(x0, y1, 0.0f, 1.0f); + // Inner corners (hole) + v[4].set(holeLeft, holeTop, (holeLeft - x0) / w, (holeTop - y0) / h); + v[5].set(holeRight, holeTop, (holeRight - x0) / w, (holeTop - y0) / h); + v[6].set(holeRight, holeBot, (holeRight - x0) / w, (holeBot - y0) / h); + v[7].set(holeLeft, holeBot, (holeLeft - x0) / w, (holeBot - y0) / h); + + node->markDirty(QSGNode::DirtyGeometry); + + // Update material uniforms + auto* material = static_cast(node->material()); + material->m_paddedX = m_cachedPaddedX; + material->m_paddedY = m_cachedPaddedY; + material->m_paddedW = m_cachedPaddedW; + material->m_paddedH = m_cachedPaddedH; + material->m_smoothFactor = pad; + material->m_myIndex = m_cachedMyIndex; + material->m_color = m_group->color(); + material->m_hasInverted = m_cachedHasInverted ? 1 : 0; + material->m_invertedRadius = m_cachedInvertedRadius; + memcpy(material->m_invertedOuter, m_cachedInvertedOuter, sizeof(m_cachedInvertedOuter)); + memcpy(material->m_invertedInner, m_cachedInvertedInner, sizeof(m_cachedInvertedInner)); + + const int count = static_cast(qMin(m_cachedRects.size(), qsizetype(16))); + material->m_rectCount = count; + for (int i = 0; i < count; ++i) + material->m_rects[i] = m_cachedRects[i]; + + node->markDirty(QSGNode::DirtyMaterial); + + return node; +} + +BlobInvertedRect::~BlobInvertedRect() { + if (m_group) + m_group->clearInvertedRect(this); +} + +void BlobInvertedRect::setBorderLeft(qreal v) { + if (qFuzzyCompare(m_borderLeft, v)) + return; + m_borderLeft = v; + emit borderLeftChanged(); + if (m_group) + m_group->markDirty(); +} + +void BlobInvertedRect::setBorderRight(qreal v) { + if (qFuzzyCompare(m_borderRight, v)) + return; + m_borderRight = v; + emit borderRightChanged(); + if (m_group) + m_group->markDirty(); +} + +void BlobInvertedRect::setBorderTop(qreal v) { + if (qFuzzyCompare(m_borderTop, v)) + return; + m_borderTop = v; + emit borderTopChanged(); + if (m_group) + m_group->markDirty(); +} + +void BlobInvertedRect::setBorderBottom(qreal v) { + if (qFuzzyCompare(m_borderBottom, v)) + return; + m_borderBottom = v; + emit borderBottomChanged(); + if (m_group) + m_group->markDirty(); +} + +void BlobInvertedRect::registerWithGroup() { + if (m_group) + m_group->setInvertedRect(this); +} + +void BlobInvertedRect::unregisterFromGroup() { + if (m_group) + m_group->clearInvertedRect(this); +} diff --git a/plugin/src/Caelestia/Blobs/blobinvertedrect.hpp b/plugin/src/Caelestia/Blobs/blobinvertedrect.hpp new file mode 100644 index 000000000..f7fa6c0a5 --- /dev/null +++ b/plugin/src/Caelestia/Blobs/blobinvertedrect.hpp @@ -0,0 +1,54 @@ +#pragma once + +#include "blobshape.hpp" + +#include + +class BlobInvertedRect : public BlobShape { + Q_OBJECT + QML_ELEMENT + Q_PROPERTY(qreal borderLeft READ borderLeft WRITE setBorderLeft NOTIFY borderLeftChanged) + Q_PROPERTY(qreal borderRight READ borderRight WRITE setBorderRight NOTIFY borderRightChanged) + Q_PROPERTY(qreal borderTop READ borderTop WRITE setBorderTop NOTIFY borderTopChanged) + Q_PROPERTY(qreal borderBottom READ borderBottom WRITE setBorderBottom NOTIFY borderBottomChanged) + +public: + explicit BlobInvertedRect(QQuickItem* parent = nullptr); + ~BlobInvertedRect() override; + + qreal borderLeft() const { return m_borderLeft; } + + void setBorderLeft(qreal v); + + qreal borderRight() const { return m_borderRight; } + + void setBorderRight(qreal v); + + qreal borderTop() const { return m_borderTop; } + + void setBorderTop(qreal v); + + qreal borderBottom() const { return m_borderBottom; } + + void setBorderBottom(qreal v); + +signals: + void borderLeftChanged(); + void borderRightChanged(); + void borderTopChanged(); + void borderBottomChanged(); + +protected: + bool isInvertedRect() const override { return true; } + + QSGNode* updatePaintNode(QSGNode* oldNode, UpdatePaintNodeData*) override; + + void registerWithGroup() override; + void unregisterFromGroup() override; + +private: + qreal m_borderLeft = 0; + qreal m_borderRight = 0; + qreal m_borderTop = 0; + qreal m_borderBottom = 0; +}; diff --git a/plugin/src/Caelestia/Blobs/blobmaterial.cpp b/plugin/src/Caelestia/Blobs/blobmaterial.cpp new file mode 100644 index 000000000..721a2532a --- /dev/null +++ b/plugin/src/Caelestia/Blobs/blobmaterial.cpp @@ -0,0 +1,102 @@ +#include "blobmaterial.hpp" + +#include + +static_assert(sizeof(decltype(BlobRectData::excludeMask)) == sizeof(float), + "BlobMaterial packs excludeMask into a float slot via memcpy"); + +QSGMaterialType* BlobMaterial::type() const { + static QSGMaterialType s_type; + return &s_type; +} + +QSGMaterialShader* BlobMaterial::createShader(QSGRendererInterface::RenderMode) const { + return new BlobMaterialShader; +} + +int BlobMaterial::compare(const QSGMaterial* other) const { + if (this < other) + return -1; + if (this > other) + return 1; + return 0; +} + +BlobMaterialShader::BlobMaterialShader() { + setShaderFileName(VertexStage, QStringLiteral(":/shaders/blob.vert.qsb")); + setShaderFileName(FragmentStage, QStringLiteral(":/shaders/blob.frag.qsb")); +} + +bool BlobMaterialShader::updateUniformData(RenderState& state, QSGMaterial* newMaterial, QSGMaterial* oldMaterial) { + Q_UNUSED(oldMaterial); + auto* mat = static_cast(newMaterial); + QByteArray* buf = state.uniformData(); + Q_ASSERT(buf->size() >= 1440); + + if (state.isMatrixDirty()) { + const QMatrix4x4 m = state.combinedMatrix(); + memcpy(buf->data(), m.constData(), 64); + } + if (state.isOpacityDirty()) { + const float opacity = state.opacity(); + memcpy(buf->data() + 64, &opacity, 4); + } + + // Padded rect (offset 68) + memcpy(buf->data() + 68, &mat->m_paddedX, 4); + memcpy(buf->data() + 72, &mat->m_paddedY, 4); + memcpy(buf->data() + 76, &mat->m_paddedW, 4); + memcpy(buf->data() + 80, &mat->m_paddedH, 4); + + // Smooth factor (offset 84) + memcpy(buf->data() + 84, &mat->m_smoothFactor, 4); + + // Rect count (offset 88) + memcpy(buf->data() + 88, &mat->m_rectCount, 4); + + // My index (offset 92) + memcpy(buf->data() + 92, &mat->m_myIndex, 4); + + // Color as vec4 (offset 96, 16 bytes) + const float color[4] = { + static_cast(mat->m_color.redF()), + static_cast(mat->m_color.greenF()), + static_cast(mat->m_color.blueF()), + static_cast(mat->m_color.alphaF()), + }; + memcpy(buf->data() + 96, color, 16); + + // Has inverted (offset 112) + memcpy(buf->data() + 112, &mat->m_hasInverted, 4); + + // Inverted radius (offset 116) + memcpy(buf->data() + 116, &mat->m_invertedRadius, 4); + + // Padding at 120-127 (skip) + + // Inverted outer (offset 128, 16 bytes) + memcpy(buf->data() + 128, mat->m_invertedOuter, 16); + + // Inverted inner (offset 144, 16 bytes) + memcpy(buf->data() + 144, mat->m_invertedInner, 16); + + // Rect data (offset 160, each rect = 5 vec4s = 80 bytes) + const int count = qMin(mat->m_rectCount, 16); + for (int i = 0; i < count; ++i) { + const auto& r = mat->m_rects[i]; + const int base = 160 + i * 80; + // Pack excludeMask into props.x via bit-cast (read in shader with floatBitsToInt) + float maskAsFloat; + memcpy(&maskAsFloat, &r.excludeMask, sizeof(float)); + const float d0[4] = { r.cx, r.cy, r.hw, r.hh }; + const float d1[4] = { maskAsFloat, r.offsetX, r.offsetY, r.minEig }; + const float d3[4] = { r.screenHalfX, r.screenHalfY, 0.0f, 0.0f }; + memcpy(buf->data() + base, d0, 16); + memcpy(buf->data() + base + 16, d1, 16); + memcpy(buf->data() + base + 32, r.invDeform, 16); + memcpy(buf->data() + base + 48, d3, 16); + memcpy(buf->data() + base + 64, r.radius, 16); + } + + return true; +} diff --git a/plugin/src/Caelestia/Blobs/blobmaterial.hpp b/plugin/src/Caelestia/Blobs/blobmaterial.hpp new file mode 100644 index 000000000..bf1eda756 --- /dev/null +++ b/plugin/src/Caelestia/Blobs/blobmaterial.hpp @@ -0,0 +1,47 @@ +#pragma once + +#include +#include +#include + +struct BlobRectData { + float cx = 0, cy = 0, hw = 0, hh = 0; + float offsetX = 0, offsetY = 0; + float minEig = 1.0f; + // Inverse of 2x2 deformation matrix, column-major for GLSL + float invDeform[4] = { 1, 0, 0, 1 }; + // Screen-space AABB half-extents of the deformed rect + float screenHalfX = 0, screenHalfY = 0; + // Effective per-corner radii (tr, br, bl, tl), pre-computed on CPU + float radius[4] = { 0, 0, 0, 0 }; + // Bitmask of indices in this rect's m_cachedRects that mutually exclude (or are excluded by) this rect. + // Used by the shader to skip smin between excluded pairs. + int excludeMask = 0; +}; + +class BlobMaterial : public QSGMaterial { +public: + QSGMaterialType* type() const override; + QSGMaterialShader* createShader(QSGRendererInterface::RenderMode) const override; + int compare(const QSGMaterial* other) const override; + + float m_paddedX = 0; + float m_paddedY = 0; + float m_paddedW = 0; + float m_paddedH = 0; + float m_smoothFactor = 32.0f; + int m_rectCount = 0; + int m_myIndex = -2; + QColor m_color{ 0x44, 0x88, 0xff }; + int m_hasInverted = 0; + float m_invertedRadius = 0; + float m_invertedOuter[4] = {}; + float m_invertedInner[4] = {}; + BlobRectData m_rects[16] = {}; +}; + +class BlobMaterialShader : public QSGMaterialShader { +public: + BlobMaterialShader(); + bool updateUniformData(RenderState& state, QSGMaterial* newMaterial, QSGMaterial* oldMaterial) override; +}; diff --git a/plugin/src/Caelestia/Blobs/blobrect.cpp b/plugin/src/Caelestia/Blobs/blobrect.cpp new file mode 100644 index 000000000..15486d83c --- /dev/null +++ b/plugin/src/Caelestia/Blobs/blobrect.cpp @@ -0,0 +1,245 @@ +#include "blobrect.hpp" +#include "blobgroup.hpp" + +#include +#include + +BlobRect::BlobRect(QQuickItem* parent) + : BlobShape(parent) {} + +BlobRect::~BlobRect() { + if (m_group) + m_group->removeShape(this); +} + +void BlobRect::updatePolish() { + BlobShape::updatePolish(); + + if (m_physicsActive) { + // Check if deformation is visually imperceptible + float totalDelta = std::abs(m_dm00 - 1.0f) + std::abs(m_dm01) + std::abs(m_dm11 - 1.0f); + float totalVel = std::abs(m_dmVel00) + std::abs(m_dmVel01) + std::abs(m_dmVel11); + + if (totalDelta < 0.004f && totalVel < 0.05f) { + // Snap to rest, no visible deformation + m_dm00 = 1.0f; + m_dm01 = 0.0f; + m_dm11 = 1.0f; + m_dmVel00 = m_dmVel01 = m_dmVel11 = 0.0f; + m_deformMatrix = QMatrix4x4(); + emit rawDeformMatrixChanged(); + updateCenteredDeformMatrix(); + m_physicsActive = false; + } else { + QMetaObject::invokeMethod( + this, + [this]() { + if (m_physicsActive && m_group) + m_group->markDirty(); + }, + Qt::QueuedConnection); + } + } +} + +void BlobRect::updatePhysics() { + const QPointF scenePos = mapToScene(QPointF(width() / 2.0, height() / 2.0)); + + if (!m_hasPrevPos) { + m_prevScenePos = scenePos; + m_elapsed.start(); + m_hasPrevPos = true; + return; + } + + const float dt = static_cast(m_elapsed.restart()) / 1000.0f; + if (dt > 0.1f || dt < 0.001f) { + m_prevScenePos = scenePos; + // Still check atRest on skipped frames to avoid getting stuck + if (m_physicsActive) + checkAtRest(0.0f); + return; + } + + const float velX = static_cast(scenePos.x() - m_prevScenePos.x()) / dt; + const float velY = static_cast(scenePos.y() - m_prevScenePos.y()) / dt; + m_prevScenePos = scenePos; + + const float speed = std::sqrt(velX * velX + velY * velY); + + if (!m_physicsActive) { + if (speed < 5.0f) + return; + m_physicsActive = true; + } + + // Compute target deformation matrix from velocity + // R(θ) * diag(stretch, compress) * R(θ)^T + const float kStretchFactor = static_cast(m_deformScale); + constexpr float kMaxStretch = 0.35f; + + float target00 = 1.0f; + float target01 = 0.0f; + float target11 = 1.0f; + + if (speed > 5.0f) { + const float targetStretch = 1.0f + std::min(speed * kStretchFactor, kMaxStretch); + const float targetCompress = 1.0f / targetStretch; + + const float cosA = velX / speed; + const float sinA = velY / speed; + const float cos2 = cosA * cosA; + const float sin2 = sinA * sinA; + const float cs = cosA * sinA; + + target00 = targetStretch * cos2 + targetCompress * sin2; + target01 = (targetStretch - targetCompress) * cs; + target11 = targetStretch * sin2 + targetCompress * cos2; + } + + // Underdamped spring on each matrix component + const float kStiffness = static_cast(m_stiffness); + const float kDamping = static_cast(m_damping); + + const float accel00 = -kStiffness * (m_dm00 - target00) - kDamping * m_dmVel00; + m_dmVel00 += accel00 * dt; + m_dm00 += m_dmVel00 * dt; + + const float accel01 = -kStiffness * (m_dm01 - target01) - kDamping * m_dmVel01; + m_dmVel01 += accel01 * dt; + m_dm01 += m_dmVel01 * dt; + + const float accel11 = -kStiffness * (m_dm11 - target11) - kDamping * m_dmVel11; + m_dmVel11 += accel11 * dt; + m_dm11 += m_dmVel11 * dt; + + m_deformMatrix = QMatrix4x4(m_dm00, m_dm01, 0, 0, m_dm01, m_dm11, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + emit rawDeformMatrixChanged(); + updateCenteredDeformMatrix(); + + checkAtRest(speed); +} + +void BlobRect::setTopLeftRadius(qreal r) { + if (!qFuzzyCompare(m_topLeftRadius, r)) { + m_topLeftRadius = r; + emit topLeftRadiusChanged(); + if (m_group) + m_group->markDirty(); + } +} + +void BlobRect::setTopRightRadius(qreal r) { + if (!qFuzzyCompare(m_topRightRadius, r)) { + m_topRightRadius = r; + emit topRightRadiusChanged(); + if (m_group) + m_group->markDirty(); + } +} + +void BlobRect::setBottomLeftRadius(qreal r) { + if (!qFuzzyCompare(m_bottomLeftRadius, r)) { + m_bottomLeftRadius = r; + emit bottomLeftRadiusChanged(); + if (m_group) + m_group->markDirty(); + } +} + +void BlobRect::setBottomRightRadius(qreal r) { + if (!qFuzzyCompare(m_bottomRightRadius, r)) { + m_bottomRightRadius = r; + emit bottomRightRadiusChanged(); + if (m_group) + m_group->markDirty(); + } +} + +void BlobRect::cornerRadii(float out[4]) const { + const auto maxR = static_cast(std::min(width(), height())) * 0.5f; + const auto base = std::min(static_cast(m_radius), maxR); + out[0] = std::min(m_topRightRadius >= 0 ? static_cast(m_topRightRadius) : base, maxR); + out[1] = std::min(m_bottomRightRadius >= 0 ? static_cast(m_bottomRightRadius) : base, maxR); + out[2] = std::min(m_bottomLeftRadius >= 0 ? static_cast(m_bottomLeftRadius) : base, maxR); + out[3] = std::min(m_topLeftRadius >= 0 ? static_cast(m_topLeftRadius) : base, maxR); +} + +bool BlobRect::isExcluded(const BlobShape* other) const { + for (const auto& ptr : m_exclude) { + if (ptr == other) + return true; + } + return false; +} + +QQmlListProperty BlobRect::exclude() { + return QQmlListProperty( + this, nullptr, &excludeAppend, &excludeCount, &excludeAt, &excludeClear, &excludeReplace, &excludeRemoveLast); +} + +void BlobRect::excludeAppend(QQmlListProperty* prop, BlobRect* rect) { + auto* self = static_cast(prop->object); + self->m_exclude.append(rect); + if (self->m_group) + self->m_group->markDirty(); + emit self->excludeChanged(); +} + +qsizetype BlobRect::excludeCount(QQmlListProperty* prop) { + auto* self = static_cast(prop->object); + return self->m_exclude.size(); +} + +BlobRect* BlobRect::excludeAt(QQmlListProperty* prop, qsizetype index) { + auto* self = static_cast(prop->object); + return self->m_exclude.at(index); +} + +void BlobRect::excludeClear(QQmlListProperty* prop) { + auto* self = static_cast(prop->object); + if (self->m_exclude.isEmpty()) + return; + self->m_exclude.clear(); + if (self->m_group) + self->m_group->markDirty(); + emit self->excludeChanged(); +} + +void BlobRect::excludeReplace(QQmlListProperty* prop, qsizetype index, BlobRect* rect) { + auto* self = static_cast(prop->object); + self->m_exclude[index] = rect; + if (self->m_group) + self->m_group->markDirty(); + emit self->excludeChanged(); +} + +void BlobRect::excludeRemoveLast(QQmlListProperty* prop) { + auto* self = static_cast(prop->object); + if (self->m_exclude.isEmpty()) + return; + self->m_exclude.removeLast(); + if (self->m_group) + self->m_group->markDirty(); + emit self->excludeChanged(); +} + +void BlobRect::checkAtRest(float speed) { + constexpr float kEpsilon = 0.002f; + const bool atRest = std::abs(m_dm00 - 1.0f) < kEpsilon && std::abs(m_dm01) < kEpsilon && + std::abs(m_dm11 - 1.0f) < kEpsilon && std::abs(m_dmVel00) < kEpsilon && + std::abs(m_dmVel01) < kEpsilon && std::abs(m_dmVel11) < kEpsilon && speed < 5.0f; + + if (atRest) { + m_dm00 = 1.0f; + m_dm01 = 0.0f; + m_dm11 = 1.0f; + m_dmVel00 = 0.0f; + m_dmVel01 = 0.0f; + m_dmVel11 = 0.0f; + m_deformMatrix = QMatrix4x4(); // identity + emit rawDeformMatrixChanged(); + updateCenteredDeformMatrix(); + m_physicsActive = false; + } +} diff --git a/plugin/src/Caelestia/Blobs/blobrect.hpp b/plugin/src/Caelestia/Blobs/blobrect.hpp new file mode 100644 index 000000000..d2d6ad453 --- /dev/null +++ b/plugin/src/Caelestia/Blobs/blobrect.hpp @@ -0,0 +1,126 @@ +#pragma once + +#include "blobshape.hpp" + +#include +#include +#include +#include + +class BlobRect : public BlobShape { + Q_OBJECT + QML_ELEMENT + Q_PROPERTY(qreal stiffness READ stiffness WRITE setStiffness NOTIFY stiffnessChanged) + Q_PROPERTY(qreal damping READ damping WRITE setDamping NOTIFY dampingChanged) + Q_PROPERTY(qreal deformScale READ deformScale WRITE setDeformScale NOTIFY deformScaleChanged) + Q_PROPERTY(QQmlListProperty exclude READ exclude NOTIFY excludeChanged) + Q_PROPERTY(qreal topLeftRadius READ topLeftRadius WRITE setTopLeftRadius NOTIFY topLeftRadiusChanged) + Q_PROPERTY(qreal topRightRadius READ topRightRadius WRITE setTopRightRadius NOTIFY topRightRadiusChanged) + Q_PROPERTY(qreal bottomLeftRadius READ bottomLeftRadius WRITE setBottomLeftRadius NOTIFY bottomLeftRadiusChanged) + Q_PROPERTY( + qreal bottomRightRadius READ bottomRightRadius WRITE setBottomRightRadius NOTIFY bottomRightRadiusChanged) + +public: + explicit BlobRect(QQuickItem* parent = nullptr); + ~BlobRect() override; + + qreal stiffness() const { return m_stiffness; } + + void setStiffness(qreal s) { + if (!qFuzzyCompare(m_stiffness, s)) { + m_stiffness = s; + emit stiffnessChanged(); + } + } + + qreal damping() const { return m_damping; } + + void setDamping(qreal d) { + if (!qFuzzyCompare(m_damping, d)) { + m_damping = d; + emit dampingChanged(); + } + } + + qreal deformScale() const { return m_deformScale; } + + void setDeformScale(qreal s) { + if (!qFuzzyCompare(m_deformScale, s)) { + m_deformScale = s; + emit deformScaleChanged(); + } + } + + QQmlListProperty exclude(); + + bool isExcluded(const BlobShape* other) const override; + void cornerRadii(float out[4]) const override; + + qreal topLeftRadius() const { return m_topLeftRadius; } + + void setTopLeftRadius(qreal r); + + qreal topRightRadius() const { return m_topRightRadius; } + + void setTopRightRadius(qreal r); + + qreal bottomLeftRadius() const { return m_bottomLeftRadius; } + + void setBottomLeftRadius(qreal r); + + qreal bottomRightRadius() const { return m_bottomRightRadius; } + + void setBottomRightRadius(qreal r); + +signals: + void stiffnessChanged(); + void dampingChanged(); + void deformScaleChanged(); + void excludeChanged(); + void topLeftRadiusChanged(); + void topRightRadiusChanged(); + void bottomLeftRadiusChanged(); + void bottomRightRadiusChanged(); + +protected: + void updatePolish() override; + void updatePhysics() override; + +private: + void checkAtRest(float speed); + + // Physics state + QPointF m_prevScenePos; + QElapsedTimer m_elapsed; + bool m_physicsActive = false; + bool m_hasPrevPos = false; + + // Symmetric 2x2 deformation matrix components (3 independent: m00, m01, + // m11) Rest state is identity: m00=1, m01=0, m11=1 + float m_dm00 = 1.0f; + float m_dm01 = 0.0f; + float m_dm11 = 1.0f; + + // Spring velocities for each component + float m_dmVel00 = 0.0f; + float m_dmVel01 = 0.0f; + float m_dmVel11 = 0.0f; + + qreal m_stiffness = 200.0; + qreal m_damping = 16.0; + qreal m_deformScale = 0.0005; + + qreal m_topLeftRadius = -1; + qreal m_topRightRadius = -1; + qreal m_bottomLeftRadius = -1; + qreal m_bottomRightRadius = -1; + + QList> m_exclude; + + static void excludeAppend(QQmlListProperty* prop, BlobRect* rect); + static qsizetype excludeCount(QQmlListProperty* prop); + static BlobRect* excludeAt(QQmlListProperty* prop, qsizetype index); + static void excludeClear(QQmlListProperty* prop); + static void excludeReplace(QQmlListProperty* prop, qsizetype index, BlobRect* rect); + static void excludeRemoveLast(QQmlListProperty* prop); +}; diff --git a/plugin/src/Caelestia/Blobs/blobshape.cpp b/plugin/src/Caelestia/Blobs/blobshape.cpp new file mode 100644 index 000000000..cfa11c44c --- /dev/null +++ b/plugin/src/Caelestia/Blobs/blobshape.cpp @@ -0,0 +1,392 @@ +#include "blobshape.hpp" +#include "blobgroup.hpp" +#include "blobinvertedrect.hpp" + +#include +#include + +#include +#include + +static float deformPadding(const QMatrix4x4& dm, float hw, float hh) { + // Bounding box of the deformed shape: |M * corners| + const float dm00 = dm(0, 0), dm01 = dm(0, 1); + const float dm10 = dm(1, 0), dm11 = dm(1, 1); + const float boundX = std::abs(dm00) * hw + std::abs(dm01) * hh; + const float boundY = std::abs(dm10) * hw + std::abs(dm11) * hh; + const float extraX = std::max(boundX - hw, 0.0f) + std::abs(dm(0, 3)); + const float extraY = std::max(boundY - hh, 0.0f) + std::abs(dm(1, 3)); + return std::max(extraX, extraY); +} + +static float cpuSdBox(float px, float py, float cx, float cy, float hw, float hh) { + const float dx = std::abs(px - cx) - hw; + const float dy = std::abs(py - cy) - hh; + const float mdx = std::max(dx, 0.0f); + const float mdy = std::max(dy, 0.0f); + return std::sqrt(mdx * mdx + mdy * mdy) + std::min(std::max(dx, dy), 0.0f); +} + +static float cpuSmoothstep(float edge0, float edge1, float x) { + const float t = std::clamp((x - edge0) / (edge1 - edge0), 0.0f, 1.0f); + return t * t * (3.0f - 2.0f * t); +} + +BlobShape::BlobShape(QQuickItem* parent) + : QQuickItem(parent) { + setFlag(ItemHasContents); +} + +void BlobShape::setGroup(BlobGroup* g) { + if (m_group == g) + return; + if (m_group && isComponentComplete()) + unregisterFromGroup(); + m_group = g; + if (m_group && isComponentComplete()) + registerWithGroup(); + emit groupChanged(); + if (m_group) + m_group->markDirty(); +} + +void BlobShape::setRadius(qreal r) { + if (qFuzzyCompare(m_radius, r)) + return; + m_radius = r; + emit radiusChanged(); + if (m_group) + m_group->markDirty(); +} + +void BlobShape::componentComplete() { + QQuickItem::componentComplete(); + if (m_group) + registerWithGroup(); +} + +void BlobShape::geometryChange(const QRectF& newGeometry, const QRectF& oldGeometry) { + QQuickItem::geometryChange(newGeometry, oldGeometry); + updateCenteredDeformMatrix(); + if (m_group) { + // Accumulate sub-pixel drift so slow movements don't desync the shader + m_pendingDx += static_cast(newGeometry.x() - oldGeometry.x()); + m_pendingDy += static_cast(newGeometry.y() - oldGeometry.y()); + const auto dw = std::abs(newGeometry.width() - oldGeometry.width()); + const auto dh = std::abs(newGeometry.height() - oldGeometry.height()); + if (std::abs(m_pendingDx) > 0.5f || std::abs(m_pendingDy) > 0.5f || dw > 0.5 || dh > 0.5) { + m_pendingDx = 0; + m_pendingDy = 0; + m_group->markShapeDirty(this); + } + } +} + +void BlobShape::updateCenteredDeformMatrix() { + const auto cx = static_cast(width()) * 0.5f; + const auto cy = static_cast(height()) * 0.5f; + QMatrix4x4 result; + result.translate(cx, cy); + result *= m_deformMatrix; + result.translate(-cx, -cy); + if (m_centeredDeformMatrix != result) { + m_centeredDeformMatrix = result; + emit deformMatrixChanged(); + } +} + +void BlobShape::cornerRadii(float out[4]) const { + const auto maxR = static_cast(std::min(width(), height())) * 0.5f; + const auto r = std::min(static_cast(m_radius), maxR); + out[0] = r; + out[1] = r; + out[2] = r; + out[3] = r; +} + +void BlobShape::registerWithGroup() { + if (m_group) + m_group->addShape(this); +} + +void BlobShape::unregisterFromGroup() { + if (m_group) + m_group->removeShape(this); +} + +void BlobShape::updatePolish() { + if (!m_group) + return; + + // Ensure all shapes have up-to-date physics (only once per frame) + m_group->ensurePhysicsUpdated(); + + const QPointF scenePos = mapToScene(QPointF(0, 0)); + const float pad = static_cast(m_group->smoothing()); + + if (isInvertedRect()) { + m_cachedPaddedX = static_cast(scenePos.x()); + m_cachedPaddedY = static_cast(scenePos.y()); + m_cachedPaddedW = static_cast(width()); + m_cachedPaddedH = static_cast(height()); + m_localPaddedRect = QRectF(0, 0, width(), height()); + } else { + const float hw = static_cast(width()) * 0.5f; + const float hh = static_cast(height()) * 0.5f; + const float totalPad = pad + deformPadding(m_deformMatrix, hw, hh); + + m_cachedPaddedX = static_cast(scenePos.x()) - totalPad; + m_cachedPaddedY = static_cast(scenePos.y()) - totalPad; + m_cachedPaddedW = static_cast(width()) + 2.0f * totalPad; + m_cachedPaddedH = static_cast(height()) + 2.0f * totalPad; + m_localPaddedRect = QRectF(static_cast(-totalPad), static_cast(-totalPad), + width() + 2.0 * static_cast(totalPad), height() + 2.0 * static_cast(totalPad)); + } + + // Filter nearby normal rects + m_cachedRects.clear(); + m_cachedMyIndex = -2; + const QRectF myPadded(static_cast(m_cachedPaddedX), static_cast(m_cachedPaddedY), + static_cast(m_cachedPaddedW), static_cast(m_cachedPaddedH)); + + // Track shape pointers parallel to m_cachedRects for pairwise exclusion lookups + QVector rectShapes; + rectShapes.reserve(m_group->shapes().size()); + + for (BlobShape* other : m_group->shapes()) { + if (other->isInvertedRect()) + continue; + + // Skip zero-size rects + if (other->width() <= 0 || other->height() <= 0) + continue; + + if (isExcluded(other)) + continue; + + const QPointF otherScene = other->mapToScene(QPointF(0, 0)); + + bool include = false; + if (isInvertedRect()) { + include = true; + } else { + const float otherHW = static_cast(other->width()) * 0.5f; + const float otherHH = static_cast(other->height()) * 0.5f; + const float otherPad = pad + deformPadding(other->m_deformMatrix, otherHW, otherHH); + const QRectF otherPadded(otherScene.x() - static_cast(otherPad), + otherScene.y() - static_cast(otherPad), other->width() + 2.0 * static_cast(otherPad), + other->height() + 2.0 * static_cast(otherPad)); + include = myPadded.intersects(otherPadded); + } + + if (include) { + if (other == this) + m_cachedMyIndex = static_cast(m_cachedRects.size()); + + const QMatrix4x4& dm = other->m_deformMatrix; + const float a = dm(0, 0), b = dm(1, 0); + const float c = dm(0, 1), d = dm(1, 1); + + BlobRectData r; + r.cx = static_cast(otherScene.x() + other->width() / 2.0); + r.cy = static_cast(otherScene.y() + other->height() / 2.0); + r.hw = static_cast(other->width() / 2.0); + r.hh = static_cast(other->height() / 2.0); + other->cornerRadii(r.radius); + r.offsetX = dm(0, 3); + r.offsetY = dm(1, 3); + + // Pre-compute inverse deformation matrix + const float det = a * d - c * b; + const float invDet = std::abs(det) > 1e-6f ? 1.0f / det : 1.0f; + r.invDeform[0] = d * invDet; + r.invDeform[1] = -b * invDet; + r.invDeform[2] = -c * invDet; + r.invDeform[3] = a * invDet; + + // Pre-compute minimum eigenvalue (avoids per-pixel sqrt) + const float halfTr = 0.5f * (a + d); + const float halfDiff = 0.5f * (a - d); + r.minEig = halfTr - std::sqrt(halfDiff * halfDiff + c * c); + + // Pre-compute screen-space AABB half-extents + r.screenHalfX = std::abs(a) * r.hw + std::abs(c) * r.hh; + r.screenHalfY = std::abs(b) * r.hw + std::abs(d) * r.hh; + + m_cachedRects.append(r); + rectShapes.append(other); + } + } + + if (isInvertedRect()) + m_cachedMyIndex = -1; + + // Compute pairwise exclude masks. Bit j in entry i is set iff rect i excludes rect j + // or rect j excludes rect i. The shader uses this to avoid smin between excluded pairs. + const auto cachedCount = m_cachedRects.size(); + for (qsizetype i = 0; i < cachedCount; ++i) { + int mask = 0; + BlobShape* si = rectShapes[i]; + for (qsizetype j = 0; j < cachedCount; ++j) { + if (j == i) + continue; + BlobShape* sj = rectShapes[j]; + if (si->isExcluded(sj) || sj->isExcluded(si)) + mask |= (1 << j); + } + m_cachedRects[i].excludeMask = mask; + } + + // Cache inverted rect data + m_cachedHasInverted = false; + m_cachedInvertedRadius = 0; + memset(m_cachedInvertedOuter, 0, sizeof(m_cachedInvertedOuter)); + memset(m_cachedInvertedInner, 0, sizeof(m_cachedInvertedInner)); + + auto* inv = m_group->invertedRect(); + if (inv) { + const QPointF invScene = inv->mapToScene(QPointF(0, 0)); + const float outerCX = static_cast(invScene.x() + inv->width() / 2.0); + const float outerCY = static_cast(invScene.y() + inv->height() / 2.0); + const float outerHW = static_cast(inv->width() / 2.0); + const float outerHH = static_cast(inv->height() / 2.0); + + const float innerCX = outerCX + static_cast((inv->borderLeft() - inv->borderRight()) / 2.0); + const float innerCY = outerCY + static_cast((inv->borderTop() - inv->borderBottom()) / 2.0); + const float innerHW = outerHW - static_cast((inv->borderLeft() + inv->borderRight()) / 2.0); + const float innerHH = outerHH - static_cast((inv->borderTop() + inv->borderBottom()) / 2.0); + + // Check if this rect is near the border (within 2x smoothing of inner edge) + bool nearBorder = isInvertedRect(); + if (!nearBorder) { + const float margin = pad * 2.0f; + const float myCX = m_cachedPaddedX + m_cachedPaddedW * 0.5f; + const float myCY = m_cachedPaddedY + m_cachedPaddedH * 0.5f; + const float myHW = m_cachedPaddedW * 0.5f; + const float myHH = m_cachedPaddedH * 0.5f; + // Near border if any edge of padded rect is within margin of inner edge + nearBorder = (myCX - myHW < innerCX - innerHW + margin) || (myCX + myHW > innerCX + innerHW - margin) || + (myCY - myHH < innerCY - innerHH + margin) || (myCY + myHH > innerCY + innerHH - margin); + } + + if (nearBorder) { + m_cachedHasInverted = true; + m_cachedInvertedRadius = static_cast(inv->radius()); + + m_cachedInvertedOuter[0] = outerCX; + m_cachedInvertedOuter[1] = outerCY; + m_cachedInvertedOuter[2] = outerHW; + m_cachedInvertedOuter[3] = outerHH; + + m_cachedInvertedInner[0] = innerCX; + m_cachedInvertedInner[1] = innerCY; + m_cachedInvertedInner[2] = innerHW; + m_cachedInvertedInner[3] = innerHH; + } + } + + // Pre-compute effective per-corner radii (moves O(N²) work from GPU to CPU) + const float smoothFactor = pad; + constexpr float minR = 2.0f; + const auto rectCount = m_cachedRects.size(); + for (qsizetype i = 0; i < rectCount; ++i) { + auto& ri = m_cachedRects[i]; + const int riExcludeMask = ri.excludeMask; + float fTr = 1.0f, fBr = 1.0f, fBl = 1.0f, fTl = 1.0f; + + const float cTrX = ri.cx + ri.hw, cTrY = ri.cy - ri.hh; + const float cBrX = ri.cx + ri.hw, cBrY = ri.cy + ri.hh; + const float cBlX = ri.cx - ri.hw, cBlY = ri.cy + ri.hh; + const float cTlX = ri.cx - ri.hw, cTlY = ri.cy - ri.hh; + + for (qsizetype j = 0; j < rectCount; ++j) { + if (j == i) + continue; + if (riExcludeMask & (1 << j)) + continue; + const auto& rj = m_cachedRects[j]; + fTr = std::min(fTr, cpuSmoothstep(0.0f, smoothFactor, cpuSdBox(cTrX, cTrY, rj.cx, rj.cy, rj.hw, rj.hh))); + fBr = std::min(fBr, cpuSmoothstep(0.0f, smoothFactor, cpuSdBox(cBrX, cBrY, rj.cx, rj.cy, rj.hw, rj.hh))); + fBl = std::min(fBl, cpuSmoothstep(0.0f, smoothFactor, cpuSdBox(cBlX, cBlY, rj.cx, rj.cy, rj.hw, rj.hh))); + fTl = std::min(fTl, cpuSmoothstep(0.0f, smoothFactor, cpuSdBox(cTlX, cTlY, rj.cx, rj.cy, rj.hw, rj.hh))); + } + + if (m_cachedHasInverted) { + const float icx = m_cachedInvertedInner[0]; + const float icy = m_cachedInvertedInner[1]; + const float ihw = m_cachedInvertedInner[2]; + const float ihh = m_cachedInvertedInner[3]; + fTr = std::min(fTr, cpuSmoothstep(0.0f, smoothFactor, -cpuSdBox(cTrX, cTrY, icx, icy, ihw, ihh))); + fBr = std::min(fBr, cpuSmoothstep(0.0f, smoothFactor, -cpuSdBox(cBrX, cBrY, icx, icy, ihw, ihh))); + fBl = std::min(fBl, cpuSmoothstep(0.0f, smoothFactor, -cpuSdBox(cBlX, cBlY, icx, icy, ihw, ihh))); + fTl = std::min(fTl, cpuSmoothstep(0.0f, smoothFactor, -cpuSdBox(cTlX, cTlY, icx, icy, ihw, ihh))); + } + + // Combine base radii with fill factors into effective per-corner radii + ri.radius[0] = std::max(ri.radius[0] * fTr, minR); + ri.radius[1] = std::max(ri.radius[1] * fBr, minR); + ri.radius[2] = std::max(ri.radius[2] * fBl, minR); + ri.radius[3] = std::max(ri.radius[3] * fTl, minR); + } +} + +QSGNode* BlobShape::updatePaintNode(QSGNode* oldNode, UpdatePaintNodeData*) { + if (!m_group) { + delete oldNode; + return nullptr; + } + + auto* node = static_cast(oldNode); + if (!node) { + node = new QSGGeometryNode; + + auto* geometry = new QSGGeometry(QSGGeometry::defaultAttributes_TexturedPoint2D(), 4); + geometry->setDrawingMode(QSGGeometry::DrawTriangleStrip); + node->setGeometry(geometry); + node->setFlag(QSGNode::OwnsGeometry); + + auto* material = new BlobMaterial; + material->setFlag(QSGMaterial::Blending); + node->setMaterial(material); + node->setFlag(QSGNode::OwnsMaterial); + } + + // Update geometry + auto* geometry = node->geometry(); + auto* v = geometry->vertexDataAsTexturedPoint2D(); + + const float x0 = static_cast(m_localPaddedRect.x()); + const float y0 = static_cast(m_localPaddedRect.y()); + const float x1 = x0 + static_cast(m_localPaddedRect.width()); + const float y1 = y0 + static_cast(m_localPaddedRect.height()); + + v[0].set(x0, y0, 0.0f, 0.0f); + v[1].set(x1, y0, 1.0f, 0.0f); + v[2].set(x0, y1, 0.0f, 1.0f); + v[3].set(x1, y1, 1.0f, 1.0f); + + node->markDirty(QSGNode::DirtyGeometry); + + // Update material + auto* material = static_cast(node->material()); + material->m_paddedX = m_cachedPaddedX; + material->m_paddedY = m_cachedPaddedY; + material->m_paddedW = m_cachedPaddedW; + material->m_paddedH = m_cachedPaddedH; + material->m_smoothFactor = static_cast(m_group->smoothing()); + material->m_myIndex = m_cachedMyIndex; + material->m_color = m_group->color(); + material->m_hasInverted = m_cachedHasInverted ? 1 : 0; + material->m_invertedRadius = m_cachedInvertedRadius; + memcpy(material->m_invertedOuter, m_cachedInvertedOuter, sizeof(m_cachedInvertedOuter)); + memcpy(material->m_invertedInner, m_cachedInvertedInner, sizeof(m_cachedInvertedInner)); + + const int count = static_cast(qMin(m_cachedRects.size(), qsizetype(16))); + material->m_rectCount = count; + for (int i = 0; i < count; ++i) + material->m_rects[i] = m_cachedRects[i]; + + node->markDirty(QSGNode::DirtyMaterial); + + return node; +} diff --git a/plugin/src/Caelestia/Blobs/blobshape.hpp b/plugin/src/Caelestia/Blobs/blobshape.hpp new file mode 100644 index 000000000..c05a40d86 --- /dev/null +++ b/plugin/src/Caelestia/Blobs/blobshape.hpp @@ -0,0 +1,79 @@ +#pragma once + +#include "blobmaterial.hpp" + +#include +#include +#include + +class BlobGroup; + +class BlobShape : public QQuickItem { + Q_OBJECT + Q_PROPERTY(BlobGroup* group READ group WRITE setGroup NOTIFY groupChanged) + Q_PROPERTY(qreal radius READ radius WRITE setRadius NOTIFY radiusChanged) + Q_PROPERTY(QMatrix4x4 deformMatrix READ deformMatrix NOTIFY deformMatrixChanged) + Q_PROPERTY(QMatrix4x4 rawDeformMatrix READ rawDeformMatrix NOTIFY rawDeformMatrixChanged) + + friend class BlobGroup; + +public: + explicit BlobShape(QQuickItem* parent = nullptr); + ~BlobShape() override = default; + + BlobGroup* group() const { return m_group; } + + void setGroup(BlobGroup* g); + + qreal radius() const { return m_radius; } + + void setRadius(qreal r); + + QMatrix4x4 deformMatrix() const { return m_centeredDeformMatrix; } + + QMatrix4x4 rawDeformMatrix() const { return m_deformMatrix; } + +signals: + void groupChanged(); + void radiusChanged(); + void deformMatrixChanged(); + void rawDeformMatrixChanged(); + +protected: + void componentComplete() override; + void geometryChange(const QRectF& newGeometry, const QRectF& oldGeometry) override; + void updatePolish() override; + QSGNode* updatePaintNode(QSGNode* oldNode, UpdatePaintNodeData*) override; + + virtual bool isInvertedRect() const { return false; } + + virtual bool isExcluded(const BlobShape* /*other*/) const { return false; } + + virtual void cornerRadii(float out[4]) const; + + virtual void updatePhysics() {} + + virtual void registerWithGroup(); + virtual void unregisterFromGroup(); + void updateCenteredDeformMatrix(); + + BlobGroup* m_group = nullptr; + qreal m_radius = 0; + QMatrix4x4 m_deformMatrix; // identity by default + QMatrix4x4 m_centeredDeformMatrix; + + // Cached data from updatePolish + float m_cachedPaddedX = 0; + float m_cachedPaddedY = 0; + float m_cachedPaddedW = 0; + float m_cachedPaddedH = 0; + QRectF m_localPaddedRect; + QVector m_cachedRects; + int m_cachedMyIndex = -2; + float m_pendingDx = 0; + float m_pendingDy = 0; + bool m_cachedHasInverted = false; + float m_cachedInvertedRadius = 0; + float m_cachedInvertedOuter[4] = {}; + float m_cachedInvertedInner[4] = {}; +}; diff --git a/plugin/src/Caelestia/Blobs/shaders/blob.frag b/plugin/src/Caelestia/Blobs/shaders/blob.frag new file mode 100644 index 000000000..c002fd259 --- /dev/null +++ b/plugin/src/Caelestia/Blobs/shaders/blob.frag @@ -0,0 +1,251 @@ +#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 paddedX; + float paddedY; + float paddedW; + float paddedH; + float smoothFactor; + int rectCount; + int myIndex; + vec4 color; + int hasInverted; + float invertedRadius; + vec4 invertedOuter; + vec4 invertedInner; + vec4 rectData[80]; +}; + +float sdRoundedBox(vec2 p, vec2 center, vec2 halfSize, float radius) { + vec2 d = abs(p - center) - halfSize + vec2(radius); + return length(max(d, vec2(0.0))) + min(max(d.x, d.y), 0.0) - radius; +} + +float sdRoundedBox4(vec2 p, vec2 center, vec2 halfSize, vec4 r) { + // r = (topRight, bottomRight, bottomLeft, topLeft) + p -= center; + r.xy = (p.x > 0.0) ? r.xy : r.wz; + r.x = (p.y > 0.0) ? r.y : r.x; + vec2 q = abs(p) - halfSize + r.x; + return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r.x; +} + +float sdBox(vec2 p, vec2 center, vec2 halfSize) { + vec2 d = abs(p - center) - halfSize; + return length(max(d, vec2(0.0))) + min(max(d.x, d.y), 0.0); +} + +float smin(float a, float b, float k) { + // Cubic smooth min (C2 continuous — no curvature kinks at blend boundary) + float h = max(k - abs(a - b), 0.0) / k; + return min(a, b) - h * h * h * k * (1.0/6.0); +} + +float smax(float a, float b, float k) { + float h = max(k - abs(a - b), 0.0) / k; + return max(a, b) + h * h * h * k * (1.0/6.0); +} + +float smaxSharpA(float a, float b, float k) { + // smax variant that keeps a's boundary sharp (no inward rounding at a = 0). + // Used for the frame outer edge so it always fills to the edges. + float h = max(k - abs(a - b), 0.0) / k; + float blend = h * h * h * k * (1.0/6.0); + blend *= smoothstep(0.0, k * 0.5, -a); + return max(a, b) + blend; +} + +void main() { + vec2 pixel = vec2(paddedX, paddedY) + qt_TexCoord0 * vec2(paddedW, paddedH); + + // Phase 1: compute per-rect SDF, track owner. We can't smin yet because excluded + // pairs need to skip the smooth blend, which requires pairwise pass below. + float dArr[16]; + int owner = -2; + float minDist = 1e10; + + for (int i = 0; i < rectCount; i++) { + vec4 rect = rectData[i * 5]; // cx, cy, hw, hh + vec4 props = rectData[i * 5 + 1]; // excludeMask(int bits), offsetX, offsetY, minEig + vec4 invDm = rectData[i * 5 + 2]; // inverse deform matrix + vec4 sh = rectData[i * 5 + 3]; // screenHalfX, screenHalfY, 0, 0 + vec4 radii = rectData[i * 5 + 4]; // effective per-corner radii (tr, br, bl, tl) + + // Offset center for asymmetric deformation + vec2 center = rect.xy + props.yz; + + // AABB early-out: skip rects far from this pixel + vec2 extent = sh.xy + vec2(smoothFactor * 1.5); + if (abs(pixel.x - center.x) > extent.x || abs(pixel.y - center.y) > extent.y) { + dArr[i] = 1e10; + continue; + } + + // Apply pre-computed inverse deformation to the evaluation point + mat2 invDeform = mat2(invDm.xy, invDm.zw); + vec2 transformedPixel = center + invDeform * (pixel - center); + + // Use pre-computed effective per-corner radii + float d = sdRoundedBox4(transformedPixel, center, rect.zw, radii); + + // Use pre-computed minimum eigenvalue for SDF correction + d *= max(props.w, 0.01); + + // Scale SDF on the axis facing a nearby border to narrow the smin blend zone + // in that direction only, without reducing k (which would cause sharp edges). + if (hasInverted != 0) { + vec2 screenHalf = sh.xy; + + float distY0 = (center.y + screenHalf.y) - (invertedInner.y - invertedInner.w); + float distY1 = (invertedInner.y + invertedInner.w) - (center.y - screenHalf.y); + float distX0 = (center.x + screenHalf.x) - (invertedInner.x - invertedInner.z); + float distX1 = (invertedInner.x + invertedInner.z) - (center.x - screenHalf.x); + + // 0 = far from border, 1 = at border (max compression) + float yProx = 1.0 - min( + smoothstep(0.0, smoothFactor, distY0), + smoothstep(0.0, smoothFactor, distY1) + ); + float xProx = 1.0 - min( + smoothstep(0.0, smoothFactor, distX0), + smoothstep(0.0, smoothFactor, distX1) + ); + + // Smooth axis weights: gradient-based at corners, face-based inside. + vec2 q = abs(pixel - center) - screenHalf; + vec2 qp = max(q, vec2(0.0)); + float cornerLen = length(qp); + + // Gradient direction in corner region (smooth 90-degree rotation) + float gradX = qp.x / max(cornerLen, 0.001); + float gradY = qp.y / max(cornerLen, 0.001); + + // Smooth face weights for inside/edge (no hard branch) + float faceY = smoothstep(-4.0, 4.0, q.y - q.x); + float faceX = 1.0 - faceY; + + // Blend: gradient in corner region, face-based inside + float t = smoothstep(0.0, 2.0, cornerLen); + float xWeight = mix(faceX, gradX, t); + float yWeight = mix(faceY, gradY, t); + + float boost = 3.0; + float scale = 1.0 + (xProx * xWeight + yProx * yWeight) * boost; + d *= scale; + } + + dArr[i] = d; + if (d < smoothFactor && d < minDist) { + minDist = d; + owner = i; + } + } + + // Phase 2: hard-min baseline over all rects. + float mergedSdf = 1e10; + for (int i = 0; i < rectCount; i++) { + mergedSdf = min(mergedSdf, dArr[i]); + } + + // Phase 3: pair-wise smin contributions, skipping excluded pairs. Pair smin <= min, + // so taking the min over all non-excluded pair smins gives the smoothly-merged SDF. + for (int i = 0; i < rectCount; i++) { + if (dArr[i] >= 1e9) + continue; + int excludeMask = floatBitsToInt(rectData[i * 5 + 1].x); + for (int j = i + 1; j < rectCount; j++) { + if (dArr[j] >= 1e9) + continue; + if ((excludeMask & (1 << j)) != 0) + continue; + // smin only deviates from min within smoothFactor + if (abs(dArr[i] - dArr[j]) >= smoothFactor) + continue; + mergedSdf = min(mergedSdf, smin(dArr[i], dArr[j], smoothFactor)); + } + } + + if (hasInverted != 0) { + float dOuter = sdBox(pixel, invertedOuter.xy, invertedOuter.zw) - 1.0; + float dInner = sdRoundedBox(pixel, invertedInner.xy, invertedInner.zw, invertedRadius); + + // Border sinks: track the opposite rect edge, clamped to border thickness + float innerTop = invertedInner.y - invertedInner.w; + float innerBot = invertedInner.y + invertedInner.w; + float innerLeft = invertedInner.x - invertedInner.z; + float innerRight = invertedInner.x + invertedInner.z; + float outerTop = invertedOuter.y - invertedOuter.w; + float outerBot = invertedOuter.y + invertedOuter.w; + float outerLeft = invertedOuter.x - invertedOuter.z; + float outerRight = invertedOuter.x + invertedOuter.z; + + float sinkValue = 0.0; + for (int i = 0; i < rectCount; i++) { + vec4 rect = rectData[i * 5]; + vec4 sinkProps = rectData[i * 5 + 1]; + vec2 sinkSh = rectData[i * 5 + 3].xy; + + // Screen-space center (with offset) and pre-computed AABB half-extents + vec2 ctr = rect.xy + sinkProps.yz; + + // Delay sink to absorb smin blend depth (cubic smin max = k/6) + float preOff = smoothFactor * (1.0/6.0); + + // Top border: track rect's BOTTOM edge, only within border thickness + float topPen = clamp(innerTop - (ctr.y + sinkSh.y) - preOff, 0.0, innerTop - outerTop); + + // Bottom border: track rect's TOP edge + float botPen = clamp((ctr.y - sinkSh.y) - innerBot - preOff, 0.0, outerBot - innerBot); + + // Left border: track rect's RIGHT edge + float leftPen = clamp(innerLeft - (ctr.x + sinkSh.x) - preOff, 0.0, innerLeft - outerLeft); + + // Right border: track rect's LEFT edge + float rightPen = clamp((ctr.x - sinkSh.x) - innerRight - preOff, 0.0, outerRight - innerRight); + + // Lateral distance from pixel to rect's extent along each edge + float hLat = max(abs(pixel.x - ctr.x) - sinkSh.x, 0.0); + float vLat = max(abs(pixel.y - ctr.y) - sinkSh.y, 0.0); + + // Perpendicular proximity: full strength in border, fade inside inner area + float topZone = 1.0 - smoothstep(innerTop, innerTop + smoothFactor, pixel.y); + float botZone = smoothstep(innerBot - smoothFactor, innerBot, pixel.y); + float leftZone = 1.0 - smoothstep(innerLeft, innerLeft + smoothFactor, pixel.x); + float rightZone = smoothstep(innerRight - smoothFactor, innerRight, pixel.x); + + float s = smoothFactor * 2.0; + float sink = max( + max(topPen * smoothstep(s, 0.0, hLat) * topZone, + botPen * smoothstep(s, 0.0, hLat) * botZone), + max(leftPen * smoothstep(s, 0.0, vLat) * leftZone, + rightPen * smoothstep(s, 0.0, vLat) * rightZone) + ); + sinkValue = max(sinkValue, sink); + } + + dInner -= sinkValue; + + float dFrame = smaxSharpA(dOuter, -dInner, smoothFactor); + + mergedSdf = smin(mergedSdf, dFrame, smoothFactor); + if (dFrame < minDist) { + owner = -1; + } + } + + // Each renderer only outputs pixels it owns, but allow rendering + // blend zones to prevent gaps (mergedSdf < smoothFactor means in blend) + // myIndex == -1: inverted rect renders border-owned pixels + // myIndex >= 0: individual rect renders its owned pixels + if (owner != myIndex && mergedSdf > smoothFactor) + discard; + + float fw = fwidth(mergedSdf); + float alpha = 1.0 - smoothstep(-fw, fw, mergedSdf); + fragColor = vec4(color.rgb * alpha, alpha) * qt_Opacity; +} diff --git a/plugin/src/Caelestia/Blobs/shaders/blob.vert b/plugin/src/Caelestia/Blobs/shaders/blob.vert new file mode 100644 index 000000000..e71d81042 --- /dev/null +++ b/plugin/src/Caelestia/Blobs/shaders/blob.vert @@ -0,0 +1,29 @@ +#version 440 + +layout(location = 0) in vec4 qt_VertexPosition; +layout(location = 1) in vec2 qt_VertexTexCoord; + +layout(location = 0) out vec2 qt_TexCoord0; + +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + float qt_Opacity; + float paddedX; + float paddedY; + float paddedW; + float paddedH; + float smoothFactor; + int rectCount; + int myIndex; + vec4 color; + int hasInverted; + float invertedRadius; + vec4 invertedOuter; + vec4 invertedInner; + vec4 rectData[80]; +}; + +void main() { + gl_Position = qt_Matrix * qt_VertexPosition; + qt_TexCoord0 = qt_VertexTexCoord; +} diff --git a/plugin/src/Caelestia/CMakeLists.txt b/plugin/src/Caelestia/CMakeLists.txt index e4a020124..6e73f4fe3 100644 --- a/plugin/src/Caelestia/CMakeLists.txt +++ b/plugin/src/Caelestia/CMakeLists.txt @@ -1,20 +1,24 @@ -find_package(Qt6 REQUIRED COMPONENTS Core Qml Gui Quick Concurrent Sql Network DBus) +find_package(Qt6 REQUIRED COMPONENTS ShaderTools Core Qml Gui Quick QuickControls2 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 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") + cmake_parse_arguments(PARSE_ARGV 1 arg "" "URI" "SOURCES;QML_FILES;LIBRARIES") qt_add_qml_module(${arg_TARGET} URI ${arg_URI} VERSION ${VERSION} SOURCES ${arg_SOURCES} + QML_FILES ${arg_QML_FILES} ) qt_query_qml_module(${arg_TARGET} @@ -34,9 +38,24 @@ function(qml_module arg_TARGET) 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}) + target_link_libraries(${arg_TARGET} PRIVATE caelestia-pch Qt::Core Qt::Qml ${arg_LIBRARIES}) endfunction() +add_library(caelestia-pch INTERFACE) +target_precompile_headers(caelestia-pch INTERFACE + + + + + + + + + + + +) + qml_module(caelestia URI Caelestia SOURCES @@ -54,6 +73,10 @@ qml_module(caelestia PkgConfig::Qalculate ) +add_subdirectory(Components) +add_subdirectory(Config) add_subdirectory(Internal) add_subdirectory(Models) add_subdirectory(Services) +add_subdirectory(Blobs) +add_subdirectory(Images) diff --git a/plugin/src/Caelestia/Components/CMakeLists.txt b/plugin/src/Caelestia/Components/CMakeLists.txt new file mode 100644 index 000000000..f880d3183 --- /dev/null +++ b/plugin/src/Caelestia/Components/CMakeLists.txt @@ -0,0 +1,7 @@ +qml_module(caelestia-components + URI Caelestia.Components + SOURCES + lazylistview.hpp lazylistview.cpp + LIBRARIES + Qt::Quick +) diff --git a/plugin/src/Caelestia/Components/lazylistview.cpp b/plugin/src/Caelestia/Components/lazylistview.cpp new file mode 100644 index 000000000..36b49301f --- /dev/null +++ b/plugin/src/Caelestia/Components/lazylistview.cpp @@ -0,0 +1,1108 @@ +#include "lazylistview.hpp" + +#include +#include +#include + +namespace { + +constexpr int ASYNC_BATCH_CREATE = 2; +constexpr int ASYNC_BATCH_DESTROY = 4; + +} // namespace + +namespace caelestia::components { + +// --- LazyListViewAttached --- + +LazyListViewAttached::LazyListViewAttached(QObject* parent) + : QObject(parent) {} + +qreal LazyListViewAttached::preferredHeight() const { + return m_preferredHeight; +} + +void LazyListViewAttached::setPreferredHeight(qreal height) { + if (qFuzzyCompare(m_preferredHeight + 1.0, height + 1.0)) + return; + m_preferredHeight = height; + emit preferredHeightChanged(); +} + +qreal LazyListViewAttached::visibleHeight() const { + return m_visibleHeight; +} + +void LazyListViewAttached::setVisibleHeight(qreal height) { + if (qFuzzyCompare(m_visibleHeight + 1.0, height + 1.0)) + return; + m_visibleHeight = height; + emit visibleHeightChanged(); +} + +bool LazyListViewAttached::ready() const { + return m_ready; +} + +void LazyListViewAttached::setReady(bool ready) { + if (m_ready == ready) + return; + m_ready = ready; + emit readyChanged(); +} + +bool LazyListViewAttached::adding() const { + return m_adding; +} + +void LazyListViewAttached::setAdding(bool adding) { + if (m_adding == adding) + return; + m_adding = adding; + emit addingChanged(); +} + +bool LazyListViewAttached::removing() const { + return m_removing; +} + +void LazyListViewAttached::setRemoving(bool removing) { + if (m_removing == removing) + return; + m_removing = removing; + emit removingChanged(); +} + +bool LazyListViewAttached::trackViewport() const { + return m_trackViewport; +} + +void LazyListViewAttached::setTrackViewport(bool track) { + if (m_trackViewport == track) + return; + m_trackViewport = track; + emit trackViewportChanged(); +} + +// --- LazyListView --- + +LazyListView::LazyListView(QQuickItem* parent) + : QQuickItem(parent) { + setFlag(ItemHasContents, false); +} + +LazyListViewAttached* LazyListView::qmlAttachedProperties(QObject* object) { + return new LazyListViewAttached(object); +} + +LazyListView::~LazyListView() { + for (auto& entry : m_delegates) + destroyDelegate(entry); + for (auto& entry : m_dyingDelegates) + destroyDelegate(entry); +} + +// --- Model & Delegate --- + +QAbstractItemModel* LazyListView::model() const { + return m_model; +} + +void LazyListView::setModel(QAbstractItemModel* model) { + if (m_model == model) + return; + + if (m_model) + disconnectModel(); + + m_model = model; + + if (m_model) + connectModel(); + + resetContent(); + emit modelChanged(); +} + +QQmlComponent* LazyListView::delegate() const { + return m_delegate; +} + +void LazyListView::setDelegate(QQmlComponent* delegate) { + if (m_delegate == delegate) + return; + + m_delegate = delegate; + resetContent(); + emit delegateChanged(); +} + +// --- Layout --- + +qreal LazyListView::spacing() const { + return m_spacing; +} + +void LazyListView::setSpacing(qreal spacing) { + if (qFuzzyCompare(m_spacing, spacing)) + return; + m_spacing = spacing; + emit spacingChanged(); + polish(); +} + +qreal LazyListView::contentHeight() const { + return m_contentHeight; +} + +qreal LazyListView::layoutHeight() const { + return m_layoutHeight; +} + +qreal LazyListView::contentY() const { + return m_contentY; +} + +void LazyListView::setContentY(qreal contentY) { + if (qFuzzyCompare(m_contentY, contentY)) + return; + m_contentY = contentY; + emit contentYChanged(); + polish(); +} + +// --- Viewport --- + +QRectF LazyListView::viewport() const { + return m_viewport; +} + +void LazyListView::setViewport(const QRectF& viewport) { + if (m_viewport == viewport) + return; + m_viewport = viewport; + emit viewportChanged(); + if (m_useCustomViewport) + polish(); +} + +bool LazyListView::useCustomViewport() const { + return m_useCustomViewport; +} + +void LazyListView::setUseCustomViewport(bool use) { + if (m_useCustomViewport == use) + return; + m_useCustomViewport = use; + emit useCustomViewportChanged(); + polish(); +} + +qreal LazyListView::cacheBuffer() const { + return m_cacheBuffer; +} + +void LazyListView::setCacheBuffer(qreal buffer) { + if (qFuzzyCompare(m_cacheBuffer, buffer)) + return; + m_cacheBuffer = buffer; + emit cacheBufferChanged(); + polish(); +} + +// --- Sizing --- + +qreal LazyListView::estimatedHeight() const { + return m_estimatedHeight; +} + +void LazyListView::setEstimatedHeight(qreal height) { + if (qFuzzyCompare(m_estimatedHeight, height)) + return; + m_estimatedHeight = height; + emit estimatedHeightChanged(); + polish(); +} + +bool LazyListView::asynchronous() const { + return m_asynchronous; +} + +void LazyListView::setAsynchronous(bool async) { + if (m_asynchronous == async) + return; + m_asynchronous = async; + emit asynchronousChanged(); +} + +qreal LazyListView::effectiveEstimatedHeight() const { + if (m_estimatedHeight >= 0) + return m_estimatedHeight; + if (m_knownHeightCount > 0) + return m_knownHeightSum / m_knownHeightCount; + return 40; +} + +void LazyListView::trackHeight(qreal height) { + m_knownHeightSum += height; + ++m_knownHeightCount; +} + +void LazyListView::untrackHeight(qreal height) { + m_knownHeightSum -= height; + --m_knownHeightCount; +} + +qreal LazyListView::delegateHeight(QQuickItem* item) { + if (!item) + return 0; + + auto* attached = qobject_cast(qmlAttachedPropertiesObject(item, false)); + if (attached && attached->preferredHeight() >= 0) + return attached->preferredHeight(); + + return item->implicitHeight(); +} + +qreal LazyListView::delegateVisibleHeight(QQuickItem* item) { + if (!item) + return 0; + + auto* attached = qobject_cast(qmlAttachedPropertiesObject(item, false)); + if (attached) { + if (attached->visibleHeight() >= 0) + return attached->visibleHeight(); + if (attached->preferredHeight() >= 0) + return attached->preferredHeight(); + } + + return item->implicitHeight(); +} + +bool LazyListView::isDelegateReady(QQuickItem* item) { + if (!item) + return false; + auto* att = qobject_cast(qmlAttachedPropertiesObject(item, false)); + return !att || att->ready(); +} + +// --- Animation Durations --- + +int LazyListView::removeDuration() const { + return m_removeDuration; +} + +void LazyListView::setRemoveDuration(int duration) { + if (m_removeDuration == duration) + return; + m_removeDuration = duration; + emit removeDurationChanged(); +} + +int LazyListView::readyDelay() const { + return m_readyDelay; +} + +void LazyListView::setReadyDelay(int delay) { + if (m_readyDelay == delay) + return; + m_readyDelay = delay; + emit readyDelayChanged(); +} + +// --- State --- + +int LazyListView::count() const { + return m_model ? m_model->rowCount() : 0; +} + +// --- QQuickItem Overrides --- + +void LazyListView::componentComplete() { + QQuickItem::componentComplete(); + m_componentComplete = true; + resetContent(); +} + +void LazyListView::geometryChange(const QRectF& newGeometry, const QRectF& oldGeometry) { + QQuickItem::geometryChange(newGeometry, oldGeometry); + + if (!m_componentComplete) + return; + + if (!qFuzzyCompare(newGeometry.width(), oldGeometry.width())) { + for (auto& entry : m_delegates) { + if (entry.item) + entry.item->setWidth(newGeometry.width()); + } + } + + polish(); +} + +void LazyListView::updatePolish() { + if (!m_componentComplete || !m_model || !m_delegate) + return; + + // Flush pending inserts — make items visible and clear the adding flag + // so enter animations begin. When readyDelay > 0 the entire insert is + // deferred so delegates have time to lay out before appearing. + for (auto& entry : m_delegates) { + if (!entry.pendingInsert || !entry.item) + continue; + + if (m_readyDelay > 0) { + if (!entry.readyDelayStarted) { + entry.readyDelayStarted = true; + auto* item = entry.item; + QTimer::singleShot(m_readyDelay, this, [this, item] { + auto indexIt = m_itemToIndex.find(item); + if (indexIt == m_itemToIndex.end()) + return; + const int idx = indexIt.value(); + auto it = m_delegates.find(idx); + if (it == m_delegates.end() || it->item != item || !it->pendingInsert) + return; + + it->pendingInsert = false; + it->readyDelayStarted = false; + + // Set initial y to visual position (based on current visible heights) + if (idx >= 0 && idx < static_cast(m_layout.size())) { + qreal visualY = 0; + bool hasVisItem = false; + for (int i = 0; i < static_cast(m_layout.size()); ++i) { + qreal h; + auto dit = m_delegates.find(i); + if (dit != m_delegates.end() && dit->item) + h = delegateVisibleHeight(dit->item); + else + h = m_layout[i].heightKnown ? m_layout[i].height : effectiveEstimatedHeight(); + if (h > 0) { + if (hasVisItem) + visualY += m_spacing; + hasVisItem = true; + } + if (i == idx) + break; + if (h > 0) + visualY += h; + } + item->setY(visualY - m_contentY); + } + + item->setVisible(true); + auto* att = + qobject_cast(qmlAttachedPropertiesObject(item, false)); + if (att) { + att->setAdding(false); + att->setReady(true); + } + + // Animate from visual position to layout position + if (idx >= 0 && idx < static_cast(m_layout.size())) + item->setProperty("y", m_layout[idx].targetY - m_contentY); + + polish(); + }); + } + continue; + } + + entry.pendingInsert = false; + entry.item->setVisible(true); + auto* att = qobject_cast(qmlAttachedPropertiesObject(entry.item, false)); + if (att) { + att->setAdding(false); + att->setReady(true); + } + } + + relayout(); + syncDelegates(); + + // Clear isNew flags — the add animation only plays for items created + // during the same polish cycle as their model insertion, not for + // delegates created later when scrolling items into the viewport. + for (auto& record : m_layout) + record.isNew = false; + + // Position delegates — QML Behavior on y handles the animation + for (auto& entry : m_delegates) { + if (!entry.item || entry.pendingRemoval || entry.pendingInsert) + continue; + + const int idx = entry.modelIndex; + if (idx < 0 || idx >= static_cast(m_layout.size())) + continue; + + if (m_layout[idx].heightKnown && qFuzzyIsNull(m_layout[idx].height)) + continue; + + // Use setProperty to go through the QML property system, + // which triggers Behaviors (setY bypasses them). + entry.item->setProperty("y", m_layout[idx].targetY - m_contentY); + } +} + +// --- Layout Engine --- + +void LazyListView::relayout() { + // Layout positioning uses preferredHeight (final/non-animated). + // Only add spacing between items with non-zero height. + qreal y = 0; + bool hasLayoutItem = false; + for (auto& record : m_layout) { + const qreal layoutH = record.heightKnown ? record.height : effectiveEstimatedHeight(); + if (layoutH > 0) { + if (hasLayoutItem) + y += m_spacing; + hasLayoutItem = true; + record.targetY = y; + y += layoutH; + } else { + record.targetY = y; + } + } + + if (!qFuzzyCompare(m_layoutHeight + 1.0, y + 1.0)) { + m_layoutHeight = y; + emit layoutHeightChanged(); + } + + // Content height tracks actual visible heights so scrolling follows animations. + // Only add spacing between items with non-zero visible height. + qreal visY = 0; + bool hasVisItem = false; + for (int i = 0; i < static_cast(m_layout.size()); ++i) { + qreal h; + auto dit = m_delegates.find(i); + if (dit != m_delegates.end() && dit->item) + h = delegateVisibleHeight(dit->item); + else + h = m_layout[i].heightKnown ? m_layout[i].height : effectiveEstimatedHeight(); + if (h > 0) { + if (hasVisItem) + visY += m_spacing; + hasVisItem = true; + visY += h; + } + } + + // Account for dying delegates still visually present + for (const auto& dying : std::as_const(m_dyingDelegates)) { + if (!dying.item) + continue; + const qreal dyingH = delegateVisibleHeight(dying.item); + if (dyingH > 0) + visY = std::max(visY, dying.item->y() + dyingH); + } + + if (!qFuzzyCompare(m_contentHeight + 1.0, visY + 1.0)) { + m_contentHeight = visY; + emit contentHeightChanged(); + } +} + +QRectF LazyListView::effectiveViewport() const { + QRectF vp; + if (m_useCustomViewport) + vp = m_viewport; + else + vp = QRectF(0, m_contentY, width(), height()); + + // During Flickable overshoot the viewport can extend entirely beyond content bounds, + // causing all delegates to be culled. Clamp so it always overlaps [0, layoutHeight]. + // Only needed for the built-in viewport — custom viewports represent the actual + // visible area and may legitimately lie entirely outside the content. + if (!m_useCustomViewport && m_layoutHeight > 0) { + const qreal top = std::min(vp.y(), m_layoutHeight); + const qreal bottom = std::max(vp.y() + vp.height(), 0.0); + if (bottom > top) + vp = QRectF(vp.x(), top, vp.width(), bottom - top); + } + + vp.adjust(0, -m_cacheBuffer, 0, m_cacheBuffer); + + // Trim the cache-buffered viewport to [0, layoutHeight]. No items exist outside + // those bounds, so extending past them wastes budget and can cause edge thrashing + // when a large cache buffer reaches the opposite end of the content. + if (m_layoutHeight > 0) { + const qreal top = std::max(vp.y(), 0.0); + const qreal bottom = std::min(vp.y() + vp.height(), m_layoutHeight); + if (top < bottom) + vp = QRectF(vp.x(), top, vp.width(), bottom - top); + else + return {}; + } + + return vp; +} + +std::pair LazyListView::computeVisibleRange() const { + if (m_layout.isEmpty()) + return { -1, -1 }; + + const auto vp = effectiveViewport(); + if (vp.isEmpty()) + return { -1, -1 }; + + const qreal vpTop = vp.y(); + const qreal vpBottom = vp.y() + vp.height(); + + // Binary search for first visible item + int lo = 0; + int hi = static_cast(m_layout.size()) - 1; + int first = static_cast(m_layout.size()); + + while (lo <= hi) { + const int mid = lo + (hi - lo) / 2; + const auto& record = m_layout[mid]; + const qreal itemBottom = record.targetY + (record.heightKnown ? record.height : effectiveEstimatedHeight()); + + if (itemBottom >= vpTop) { + first = mid; + hi = mid - 1; + } else { + lo = mid + 1; + } + } + + if (first >= static_cast(m_layout.size())) + return { -1, -1 }; + + // Linear scan for last visible item + int last = first; + for (int i = first; i < static_cast(m_layout.size()); ++i) { + if (m_layout[i].targetY > vpBottom) + break; + last = i; + } + + return { first, last }; +} + +// --- Delegate Lifecycle --- + +void LazyListView::syncDelegates() { + const auto [first, last] = computeVisibleRange(); + + // Collect indices that should be alive + QSet visibleIndices; + if (first >= 0) { + for (int i = first; i <= last; ++i) + visibleIndices.insert(i); + } + + // Collect delegates to destroy — only if visually outside the viewport + const auto vp = effectiveViewport(); + QList toRemove; + for (auto it = m_delegates.begin(); it != m_delegates.end(); ++it) { + if (visibleIndices.contains(it.key())) + continue; + if (!it->item || vp.isEmpty()) { + toRemove.append(it.key()); + continue; + } + const qreal itemTop = it->item->y(); + const qreal itemBottom = itemTop + delegateVisibleHeight(it->item); + if (itemBottom < vp.top() || itemTop > vp.bottom()) + toRemove.append(it.key()); + } + + // Batch destroy + const int destroyBudget = m_asynchronous ? ASYNC_BATCH_DESTROY : static_cast(toRemove.size()); + QVector removedEntries; + removedEntries.reserve(std::min(destroyBudget, static_cast(toRemove.size()))); + int destroyed = 0; + for (int idx : toRemove) { + if (destroyed >= destroyBudget) + break; + auto entry = m_delegates.take(idx); + if (entry.item) + m_itemToIndex.remove(entry.item); + removedEntries.append(std::move(entry)); + ++destroyed; + } + for (auto& entry : removedEntries) + destroyDelegate(entry); + + // Collect indices to create + QList toCreate; + if (first >= 0) { + for (int i = first; i <= last; ++i) { + if (!m_delegates.contains(i)) + toCreate.append(i); + } + } + + // Batch create + const int createBudget = m_asynchronous ? ASYNC_BATCH_CREATE : static_cast(toCreate.size()); + int created = 0; + for (int i : toCreate) { + if (created >= createBudget) + break; + + auto entry = createDelegate(i); + if (entry.item) { + // Height tracking and viewport compensation are deferred + // until the delegate signals ready via readyChanged. + entry.pendingInsert = true; + entry.item->setY(m_layout[i].targetY - m_contentY); + m_itemToIndex.insert(entry.item, i); + m_delegates.insert(i, std::move(entry)); + ++created; + } + } + + // Pending inserts need to become visible on the next frame, and + // async mode may have remaining create/destroy work. + if (created > 0 || (m_asynchronous && (destroyed < static_cast(toRemove.size()) || + created < static_cast(toCreate.size())))) + polish(); +} + +LazyListView::DelegateEntry LazyListView::createDelegate(int modelIndex) { + DelegateEntry entry; + entry.modelIndex = modelIndex; + + if (!m_delegate || !m_model) + return entry; + + const auto roleNames = m_model->roleNames(); + + // Use the delegate component's creation context for beginCreate + // so bound components (pragma ComponentBehavior: Bound) are accepted. + auto* compContext = m_delegate->creationContext(); + if (!compContext) + compContext = qmlContext(this); + if (!compContext) + return entry; + + auto* obj = m_delegate->beginCreate(compContext); + entry.item = qobject_cast(obj); + + if (!entry.item) { + if (obj) + m_delegate->completeCreate(); + delete obj; + return entry; + } + + // Build initial properties from model data + const auto index = m_model->index(modelIndex, 0); + QVariantMap initialProps; + bool hasModelData = false; + + for (auto it = roleNames.constBegin(); it != roleNames.constEnd(); ++it) { + const auto name = QString::fromUtf8(it.value()); + initialProps.insert(name, m_model->data(index, it.key())); + if (name == QStringLiteral("modelData")) + hasModelData = true; + } + initialProps.insert(QStringLiteral("index"), modelIndex); + + if (!hasModelData) { + const auto role = roleNames.isEmpty() ? Qt::DisplayRole : roleNames.constBegin().key(); + initialProps.insert(QStringLiteral("modelData"), m_model->data(index, role)); + } + + m_delegate->setInitialProperties(entry.item, initialProps); + + entry.item->setParentItem(this); + entry.item->setWidth(width()); + + // Only set adding = true for genuinely new model items (not viewport entries). + // Cleared on the next frame in updatePolish when the item becomes visible. + if (modelIndex < static_cast(m_layout.size()) && m_layout[modelIndex].isNew) { + auto* addingAttached = + qobject_cast(qmlAttachedPropertiesObject(entry.item, true)); + if (addingAttached) + addingAttached->setAdding(true); + } + + m_delegate->completeCreate(); + + // Keep adding=true and hide — flushed on the next frame in updatePolish + entry.item->setVisible(false); + + // Height-change handler — uses m_itemToIndex for O(1) lookup. + // Ignored while the delegate is not yet ready. + auto onHeightChanged = [this, item = entry.item] { + if (!isDelegateReady(item)) + return; + auto indexIt = m_itemToIndex.find(item); + if (indexIt == m_itemToIndex.end()) + return; + const int idx = indexIt.value(); + auto delegateIt = m_delegates.find(idx); + if (delegateIt == m_delegates.end() || delegateIt->item != item) + return; + const qreal h = delegateHeight(item); + if (idx < static_cast(m_layout.size()) && !qFuzzyCompare(m_layout[idx].height + 1.0, h + 1.0)) { + const qreal oldH = m_layout[idx].height; + const bool wasKnown = m_layout[idx].heightKnown; + m_layout[idx].height = h; + m_layout[idx].heightKnown = true; + if (wasKnown) + untrackHeight(oldH); + trackHeight(h); + + // If this tracked item is above the viewport, emit a + // compensation delta so the consumer can adjust scroll. + if (wasKnown) { + auto* att = qobject_cast(qmlAttachedPropertiesObject(item, false)); + if (att && att->trackViewport()) { + const qreal vpTop = m_useCustomViewport ? m_viewport.y() : m_contentY; + if (m_layout[idx].targetY < vpTop) + emit viewportAdjustNeeded(h - oldH); + } + } + + if (!m_relayoutPending) { + m_relayoutPending = true; + QTimer::singleShot(0, this, [this] { + m_relayoutPending = false; + relayout(); + polish(); + }); + } + } + }; + + // Watch implicitHeight as fallback + connect(entry.item, &QQuickItem::implicitHeightChanged, this, onHeightChanged); + + // Watch attached properties if the delegate uses them + auto* attached = qobject_cast(qmlAttachedPropertiesObject(entry.item, false)); + if (attached) { + connect(attached, &LazyListViewAttached::preferredHeightChanged, this, onHeightChanged); + connect(attached, &LazyListViewAttached::visibleHeightChanged, this, [this] { + polish(); + }); + connect(attached, &LazyListViewAttached::readyChanged, this, [this, item = entry.item] { + auto indexIt = m_itemToIndex.find(item); + if (indexIt == m_itemToIndex.end()) + return; + const int idx = indexIt.value(); + if (idx >= static_cast(m_layout.size())) + return; + auto* att = qobject_cast(qmlAttachedPropertiesObject(item, false)); + if (!att || !att->ready()) + return; + + const qreal h = delegateHeight(item); + const qreal oldLayoutH = m_layout[idx].heightKnown ? m_layout[idx].height : effectiveEstimatedHeight(); + if (m_layout[idx].heightKnown) + untrackHeight(m_layout[idx].height); + m_layout[idx].height = h; + m_layout[idx].heightKnown = true; + trackHeight(h); + + if (att->trackViewport() && !qFuzzyCompare(h + 1.0, oldLayoutH + 1.0)) { + const qreal vpTop = m_useCustomViewport ? m_viewport.y() : m_contentY; + if (m_layout[idx].targetY < vpTop) + emit viewportAdjustNeeded(h - oldLayoutH); + } + + polish(); + }); + } + + return entry; +} + +void LazyListView::destroyDelegate(DelegateEntry& entry) { + if (entry.item) { + entry.item->setParentItem(nullptr); + entry.item->setVisible(false); + entry.item->deleteLater(); + entry.item = nullptr; + } +} + +void LazyListView::updateDelegateData(DelegateEntry& entry) { + if (!m_model || !entry.item) + return; + + const auto roleNames = m_model->roleNames(); + const auto index = m_model->index(entry.modelIndex, 0); + bool hasModelData = false; + + for (auto it = roleNames.constBegin(); it != roleNames.constEnd(); ++it) { + const auto name = QString::fromUtf8(it.value()); + entry.item->setProperty(name.toUtf8().constData(), m_model->data(index, it.key())); + if (name == QStringLiteral("modelData")) + hasModelData = true; + } + + entry.item->setProperty("index", entry.modelIndex); + + if (!hasModelData) { + const auto role = roleNames.isEmpty() ? Qt::DisplayRole : roleNames.constBegin().key(); + entry.item->setProperty("modelData", m_model->data(index, role)); + } +} + +// --- Model Connection --- + +void LazyListView::connectModel() { + if (!m_model) + return; + + m_modelConnections = { + connect(m_model, &QAbstractItemModel::rowsInserted, this, &LazyListView::onRowsInserted), + connect(m_model, &QAbstractItemModel::rowsAboutToBeRemoved, this, &LazyListView::onRowsAboutToBeRemoved), + connect(m_model, &QAbstractItemModel::rowsRemoved, this, &LazyListView::onRowsRemoved), + connect(m_model, &QAbstractItemModel::rowsMoved, this, &LazyListView::onRowsMoved), + connect(m_model, &QAbstractItemModel::dataChanged, this, &LazyListView::onDataChanged), + connect(m_model, &QAbstractItemModel::modelReset, this, &LazyListView::onModelReset), + connect(m_model, &QAbstractItemModel::layoutChanged, this, + [this] { + for (auto& entry : m_delegates) + updateDelegateData(entry); + polish(); + }), + connect(m_model, &QObject::destroyed, this, + [this] { + m_model = nullptr; + resetContent(); + emit modelChanged(); + }), + }; +} + +void LazyListView::disconnectModel() { + for (auto& conn : m_modelConnections) + disconnect(conn); + m_modelConnections.clear(); +} + +void LazyListView::resetContent() { + // Stop all animations and destroy all delegates + for (auto& entry : m_delegates) + destroyDelegate(entry); + m_delegates.clear(); + m_itemToIndex.clear(); + + for (auto& entry : m_dyingDelegates) + destroyDelegate(entry); + m_dyingDelegates.clear(); + + // Reset pending state + m_knownHeightSum = 0; + m_knownHeightCount = 0; + + // Rebuild layout from model + m_layout.clear(); + if (m_model && m_componentComplete) { + const int rows = m_model->rowCount(); + m_layout.resize(rows); + for (int i = 0; i < rows; ++i) { + m_layout[i].height = 0; + m_layout[i].heightKnown = false; + } + emit countChanged(); + } + + polish(); +} + +void LazyListView::onRowsInserted(const QModelIndex& parent, int first, int last) { + if (parent.isValid()) + return; + + const int insertCount = last - first + 1; + // Insert new layout records + m_layout.insert(first, insertCount, ItemRecord{ 0, 0, false, true }); + + // Shift existing delegate indices + QHash shifted; + for (auto it = m_delegates.begin(); it != m_delegates.end(); ++it) { + int newIdx = it.key() >= first ? it.key() + insertCount : it.key(); + auto entry = std::move(it.value()); + entry.modelIndex = newIdx; + if (entry.item) { + entry.item->setProperty("index", newIdx); + m_itemToIndex[entry.item] = newIdx; + } + shifted.insert(newIdx, std::move(entry)); + } + m_delegates = std::move(shifted); + + emit countChanged(); + polish(); +} + +void LazyListView::onRowsAboutToBeRemoved(const QModelIndex& parent, int first, int last) { + if (parent.isValid()) + return; + + for (int i = first; i <= last; ++i) { + if (!m_delegates.contains(i)) + continue; + + auto entry = m_delegates.take(i); + if (entry.item) + m_itemToIndex.remove(entry.item); + entry.pendingRemoval = true; + + // Never made visible — skip remove animation + if (entry.pendingInsert) { + destroyDelegate(entry); + continue; + } + + if (m_removeDuration > 0 && entry.item) { + auto* attached = + qobject_cast(qmlAttachedPropertiesObject(entry.item, false)); + if (attached) + attached->setRemoving(true); + + // Schedule destruction after the remove animation duration + auto* item = entry.item; + QTimer::singleShot(m_removeDuration, this, [this, item] { + for (auto it = m_dyingDelegates.begin(); it != m_dyingDelegates.end(); ++it) { + if (it->item == item) { + destroyDelegate(*it); + m_dyingDelegates.erase(it); + return; + } + } + }); + m_dyingDelegates.append(std::move(entry)); + } else { + destroyDelegate(entry); + } + } +} + +void LazyListView::onRowsRemoved(const QModelIndex& parent, int first, int last) { + if (parent.isValid()) + return; + + const int removeCount = last - first + 1; + + // Untrack known heights being removed + for (int i = first; i <= last; ++i) { + if (m_layout[i].heightKnown) + untrackHeight(m_layout[i].height); + } + + // Remove layout records + m_layout.remove(first, removeCount); + + // Shift remaining delegate indices down + QHash shifted; + for (auto it = m_delegates.begin(); it != m_delegates.end(); ++it) { + int newIdx = it.key() > last ? it.key() - removeCount : it.key(); + auto entry = std::move(it.value()); + entry.modelIndex = newIdx; + if (entry.item) { + entry.item->setProperty("index", newIdx); + m_itemToIndex[entry.item] = newIdx; + } + shifted.insert(newIdx, std::move(entry)); + } + m_delegates = std::move(shifted); + + emit countChanged(); + polish(); +} + +void LazyListView::onRowsMoved(const QModelIndex& parent, int start, int end, const QModelIndex& destination, int row) { + if (parent.isValid() || destination.isValid()) + return; + + const int count = end - start + 1; + const int dest = row > start ? row - count : row; + + // Reorder layout records + QVector moved; + moved.reserve(count); + for (int i = start; i <= end; ++i) + moved.append(m_layout[i]); + m_layout.remove(start, count); + for (int i = 0; i < count; ++i) + m_layout.insert(dest + i, moved[i]); + + // Remap delegate indices to match new model order + QHash remapped; + for (auto it = m_delegates.begin(); it != m_delegates.end(); ++it) { + int oldIdx = it.key(); + int newIdx = oldIdx; + + if (oldIdx >= start && oldIdx <= end) { + newIdx = dest + (oldIdx - start); + } else { + if (oldIdx > end) + newIdx -= count; + if (newIdx >= dest) + newIdx += count; + } + + auto entry = std::move(it.value()); + entry.modelIndex = newIdx; + if (entry.item) { + entry.item->setProperty("index", newIdx); + m_itemToIndex[entry.item] = newIdx; + } + remapped.insert(newIdx, std::move(entry)); + } + m_delegates = std::move(remapped); + + polish(); +} + +void LazyListView::onDataChanged(const QModelIndex& topLeft, const QModelIndex& bottomRight, const QList& roles) { + Q_UNUSED(roles) + + if (topLeft.parent().isValid()) + return; + + for (int i = topLeft.row(); i <= bottomRight.row(); ++i) { + if (m_delegates.contains(i)) + updateDelegateData(m_delegates[i]); + } +} + +void LazyListView::onModelReset() { + if (!m_model) { + resetContent(); + return; + } + + const int newRows = m_model->rowCount(); + const int oldRows = static_cast(m_layout.size()); + + // Check if the model data actually changed + if (newRows == oldRows) { + const auto roleNames = m_model->roleNames(); + const auto role = roleNames.isEmpty() ? Qt::DisplayRole : roleNames.constBegin().key(); + bool changed = false; + + for (auto it = m_delegates.constBegin(); it != m_delegates.constEnd(); ++it) { + if (!it->item || it.key() >= newRows) { + changed = true; + break; + } + const auto newData = m_model->data(m_model->index(it.key(), 0), role); + const auto oldData = it->item->property("modelData"); + if (newData != oldData) { + changed = true; + break; + } + } + + if (!changed) { + // Model content unchanged, just refresh delegate data + for (auto& entry : m_delegates) + updateDelegateData(entry); + return; + } + } + + resetContent(); +} + +} // namespace caelestia::components diff --git a/plugin/src/Caelestia/Components/lazylistview.hpp b/plugin/src/Caelestia/Components/lazylistview.hpp new file mode 100644 index 000000000..e0746db2c --- /dev/null +++ b/plugin/src/Caelestia/Components/lazylistview.hpp @@ -0,0 +1,243 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace caelestia::components { + +class LazyListViewAttached : public QObject { + Q_OBJECT + + Q_PROPERTY(qreal preferredHeight READ preferredHeight WRITE setPreferredHeight NOTIFY preferredHeightChanged) + Q_PROPERTY(qreal visibleHeight READ visibleHeight WRITE setVisibleHeight NOTIFY visibleHeightChanged) + Q_PROPERTY(bool ready READ ready NOTIFY readyChanged) + Q_PROPERTY(bool adding READ adding NOTIFY addingChanged) + Q_PROPERTY(bool removing READ removing NOTIFY removingChanged) + Q_PROPERTY(bool trackViewport READ trackViewport WRITE setTrackViewport NOTIFY trackViewportChanged) + +public: + explicit LazyListViewAttached(QObject* parent = nullptr); + + [[nodiscard]] qreal preferredHeight() const; + void setPreferredHeight(qreal height); + + [[nodiscard]] qreal visibleHeight() const; + void setVisibleHeight(qreal height); + + [[nodiscard]] bool ready() const; + void setReady(bool ready); + + [[nodiscard]] bool adding() const; + void setAdding(bool adding); + + [[nodiscard]] bool removing() const; + void setRemoving(bool removing); + + [[nodiscard]] bool trackViewport() const; + void setTrackViewport(bool track); + +signals: + void preferredHeightChanged(); + void visibleHeightChanged(); + void readyChanged(); + void addingChanged(); + void removingChanged(); + void trackViewportChanged(); + +private: + qreal m_preferredHeight = -1; + qreal m_visibleHeight = -1; + bool m_ready = false; + bool m_adding = false; + bool m_removing = false; + bool m_trackViewport = false; +}; + +class LazyListView : public QQuickItem { + Q_OBJECT + QML_ELEMENT + QML_ATTACHED(LazyListViewAttached) + + // Model & Delegate + Q_PROPERTY(QAbstractItemModel* model READ model WRITE setModel NOTIFY modelChanged) + Q_PROPERTY(QQmlComponent* delegate READ delegate WRITE setDelegate NOTIFY delegateChanged) + + // Layout + Q_PROPERTY(qreal spacing READ spacing WRITE setSpacing NOTIFY spacingChanged) + Q_PROPERTY(qreal contentHeight READ contentHeight NOTIFY contentHeightChanged) + Q_PROPERTY(qreal layoutHeight READ layoutHeight NOTIFY layoutHeightChanged) + Q_PROPERTY(qreal contentY READ contentY WRITE setContentY NOTIFY contentYChanged) + + // Viewport & Lazy Loading + Q_PROPERTY(QRectF viewport READ viewport WRITE setViewport NOTIFY viewportChanged) + Q_PROPERTY(bool useCustomViewport READ useCustomViewport WRITE setUseCustomViewport NOTIFY useCustomViewportChanged) + Q_PROPERTY(qreal cacheBuffer READ cacheBuffer WRITE setCacheBuffer NOTIFY cacheBufferChanged) + + // Sizing + Q_PROPERTY(qreal estimatedHeight READ estimatedHeight WRITE setEstimatedHeight NOTIFY estimatedHeightChanged) + + // Async + Q_PROPERTY(bool asynchronous READ asynchronous WRITE setAsynchronous NOTIFY asynchronousChanged) + + // Animation Durations + Q_PROPERTY(int removeDuration READ removeDuration WRITE setRemoveDuration NOTIFY removeDurationChanged) + Q_PROPERTY(int readyDelay READ readyDelay WRITE setReadyDelay NOTIFY readyDelayChanged) + + // State + Q_PROPERTY(int count READ count NOTIFY countChanged) + +public: + explicit LazyListView(QQuickItem* parent = nullptr); + ~LazyListView() override; + + static LazyListViewAttached* qmlAttachedProperties(QObject* object); + + // Model & Delegate + [[nodiscard]] QAbstractItemModel* model() const; + void setModel(QAbstractItemModel* model); + + [[nodiscard]] QQmlComponent* delegate() const; + void setDelegate(QQmlComponent* delegate); + + // Layout + [[nodiscard]] qreal spacing() const; + void setSpacing(qreal spacing); + + [[nodiscard]] qreal contentHeight() const; + [[nodiscard]] qreal layoutHeight() const; + + [[nodiscard]] qreal contentY() const; + void setContentY(qreal contentY); + + // Viewport + [[nodiscard]] QRectF viewport() const; + void setViewport(const QRectF& viewport); + + [[nodiscard]] bool useCustomViewport() const; + void setUseCustomViewport(bool use); + + [[nodiscard]] qreal cacheBuffer() const; + void setCacheBuffer(qreal buffer); + + // Sizing + [[nodiscard]] qreal estimatedHeight() const; + void setEstimatedHeight(qreal height); + + // Async + [[nodiscard]] bool asynchronous() const; + void setAsynchronous(bool async); + + // Animation Durations + [[nodiscard]] int removeDuration() const; + void setRemoveDuration(int duration); + + [[nodiscard]] int readyDelay() const; + void setReadyDelay(int delay); + + // State + [[nodiscard]] int count() const; +signals: + void modelChanged(); + void delegateChanged(); + void spacingChanged(); + void contentHeightChanged(); + void layoutHeightChanged(); + void contentYChanged(); + void viewportChanged(); + void useCustomViewportChanged(); + void cacheBufferChanged(); + void estimatedHeightChanged(); + void asynchronousChanged(); + void removeDurationChanged(); + void readyDelayChanged(); + void countChanged(); + void viewportAdjustNeeded(qreal delta); + +protected: + void componentComplete() override; + void geometryChange(const QRectF& newGeometry, const QRectF& oldGeometry) override; + void updatePolish() override; + +private: + struct ItemRecord { + qreal targetY = 0; + qreal height = 0; + bool heightKnown = false; + bool isNew = false; + }; + + struct DelegateEntry { + int modelIndex = -1; + QQuickItem* item = nullptr; + bool pendingRemoval = false; + bool pendingInsert = false; + bool readyDelayStarted = false; + }; + + // Layout + void relayout(); + [[nodiscard]] std::pair computeVisibleRange() const; + [[nodiscard]] QRectF effectiveViewport() const; + [[nodiscard]] qreal effectiveEstimatedHeight() const; + [[nodiscard]] static qreal delegateHeight(QQuickItem* item); + [[nodiscard]] static qreal delegateVisibleHeight(QQuickItem* item); + [[nodiscard]] static bool isDelegateReady(QQuickItem* item); + void trackHeight(qreal height); + void untrackHeight(qreal height); + + // Delegate lifecycle + void syncDelegates(); + DelegateEntry createDelegate(int modelIndex); + void destroyDelegate(DelegateEntry& entry); + void updateDelegateData(DelegateEntry& entry); + + // Model connection + void connectModel(); + void disconnectModel(); + void resetContent(); + void onRowsInserted(const QModelIndex& parent, int first, int last); + void onRowsAboutToBeRemoved(const QModelIndex& parent, int first, int last); + void onRowsRemoved(const QModelIndex& parent, int first, int last); + void onRowsMoved(const QModelIndex& parent, int start, int end, const QModelIndex& destination, int row); + void onDataChanged(const QModelIndex& topLeft, const QModelIndex& bottomRight, const QList& roles); + void onModelReset(); + + // Members + QAbstractItemModel* m_model = nullptr; + QQmlComponent* m_delegate = nullptr; + + qreal m_spacing = 0; + qreal m_contentHeight = 0; + qreal m_layoutHeight = 0; + qreal m_contentY = 0; + + QRectF m_viewport; + bool m_useCustomViewport = false; + qreal m_cacheBuffer = 0; + + qreal m_estimatedHeight = -1; + qreal m_knownHeightSum = 0; + int m_knownHeightCount = 0; + bool m_asynchronous = false; + + int m_removeDuration = 300; + int m_readyDelay = 0; + + QVector m_layout; + QHash m_delegates; + QHash m_itemToIndex; + QVector m_dyingDelegates; + + bool m_componentComplete = false; + bool m_relayoutPending = false; + + QList m_modelConnections; +}; + +} // namespace caelestia::components diff --git a/plugin/src/Caelestia/Config/CMakeLists.txt b/plugin/src/Caelestia/Config/CMakeLists.txt new file mode 100644 index 000000000..4b26ea02d --- /dev/null +++ b/plugin/src/Caelestia/Config/CMakeLists.txt @@ -0,0 +1,32 @@ +qml_module(caelestia-config + URI Caelestia.Config + SOURCES + config.cpp + configattached.cpp + configobject.cpp + rootconfig.cpp + appearanceconfig.cpp + tokens.cpp + tokensattached.cpp + anim.cpp + monitorconfigmanager.cpp + backgroundconfig.hpp + barconfig.hpp + borderconfig.hpp + controlcenterconfig.hpp + dashboardconfig.hpp + generalconfig.hpp + launcherconfig.hpp + lockconfig.hpp + notifsconfig.hpp + osdconfig.hpp + serviceconfig.hpp + sessionconfig.hpp + sidebarconfig.hpp + userpaths.hpp + utilitiesconfig.hpp + winfoconfig.hpp + LIBRARIES + Qt::Quick + Qt::QuickControls2 +) diff --git a/plugin/src/Caelestia/Config/anim.cpp b/plugin/src/Caelestia/Config/anim.cpp new file mode 100644 index 000000000..e307e2f9b --- /dev/null +++ b/plugin/src/Caelestia/Config/anim.cpp @@ -0,0 +1,117 @@ +#include "anim.hpp" +#include "appearanceconfig.hpp" +#include "tokens.hpp" + +#include + +namespace caelestia::config { + +AnimTokens::AnimTokens(QObject* parent) + : QObject(parent) {} + +QEasingCurve AnimTokens::emphasized() const { + return m_emphasized; +} + +QEasingCurve AnimTokens::emphasizedAccel() const { + return m_emphasizedAccel; +} + +QEasingCurve AnimTokens::emphasizedDecel() const { + return m_emphasizedDecel; +} + +QEasingCurve AnimTokens::standard() const { + return m_standard; +} + +QEasingCurve AnimTokens::standardAccel() const { + return m_standardAccel; +} + +QEasingCurve AnimTokens::standardDecel() const { + return m_standardDecel; +} + +QEasingCurve AnimTokens::expressiveFastSpatial() const { + return m_expressiveFastSpatial; +} + +QEasingCurve AnimTokens::expressiveDefaultSpatial() const { + return m_expressiveDefaultSpatial; +} + +QEasingCurve AnimTokens::expressiveSlowSpatial() const { + return m_expressiveSlowSpatial; +} + +QEasingCurve AnimTokens::expressiveFastEffects() const { + return m_expressiveFastEffects; +} + +QEasingCurve AnimTokens::expressiveDefaultEffects() const { + return m_expressiveDefaultEffects; +} + +QEasingCurve AnimTokens::expressiveSlowEffects() const { + return m_expressiveSlowEffects; +} + +AnimDurations* AnimTokens::durations() const { + return m_durations; +} + +QEasingCurve AnimTokens::buildCurve(const QList& points) { + QEasingCurve curve(QEasingCurve::BezierSpline); + + // Points come in pairs of (x, y) forming cubic bezier segments. + // Each segment needs 3 control points: c1, c2, endPoint. + // So 6 values per segment: c1x, c1y, c2x, c2y, endX, endY. + for (int i = 0; i + 5 < points.size(); i += 6) { + QPointF c1(points[i], points[i + 1]); + QPointF c2(points[i + 2], points[i + 3]); + QPointF end(points[i + 4], points[i + 5]); + curve.addCubicBezierSegment(c1, c2, end); + } + + return curve; +} + +void AnimTokens::rebuildCurves() { + if (!m_curves) + return; + + m_emphasized = buildCurve(m_curves->emphasized()); + m_emphasizedAccel = buildCurve(m_curves->emphasizedAccel()); + m_emphasizedDecel = buildCurve(m_curves->emphasizedDecel()); + m_standard = buildCurve(m_curves->standard()); + m_standardAccel = buildCurve(m_curves->standardAccel()); + m_standardDecel = buildCurve(m_curves->standardDecel()); + m_expressiveFastSpatial = buildCurve(m_curves->expressiveFastSpatial()); + m_expressiveDefaultSpatial = buildCurve(m_curves->expressiveDefaultSpatial()); + m_expressiveSlowSpatial = buildCurve(m_curves->expressiveSlowSpatial()); + m_expressiveFastEffects = buildCurve(m_curves->expressiveFastEffects()); + m_expressiveDefaultEffects = buildCurve(m_curves->expressiveDefaultEffects()); + m_expressiveSlowEffects = buildCurve(m_curves->expressiveSlowEffects()); + + emit curvesChanged(); +} + +void AnimTokens::bindCurves(AnimCurves* curves) { + m_curves = curves; + + // Rebuild when any curve control points change + connect(curves, &AnimCurves::propertiesChanged, this, &AnimTokens::rebuildCurves); + + rebuildCurves(); +} + +void AnimTokens::bindDurations(AnimDurations* durations) { + if (m_durations == durations) + return; + + m_durations = durations; + emit durationsChanged(); +} + +} // namespace caelestia::config diff --git a/plugin/src/Caelestia/Config/anim.hpp b/plugin/src/Caelestia/Config/anim.hpp new file mode 100644 index 000000000..895b8e067 --- /dev/null +++ b/plugin/src/Caelestia/Config/anim.hpp @@ -0,0 +1,78 @@ +#pragma once + +#include +#include +#include + +namespace caelestia::config { + +class AnimCurves; +class AnimDurations; + +class AnimTokens : public QObject { + Q_OBJECT + Q_MOC_INCLUDE("tokens.hpp") // AnimCurves + Q_MOC_INCLUDE("appearanceconfig.hpp") // AnimDurations + QML_ANONYMOUS + + Q_PROPERTY(QEasingCurve emphasized READ emphasized NOTIFY curvesChanged) + Q_PROPERTY(QEasingCurve emphasizedAccel READ emphasizedAccel NOTIFY curvesChanged) + Q_PROPERTY(QEasingCurve emphasizedDecel READ emphasizedDecel NOTIFY curvesChanged) + Q_PROPERTY(QEasingCurve standard READ standard NOTIFY curvesChanged) + Q_PROPERTY(QEasingCurve standardAccel READ standardAccel NOTIFY curvesChanged) + Q_PROPERTY(QEasingCurve standardDecel READ standardDecel NOTIFY curvesChanged) + Q_PROPERTY(QEasingCurve expressiveFastSpatial READ expressiveFastSpatial NOTIFY curvesChanged) + Q_PROPERTY(QEasingCurve expressiveDefaultSpatial READ expressiveDefaultSpatial NOTIFY curvesChanged) + Q_PROPERTY(QEasingCurve expressiveSlowSpatial READ expressiveSlowSpatial NOTIFY curvesChanged) + Q_PROPERTY(QEasingCurve expressiveFastEffects READ expressiveFastEffects NOTIFY curvesChanged) + Q_PROPERTY(QEasingCurve expressiveDefaultEffects READ expressiveDefaultEffects NOTIFY curvesChanged) + Q_PROPERTY(QEasingCurve expressiveSlowEffects READ expressiveSlowEffects NOTIFY curvesChanged) + + Q_PROPERTY(caelestia::config::AnimDurations* durations READ durations NOTIFY durationsChanged) + +public: + explicit AnimTokens(QObject* parent = nullptr); + + void bindCurves(AnimCurves* curves); + void bindDurations(AnimDurations* durations); + + [[nodiscard]] QEasingCurve emphasized() const; + [[nodiscard]] QEasingCurve emphasizedAccel() const; + [[nodiscard]] QEasingCurve emphasizedDecel() const; + [[nodiscard]] QEasingCurve standard() const; + [[nodiscard]] QEasingCurve standardAccel() const; + [[nodiscard]] QEasingCurve standardDecel() const; + [[nodiscard]] QEasingCurve expressiveFastSpatial() const; + [[nodiscard]] QEasingCurve expressiveDefaultSpatial() const; + [[nodiscard]] QEasingCurve expressiveSlowSpatial() const; + [[nodiscard]] QEasingCurve expressiveFastEffects() const; + [[nodiscard]] QEasingCurve expressiveDefaultEffects() const; + [[nodiscard]] QEasingCurve expressiveSlowEffects() const; + [[nodiscard]] AnimDurations* durations() const; + +signals: + void curvesChanged(); + void durationsChanged(); + +private: + void rebuildCurves(); + static QEasingCurve buildCurve(const QList& points); + + AnimCurves* m_curves = nullptr; + AnimDurations* m_durations = nullptr; + + QEasingCurve m_emphasized; + QEasingCurve m_emphasizedAccel; + QEasingCurve m_emphasizedDecel; + QEasingCurve m_standard; + QEasingCurve m_standardAccel; + QEasingCurve m_standardDecel; + QEasingCurve m_expressiveFastSpatial; + QEasingCurve m_expressiveDefaultSpatial; + QEasingCurve m_expressiveSlowSpatial; + QEasingCurve m_expressiveFastEffects; + QEasingCurve m_expressiveDefaultEffects; + QEasingCurve m_expressiveSlowEffects; +}; + +} // namespace caelestia::config diff --git a/plugin/src/Caelestia/Config/appearanceconfig.cpp b/plugin/src/Caelestia/Config/appearanceconfig.cpp new file mode 100644 index 000000000..59228c671 --- /dev/null +++ b/plugin/src/Caelestia/Config/appearanceconfig.cpp @@ -0,0 +1,183 @@ +#include "appearanceconfig.hpp" +#include "tokens.hpp" + +#include + +namespace caelestia::config { + +// Helper: connect all changed signals from a token object to a single valuesChanged signal, +// plus connect the local scaleChanged signal. +template static void connectTokenSignals(Source* source, Target* target) { + const auto* meta = source->metaObject(); + + for (int i = meta->propertyOffset(); i < meta->propertyCount(); ++i) { + auto prop = meta->property(i); + + if (prop.hasNotifySignal()) + QObject::connect(source, prop.notifySignal(), target, + target->metaObject()->method(target->metaObject()->indexOfSignal("valuesChanged()"))); + } + + QObject::connect(target, &Target::scaleChanged, target, &Target::valuesChanged); +} + +// AppearanceRounding + +void AppearanceRounding::bindTokens(RoundingTokens* tokens) { + m_tokens = tokens; + connectTokenSignals(tokens, this); +} + +int AppearanceRounding::extraSmall() const { + return m_tokens ? static_cast(m_tokens->extraSmall() * m_scale) : 0; +} + +int AppearanceRounding::small() const { + return m_tokens ? static_cast(m_tokens->small() * m_scale) : 0; +} + +int AppearanceRounding::normal() const { + return m_tokens ? static_cast(m_tokens->normal() * m_scale) : 0; +} + +int AppearanceRounding::large() const { + return m_tokens ? static_cast(m_tokens->large() * m_scale) : 0; +} + +int AppearanceRounding::full() const { + return m_tokens ? static_cast(m_tokens->full() * m_scale) : 0; +} + +// AppearanceSpacing + +void AppearanceSpacing::bindTokens(SpacingTokens* tokens) { + m_tokens = tokens; + connectTokenSignals(tokens, this); +} + +int AppearanceSpacing::small() const { + return m_tokens ? static_cast(m_tokens->small() * m_scale) : 0; +} + +int AppearanceSpacing::smaller() const { + return m_tokens ? static_cast(m_tokens->smaller() * m_scale) : 0; +} + +int AppearanceSpacing::normal() const { + return m_tokens ? static_cast(m_tokens->normal() * m_scale) : 0; +} + +int AppearanceSpacing::larger() const { + return m_tokens ? static_cast(m_tokens->larger() * m_scale) : 0; +} + +int AppearanceSpacing::large() const { + return m_tokens ? static_cast(m_tokens->large() * m_scale) : 0; +} + +// AppearancePadding + +void AppearancePadding::bindTokens(PaddingTokens* tokens) { + m_tokens = tokens; + connectTokenSignals(tokens, this); +} + +int AppearancePadding::small() const { + return m_tokens ? static_cast(m_tokens->small() * m_scale) : 0; +} + +int AppearancePadding::smaller() const { + return m_tokens ? static_cast(m_tokens->smaller() * m_scale) : 0; +} + +int AppearancePadding::normal() const { + return m_tokens ? static_cast(m_tokens->normal() * m_scale) : 0; +} + +int AppearancePadding::larger() const { + return m_tokens ? static_cast(m_tokens->larger() * m_scale) : 0; +} + +int AppearancePadding::large() const { + return m_tokens ? static_cast(m_tokens->large() * m_scale) : 0; +} + +// FontSize + +void FontSize::bindTokens(FontSizeTokens* tokens) { + m_tokens = tokens; + connectTokenSignals(tokens, this); +} + +int FontSize::small() const { + return m_tokens ? static_cast(m_tokens->small() * m_scale) : 0; +} + +int FontSize::smaller() const { + return m_tokens ? static_cast(m_tokens->smaller() * m_scale) : 0; +} + +int FontSize::normal() const { + return m_tokens ? static_cast(m_tokens->normal() * m_scale) : 0; +} + +int FontSize::larger() const { + return m_tokens ? static_cast(m_tokens->larger() * m_scale) : 0; +} + +int FontSize::large() const { + return m_tokens ? static_cast(m_tokens->large() * m_scale) : 0; +} + +int FontSize::extraLarge() const { + return m_tokens ? static_cast(m_tokens->extraLarge() * m_scale) : 0; +} + +// AnimDurations + +void AnimDurations::bindTokens(AnimDurationTokens* tokens) { + m_tokens = tokens; + connectTokenSignals(tokens, this); +} + +int AnimDurations::small() const { + return m_tokens ? static_cast(m_tokens->small() * m_scale) : 0; +} + +int AnimDurations::normal() const { + return m_tokens ? static_cast(m_tokens->normal() * m_scale) : 0; +} + +int AnimDurations::large() const { + return m_tokens ? static_cast(m_tokens->large() * m_scale) : 0; +} + +int AnimDurations::extraLarge() const { + return m_tokens ? static_cast(m_tokens->extraLarge() * m_scale) : 0; +} + +int AnimDurations::expressiveFastSpatial() const { + return m_tokens ? static_cast(m_tokens->expressiveFastSpatial() * m_scale) : 0; +} + +int AnimDurations::expressiveDefaultSpatial() const { + return m_tokens ? static_cast(m_tokens->expressiveDefaultSpatial() * m_scale) : 0; +} + +int AnimDurations::expressiveSlowSpatial() const { + return m_tokens ? static_cast(m_tokens->expressiveSlowSpatial() * m_scale) : 0; +} + +int AnimDurations::expressiveFastEffects() const { + return m_tokens ? static_cast(m_tokens->expressiveFastEffects() * m_scale) : 0; +} + +int AnimDurations::expressiveDefaultEffects() const { + return m_tokens ? static_cast(m_tokens->expressiveDefaultEffects() * m_scale) : 0; +} + +int AnimDurations::expressiveSlowEffects() const { + return m_tokens ? static_cast(m_tokens->expressiveSlowEffects() * m_scale) : 0; +} + +} // namespace caelestia::config diff --git a/plugin/src/Caelestia/Config/appearanceconfig.hpp b/plugin/src/Caelestia/Config/appearanceconfig.hpp new file mode 100644 index 000000000..3446ea72c --- /dev/null +++ b/plugin/src/Caelestia/Config/appearanceconfig.hpp @@ -0,0 +1,259 @@ +#pragma once + +#include "configobject.hpp" + +#include + +namespace caelestia::config { + +// Forward declare token types from advancedconfig.hpp +class RoundingTokens; +class SpacingTokens; +class PaddingTokens; +class FontSizeTokens; +class AnimDurationTokens; + +class AppearanceRounding : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(qreal, scale, 1) + + Q_PROPERTY(int extraSmall READ extraSmall NOTIFY valuesChanged) + Q_PROPERTY(int small READ small NOTIFY valuesChanged) + Q_PROPERTY(int normal READ normal NOTIFY valuesChanged) + Q_PROPERTY(int large READ large NOTIFY valuesChanged) + Q_PROPERTY(int full READ full NOTIFY valuesChanged) + +public: + explicit AppearanceRounding(QObject* parent = nullptr) + : ConfigObject(parent) {} + + void bindTokens(RoundingTokens* tokens); + + [[nodiscard]] int extraSmall() const; + [[nodiscard]] int small() const; + [[nodiscard]] int normal() const; + [[nodiscard]] int large() const; + [[nodiscard]] int full() const; + +signals: + void valuesChanged(); + +private: + RoundingTokens* m_tokens = nullptr; +}; + +class AppearanceSpacing : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(qreal, scale, 1) + + Q_PROPERTY(int small READ small NOTIFY valuesChanged) + Q_PROPERTY(int smaller READ smaller NOTIFY valuesChanged) + Q_PROPERTY(int normal READ normal NOTIFY valuesChanged) + Q_PROPERTY(int larger READ larger NOTIFY valuesChanged) + Q_PROPERTY(int large READ large NOTIFY valuesChanged) + +public: + explicit AppearanceSpacing(QObject* parent = nullptr) + : ConfigObject(parent) {} + + void bindTokens(SpacingTokens* tokens); + + [[nodiscard]] int small() const; + [[nodiscard]] int smaller() const; + [[nodiscard]] int normal() const; + [[nodiscard]] int larger() const; + [[nodiscard]] int large() const; + +signals: + void valuesChanged(); + +private: + SpacingTokens* m_tokens = nullptr; +}; + +class AppearancePadding : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(qreal, scale, 1) + + Q_PROPERTY(int small READ small NOTIFY valuesChanged) + Q_PROPERTY(int smaller READ smaller NOTIFY valuesChanged) + Q_PROPERTY(int normal READ normal NOTIFY valuesChanged) + Q_PROPERTY(int larger READ larger NOTIFY valuesChanged) + Q_PROPERTY(int large READ large NOTIFY valuesChanged) + +public: + explicit AppearancePadding(QObject* parent = nullptr) + : ConfigObject(parent) {} + + void bindTokens(PaddingTokens* tokens); + + [[nodiscard]] int small() const; + [[nodiscard]] int smaller() const; + [[nodiscard]] int normal() const; + [[nodiscard]] int larger() const; + [[nodiscard]] int large() const; + +signals: + void valuesChanged(); + +private: + PaddingTokens* m_tokens = nullptr; +}; + +class FontFamily : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(QString, sans, QStringLiteral("Rubik")) + CONFIG_PROPERTY(QString, mono, QStringLiteral("CaskaydiaCove NF")) + CONFIG_PROPERTY(QString, material, QStringLiteral("Material Symbols Rounded")) + CONFIG_PROPERTY(QString, clock, QStringLiteral("Rubik")) + +public: + explicit FontFamily(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +class FontSize : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(qreal, scale, 1) + + Q_PROPERTY(int small READ small NOTIFY valuesChanged) + Q_PROPERTY(int smaller READ smaller NOTIFY valuesChanged) + Q_PROPERTY(int normal READ normal NOTIFY valuesChanged) + Q_PROPERTY(int larger READ larger NOTIFY valuesChanged) + Q_PROPERTY(int large READ large NOTIFY valuesChanged) + Q_PROPERTY(int extraLarge READ extraLarge NOTIFY valuesChanged) + +public: + explicit FontSize(QObject* parent = nullptr) + : ConfigObject(parent) {} + + void bindTokens(FontSizeTokens* tokens); + + [[nodiscard]] int small() const; + [[nodiscard]] int smaller() const; + [[nodiscard]] int normal() const; + [[nodiscard]] int larger() const; + [[nodiscard]] int large() const; + [[nodiscard]] int extraLarge() const; + +signals: + void valuesChanged(); + +private: + FontSizeTokens* m_tokens = nullptr; +}; + +class AppearanceFont : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_SUBOBJECT(FontFamily, family) + CONFIG_SUBOBJECT(FontSize, size) + +public: + explicit AppearanceFont(QObject* parent = nullptr) + : ConfigObject(parent) + , m_family(new FontFamily(this)) + , m_size(new FontSize(this)) {} +}; + +class AnimDurations : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_GLOBAL_PROPERTY(qreal, scale, 1) + + Q_PROPERTY(int small READ small NOTIFY valuesChanged) + Q_PROPERTY(int normal READ normal NOTIFY valuesChanged) + Q_PROPERTY(int large READ large NOTIFY valuesChanged) + Q_PROPERTY(int extraLarge READ extraLarge NOTIFY valuesChanged) + Q_PROPERTY(int expressiveFastSpatial READ expressiveFastSpatial NOTIFY valuesChanged) + Q_PROPERTY(int expressiveDefaultSpatial READ expressiveDefaultSpatial NOTIFY valuesChanged) + Q_PROPERTY(int expressiveSlowSpatial READ expressiveSlowSpatial NOTIFY valuesChanged) + Q_PROPERTY(int expressiveFastEffects READ expressiveFastEffects NOTIFY valuesChanged) + Q_PROPERTY(int expressiveDefaultEffects READ expressiveDefaultEffects NOTIFY valuesChanged) + Q_PROPERTY(int expressiveSlowEffects READ expressiveSlowEffects NOTIFY valuesChanged) + +public: + explicit AnimDurations(QObject* parent = nullptr) + : ConfigObject(parent) {} + + void bindTokens(AnimDurationTokens* tokens); + + [[nodiscard]] int small() const; + [[nodiscard]] int normal() const; + [[nodiscard]] int large() const; + [[nodiscard]] int extraLarge() const; + [[nodiscard]] int expressiveFastSpatial() const; + [[nodiscard]] int expressiveDefaultSpatial() const; + [[nodiscard]] int expressiveSlowSpatial() const; + [[nodiscard]] int expressiveFastEffects() const; + [[nodiscard]] int expressiveDefaultEffects() const; + [[nodiscard]] int expressiveSlowEffects() const; + +signals: + void valuesChanged(); + +private: + AnimDurationTokens* m_tokens = nullptr; +}; + +class AppearanceAnim : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_SUBOBJECT(AnimDurations, durations) + +public: + explicit AppearanceAnim(QObject* parent = nullptr) + : ConfigObject(parent) + , m_durations(new AnimDurations(this)) {} +}; + +class AppearanceTransparency : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_GLOBAL_PROPERTY(bool, enabled, false) + CONFIG_GLOBAL_PROPERTY(qreal, base, 0.85) + CONFIG_GLOBAL_PROPERTY(qreal, layers, 0.4) + +public: + explicit AppearanceTransparency(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +class AppearanceConfig : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(qreal, deformScale, 1) + CONFIG_SUBOBJECT(AppearanceRounding, rounding) + CONFIG_SUBOBJECT(AppearanceSpacing, spacing) + CONFIG_SUBOBJECT(AppearancePadding, padding) + CONFIG_SUBOBJECT(AppearanceFont, font) + CONFIG_SUBOBJECT(AppearanceAnim, anim) + CONFIG_SUBOBJECT(AppearanceTransparency, transparency) + +public: + explicit AppearanceConfig(QObject* parent = nullptr) + : ConfigObject(parent) + , m_rounding(new AppearanceRounding(this)) + , m_spacing(new AppearanceSpacing(this)) + , m_padding(new AppearancePadding(this)) + , m_font(new AppearanceFont(this)) + , m_anim(new AppearanceAnim(this)) + , m_transparency(new AppearanceTransparency(this)) {} +}; + +} // namespace caelestia::config diff --git a/plugin/src/Caelestia/Config/backgroundconfig.hpp b/plugin/src/Caelestia/Config/backgroundconfig.hpp new file mode 100644 index 000000000..743cd787a --- /dev/null +++ b/plugin/src/Caelestia/Config/backgroundconfig.hpp @@ -0,0 +1,84 @@ +#pragma once + +#include "configobject.hpp" + +#include + +namespace caelestia::config { + +class DesktopClockBackground : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(bool, enabled, false) + CONFIG_PROPERTY(qreal, opacity, 0.7) + CONFIG_PROPERTY(bool, blur, true) + +public: + explicit DesktopClockBackground(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +class DesktopClockShadow : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(bool, enabled, true) + CONFIG_PROPERTY(qreal, opacity, 0.7) + CONFIG_PROPERTY(qreal, blur, 0.4) + +public: + explicit DesktopClockShadow(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +class DesktopClock : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(bool, enabled, false) + CONFIG_PROPERTY(qreal, scale, 1.0) + CONFIG_PROPERTY(QString, position, QStringLiteral("bottom-right")) + CONFIG_PROPERTY(bool, invertColors, false) + CONFIG_SUBOBJECT(DesktopClockBackground, background) + CONFIG_SUBOBJECT(DesktopClockShadow, shadow) + +public: + explicit DesktopClock(QObject* parent = nullptr) + : ConfigObject(parent) + , m_background(new DesktopClockBackground(this)) + , m_shadow(new DesktopClockShadow(this)) {} +}; + +class BackgroundVisualiser : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(bool, enabled, false) + CONFIG_PROPERTY(bool, autoHide, true) + CONFIG_PROPERTY(bool, blur, false) + CONFIG_PROPERTY(qreal, rounding, 1) + CONFIG_PROPERTY(qreal, spacing, 1) + +public: + explicit BackgroundVisualiser(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +class BackgroundConfig : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(bool, enabled, true) + CONFIG_PROPERTY(bool, wallpaperEnabled, true) + CONFIG_SUBOBJECT(DesktopClock, desktopClock) + CONFIG_SUBOBJECT(BackgroundVisualiser, visualiser) + +public: + explicit BackgroundConfig(QObject* parent = nullptr) + : ConfigObject(parent) + , m_desktopClock(new DesktopClock(this)) + , m_visualiser(new BackgroundVisualiser(this)) {} +}; + +} // namespace caelestia::config diff --git a/plugin/src/Caelestia/Config/barconfig.hpp b/plugin/src/Caelestia/Config/barconfig.hpp new file mode 100644 index 000000000..43d43205a --- /dev/null +++ b/plugin/src/Caelestia/Config/barconfig.hpp @@ -0,0 +1,166 @@ +#pragma once + +#include "configobject.hpp" + +#include +#include +#include + +namespace caelestia::config { + +using Qt::StringLiterals::operator""_s; + +class BarScrollActions : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(bool, workspaces, true) + CONFIG_PROPERTY(bool, volume, true) + CONFIG_PROPERTY(bool, brightness, true) + +public: + explicit BarScrollActions(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +class BarPopouts : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(bool, activeWindow, true) + CONFIG_PROPERTY(bool, tray, true) + CONFIG_PROPERTY(bool, statusIcons, true) + +public: + explicit BarPopouts(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +class BarWorkspaces : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(int, shown, 5) + CONFIG_PROPERTY(bool, activeIndicator, true) + CONFIG_PROPERTY(bool, occupiedBg, false) + CONFIG_PROPERTY(bool, showWindows, true) + CONFIG_PROPERTY(bool, showWindowsOnSpecialWorkspaces, true) + CONFIG_PROPERTY(int, maxWindowIcons, 5) + CONFIG_PROPERTY(bool, activeTrail, false) + CONFIG_GLOBAL_PROPERTY(bool, perMonitorWorkspaces, true) + CONFIG_PROPERTY(QString, label, u" "_s) + CONFIG_PROPERTY(QString, occupiedLabel, u"󰮯"_s) + CONFIG_PROPERTY(QString, activeLabel, u"󰮯"_s) + CONFIG_PROPERTY(QString, capitalisation, u"preserve"_s) + CONFIG_GLOBAL_PROPERTY(QVariantList, specialWorkspaceIcons) + CONFIG_GLOBAL_PROPERTY(QVariantList, windowIcons, + { vmap({ + { u"regex"_s, u"steam(_app_(default|[0-9]+))?"_s }, + { u"icon"_s, u"sports_esports"_s }, + }) }) + +public: + explicit BarWorkspaces(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +class BarActiveWindow : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(bool, compact, false) + CONFIG_PROPERTY(bool, inverted, false) + CONFIG_PROPERTY(bool, showOnHover, true) + +public: + explicit BarActiveWindow(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +class BarTray : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(bool, background, false) + CONFIG_PROPERTY(bool, recolour, false) + CONFIG_PROPERTY(bool, compact, false) + CONFIG_GLOBAL_PROPERTY(QVariantList, iconSubs) + CONFIG_GLOBAL_PROPERTY(QStringList, hiddenIcons) + +public: + explicit BarTray(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +class BarStatus : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(bool, showAudio, false) + CONFIG_PROPERTY(bool, showMicrophone, false) + CONFIG_PROPERTY(bool, showKbLayout, false) + CONFIG_PROPERTY(bool, showNetwork, true) + CONFIG_PROPERTY(bool, showWifi, true) + CONFIG_PROPERTY(bool, showBluetooth, true) + CONFIG_PROPERTY(bool, showBattery, true) + CONFIG_PROPERTY(bool, showLockStatus, true) + +public: + explicit BarStatus(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +class BarClock : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(bool, background, false) + CONFIG_PROPERTY(bool, showDate, false) + CONFIG_PROPERTY(bool, showIcon, true) + +public: + explicit BarClock(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +class BarConfig : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(bool, persistent, true) + CONFIG_PROPERTY(bool, showOnHover, true) + CONFIG_PROPERTY(int, dragThreshold, 20) + CONFIG_SUBOBJECT(BarScrollActions, scrollActions) + CONFIG_SUBOBJECT(BarPopouts, popouts) + CONFIG_SUBOBJECT(BarWorkspaces, workspaces) + CONFIG_SUBOBJECT(BarActiveWindow, activeWindow) + CONFIG_SUBOBJECT(BarTray, tray) + CONFIG_SUBOBJECT(BarStatus, status) + CONFIG_SUBOBJECT(BarClock, clock) + CONFIG_PROPERTY(QVariantList, entries, + { + vmap({ { u"id"_s, u"logo"_s }, { u"enabled"_s, true } }), + vmap({ { u"id"_s, u"workspaces"_s }, { u"enabled"_s, true } }), + vmap({ { u"id"_s, u"spacer"_s }, { u"enabled"_s, true } }), + vmap({ { u"id"_s, u"activeWindow"_s }, { u"enabled"_s, true } }), + vmap({ { u"id"_s, u"spacer"_s }, { u"enabled"_s, true } }), + vmap({ { u"id"_s, u"tray"_s }, { u"enabled"_s, true } }), + vmap({ { u"id"_s, u"clock"_s }, { u"enabled"_s, true } }), + vmap({ { u"id"_s, u"statusIcons"_s }, { u"enabled"_s, true } }), + vmap({ { u"id"_s, u"power"_s }, { u"enabled"_s, true } }), + }) + CONFIG_PROPERTY(QStringList, excludedScreens) + +public: + explicit BarConfig(QObject* parent = nullptr) + : ConfigObject(parent) + , m_scrollActions(new BarScrollActions(this)) + , m_popouts(new BarPopouts(this)) + , m_workspaces(new BarWorkspaces(this)) + , m_activeWindow(new BarActiveWindow(this)) + , m_tray(new BarTray(this)) + , m_status(new BarStatus(this)) + , m_clock(new BarClock(this)) {} +}; + +} // namespace caelestia::config diff --git a/plugin/src/Caelestia/Config/borderconfig.hpp b/plugin/src/Caelestia/Config/borderconfig.hpp new file mode 100644 index 000000000..9de604e82 --- /dev/null +++ b/plugin/src/Caelestia/Config/borderconfig.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include "configobject.hpp" + +#include + +namespace caelestia::config { + +class BorderConfig : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(int, thickness, 10) + CONFIG_PROPERTY(int, rounding, 25) + CONFIG_PROPERTY(int, smoothing, 32) + + Q_PROPERTY(int minThickness READ minThickness CONSTANT) + Q_PROPERTY(int clampedThickness READ clampedThickness NOTIFY thicknessChanged) + +public: + explicit BorderConfig(QObject* parent = nullptr) + : ConfigObject(parent) {} + + [[nodiscard]] static int minThickness() { return 2; } + + [[nodiscard]] int clampedThickness() const { return std::max(minThickness(), m_thickness); } +}; + +} // namespace caelestia::config diff --git a/plugin/src/Caelestia/Config/config.cpp b/plugin/src/Caelestia/Config/config.cpp new file mode 100644 index 000000000..ff939f998 --- /dev/null +++ b/plugin/src/Caelestia/Config/config.cpp @@ -0,0 +1,120 @@ +#include "config.hpp" +#include "appearanceconfig.hpp" +#include "backgroundconfig.hpp" +#include "barconfig.hpp" +#include "borderconfig.hpp" +#include "controlcenterconfig.hpp" +#include "dashboardconfig.hpp" +#include "generalconfig.hpp" +#include "launcherconfig.hpp" +#include "lockconfig.hpp" +#include "monitorconfigmanager.hpp" +#include "notifsconfig.hpp" +#include "osdconfig.hpp" +#include "serviceconfig.hpp" +#include "sessionconfig.hpp" +#include "sidebarconfig.hpp" +#include "tokens.hpp" +#include "userpaths.hpp" +#include "utilitiesconfig.hpp" +#include "winfoconfig.hpp" + +#include +#include + +namespace caelestia::config { + +namespace { + +QString configDir() { + return QStandardPaths::writableLocation(QStandardPaths::GenericConfigLocation) + QStringLiteral("/caelestia/"); +} + +} // namespace + +GlobalConfig::GlobalConfig(QObject* parent) + : RootConfig(parent) + , m_appearance(new AppearanceConfig(this)) + , m_general(new GeneralConfig(this)) + , m_background(new BackgroundConfig(this)) + , m_bar(new BarConfig(this)) + , m_border(new BorderConfig(this)) + , m_dashboard(new DashboardConfig(this)) + , m_controlCenter(new ControlCenterConfig(this)) + , m_launcher(new LauncherConfig(this)) + , m_notifs(new NotifsConfig(this)) + , m_osd(new OsdConfig(this)) + , m_session(new SessionConfig(this)) + , m_winfo(new WInfoConfig(this)) + , m_lock(new LockConfig(this)) + , m_utilities(new UtilitiesConfig(this)) + , m_sidebar(new SidebarConfig(this)) + , m_services(new ServiceConfig(this)) + , m_paths(new UserPaths(this)) { + setupFileBackend(configDir() + QStringLiteral("shell.json")); +} + +GlobalConfig::GlobalConfig(GlobalConfig* fallback, const QString& filePath, const QString& screen, QObject* parent) + : RootConfig(parent) + , m_appearance(new AppearanceConfig(this)) + , m_general(new GeneralConfig(this)) + , m_background(new BackgroundConfig(this)) + , m_bar(new BarConfig(this)) + , m_border(new BorderConfig(this)) + , m_dashboard(new DashboardConfig(this)) + , m_controlCenter(new ControlCenterConfig(this)) + , m_launcher(new LauncherConfig(this)) + , m_notifs(new NotifsConfig(this)) + , m_osd(new OsdConfig(this)) + , m_session(new SessionConfig(this)) + , m_winfo(new WInfoConfig(this)) + , m_lock(new LockConfig(this)) + , m_utilities(new UtilitiesConfig(this)) + , m_sidebar(new SidebarConfig(this)) + , m_services(new ServiceConfig(this)) + , m_paths(new UserPaths(this)) { + if (!filePath.isEmpty()) + setupFileBackend(filePath, screen); + if (fallback) + syncFromGlobal(fallback); + + // Bind appearance computed properties to token base values + bindAppearanceTokens(); +} + +GlobalConfig* GlobalConfig::instance() { + static GlobalConfig instance; + instance.bindAppearanceTokens(); + return &instance; +} + +GlobalConfig* GlobalConfig::defaults() { + if (!m_defaults) + m_defaults = new GlobalConfig(nullptr, QString(), QString(), this); + return m_defaults; +} + +void GlobalConfig::bindAppearanceTokens() { + if (m_tokensBound) + return; + + qCDebug(lcConfig) << "GlobalConfig::bindAppearanceTokens: binding appearance to token values"; + auto* const tokenAppearance = TokenConfig::instance()->appearance(); + m_appearance->rounding()->bindTokens(tokenAppearance->rounding()); + m_appearance->spacing()->bindTokens(tokenAppearance->spacing()); + m_appearance->padding()->bindTokens(tokenAppearance->padding()); + m_appearance->font()->size()->bindTokens(tokenAppearance->fontSize()); + m_appearance->anim()->durations()->bindTokens(tokenAppearance->animDurations()); + m_tokensBound = true; +} + +GlobalConfig* GlobalConfig::forScreen(const QString& screen) { + return MonitorConfigManager::instance()->configForScreen(screen); +} + +GlobalConfig* GlobalConfig::create(QQmlEngine*, QJSEngine*) { + QQmlEngine::setObjectOwnership(instance(), QQmlEngine::CppOwnership); + return instance(); +} + +} // namespace caelestia::config diff --git a/plugin/src/Caelestia/Config/config.hpp b/plugin/src/Caelestia/Config/config.hpp new file mode 100644 index 000000000..248a3f225 --- /dev/null +++ b/plugin/src/Caelestia/Config/config.hpp @@ -0,0 +1,86 @@ +#pragma once + +#include "rootconfig.hpp" + +#include + +namespace caelestia::config { + +class AppearanceConfig; +class BackgroundConfig; +class BarConfig; +class BorderConfig; +class ControlCenterConfig; +class DashboardConfig; +class GeneralConfig; +class LauncherConfig; +class LockConfig; +class NotifsConfig; +class OsdConfig; +class ServiceConfig; +class SessionConfig; +class SidebarConfig; +class UserPaths; +class UtilitiesConfig; +class WInfoConfig; + +class GlobalConfig : public RootConfig { + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + Q_MOC_INCLUDE("appearanceconfig.hpp") + Q_MOC_INCLUDE("backgroundconfig.hpp") + Q_MOC_INCLUDE("barconfig.hpp") + Q_MOC_INCLUDE("borderconfig.hpp") + Q_MOC_INCLUDE("controlcenterconfig.hpp") + Q_MOC_INCLUDE("dashboardconfig.hpp") + Q_MOC_INCLUDE("generalconfig.hpp") + Q_MOC_INCLUDE("launcherconfig.hpp") + Q_MOC_INCLUDE("lockconfig.hpp") + Q_MOC_INCLUDE("notifsconfig.hpp") + Q_MOC_INCLUDE("osdconfig.hpp") + Q_MOC_INCLUDE("serviceconfig.hpp") + Q_MOC_INCLUDE("sessionconfig.hpp") + Q_MOC_INCLUDE("sidebarconfig.hpp") + Q_MOC_INCLUDE("userpaths.hpp") + Q_MOC_INCLUDE("utilitiesconfig.hpp") + Q_MOC_INCLUDE("winfoconfig.hpp") + + CONFIG_PROPERTY(bool, enabled, true) + CONFIG_SUBOBJECT(AppearanceConfig, appearance) + CONFIG_SUBOBJECT(GeneralConfig, general) + CONFIG_SUBOBJECT(BackgroundConfig, background) + CONFIG_SUBOBJECT(BarConfig, bar) + CONFIG_SUBOBJECT(BorderConfig, border) + CONFIG_SUBOBJECT(DashboardConfig, dashboard) + CONFIG_SUBOBJECT(ControlCenterConfig, controlCenter) + CONFIG_SUBOBJECT(LauncherConfig, launcher) + CONFIG_SUBOBJECT(NotifsConfig, notifs) + CONFIG_SUBOBJECT(OsdConfig, osd) + CONFIG_SUBOBJECT(SessionConfig, session) + CONFIG_SUBOBJECT(WInfoConfig, winfo) + CONFIG_SUBOBJECT(LockConfig, lock) + CONFIG_SUBOBJECT(UtilitiesConfig, utilities) + CONFIG_SUBOBJECT(SidebarConfig, sidebar) + CONFIG_SUBOBJECT(ServiceConfig, services) + CONFIG_SUBOBJECT(UserPaths, paths) + +public: + static GlobalConfig* instance(); + [[nodiscard]] Q_INVOKABLE GlobalConfig* defaults(); + [[nodiscard]] Q_INVOKABLE static GlobalConfig* forScreen(const QString& screen); + static GlobalConfig* create(QQmlEngine*, QJSEngine*); + + void bindAppearanceTokens(); + +private: + friend class MonitorConfigManager; + explicit GlobalConfig(QObject* parent = nullptr); + explicit GlobalConfig( + GlobalConfig* fallback, const QString& filePath, const QString& screen = {}, QObject* parent = nullptr); + + GlobalConfig* m_defaults = nullptr; + bool m_tokensBound = false; +}; + +} // namespace caelestia::config diff --git a/plugin/src/Caelestia/Config/configattached.cpp b/plugin/src/Caelestia/Config/configattached.cpp new file mode 100644 index 000000000..8d229049b --- /dev/null +++ b/plugin/src/Caelestia/Config/configattached.cpp @@ -0,0 +1,96 @@ +#include "configattached.hpp" +#include "config.hpp" +#include "monitorconfigmanager.hpp" + +#include + +namespace caelestia::config { + +Config::Config(QObject* parent) + : QQuickAttachedPropertyPropagator(parent) { + initialize(); +} + +void Config::classBegin() {} + +void Config::componentComplete() { + m_complete = true; +} + +QString Config::screen() const { + return m_screen; +} + +void Config::inheritScreen(const QString& screen) { + if (screen == m_screen) + return; + + m_screen = screen; + + if (m_screen.isEmpty()) + m_config = nullptr; + else + m_config = MonitorConfigManager::instance()->configForScreen(m_screen); + + propagateScreen(); + emit sourceChanged(); +} + +void Config::propagateScreen() { + const auto children = attachedChildren(); + for (auto* const child : children) { + auto* const config = qobject_cast(child); + if (config) + config->inheritScreen(m_screen); + } +} + +void Config::attachedParentChange( + QQuickAttachedPropertyPropagator* newParent, QQuickAttachedPropertyPropagator* oldParent) { + Q_UNUSED(oldParent); + auto* const config = qobject_cast(newParent); + if (config) + inheritScreen(config->screen()); +} + +#define CONFIG_ATTACHED_GETTER(Type, name) \ + const Type* Config::name() const { \ + if (m_config) \ + return m_config->name(); \ + /* Suppress warnings before component is complete if attached to a QQuickItem. */ \ + /* Raw QObjects are unable to inherit the screen (only QQuickItems can). */ \ + if ((m_complete || !qobject_cast(parent())) && parent()) \ + qCWarning(lcConfig, "Config.%s accessed without a screen set on %s", #name, \ + parent()->metaObject()->className()); \ + return GlobalConfig::instance()->name(); \ + } + +CONFIG_ATTACHED_GETTER(AppearanceConfig, appearance) +CONFIG_ATTACHED_GETTER(GeneralConfig, general) +CONFIG_ATTACHED_GETTER(BackgroundConfig, background) +CONFIG_ATTACHED_GETTER(BarConfig, bar) +CONFIG_ATTACHED_GETTER(BorderConfig, border) +CONFIG_ATTACHED_GETTER(DashboardConfig, dashboard) +CONFIG_ATTACHED_GETTER(ControlCenterConfig, controlCenter) +CONFIG_ATTACHED_GETTER(LauncherConfig, launcher) +CONFIG_ATTACHED_GETTER(NotifsConfig, notifs) +CONFIG_ATTACHED_GETTER(OsdConfig, osd) +CONFIG_ATTACHED_GETTER(SessionConfig, session) +CONFIG_ATTACHED_GETTER(WInfoConfig, winfo) +CONFIG_ATTACHED_GETTER(LockConfig, lock) +CONFIG_ATTACHED_GETTER(UtilitiesConfig, utilities) +CONFIG_ATTACHED_GETTER(SidebarConfig, sidebar) +CONFIG_ATTACHED_GETTER(ServiceConfig, services) +CONFIG_ATTACHED_GETTER(UserPaths, paths) + +#undef CONFIG_ATTACHED_GETTER + +GlobalConfig* Config::forScreen(const QString& screen) { + return GlobalConfig::forScreen(screen); +} + +Config* Config::qmlAttachedProperties(QObject* object) { + return new Config(object); +} + +} // namespace caelestia::config diff --git a/plugin/src/Caelestia/Config/configattached.hpp b/plugin/src/Caelestia/Config/configattached.hpp new file mode 100644 index 000000000..815b10805 --- /dev/null +++ b/plugin/src/Caelestia/Config/configattached.hpp @@ -0,0 +1,98 @@ +#pragma once + +#include "config.hpp" + +#include + +namespace caelestia::config { + +class Config : public QQuickAttachedPropertyPropagator, public QQmlParserStatus { + Q_OBJECT + Q_INTERFACES(QQmlParserStatus) + QML_ELEMENT + QML_UNCREATABLE("") + QML_ATTACHED(Config) + Q_MOC_INCLUDE("appearanceconfig.hpp") + Q_MOC_INCLUDE("backgroundconfig.hpp") + Q_MOC_INCLUDE("barconfig.hpp") + Q_MOC_INCLUDE("borderconfig.hpp") + Q_MOC_INCLUDE("controlcenterconfig.hpp") + Q_MOC_INCLUDE("dashboardconfig.hpp") + Q_MOC_INCLUDE("generalconfig.hpp") + Q_MOC_INCLUDE("launcherconfig.hpp") + Q_MOC_INCLUDE("lockconfig.hpp") + Q_MOC_INCLUDE("notifsconfig.hpp") + Q_MOC_INCLUDE("osdconfig.hpp") + Q_MOC_INCLUDE("serviceconfig.hpp") + Q_MOC_INCLUDE("sessionconfig.hpp") + Q_MOC_INCLUDE("sidebarconfig.hpp") + Q_MOC_INCLUDE("userpaths.hpp") + Q_MOC_INCLUDE("utilitiesconfig.hpp") + Q_MOC_INCLUDE("winfoconfig.hpp") + + Q_PROPERTY(QString screen READ screen WRITE inheritScreen NOTIFY sourceChanged) + Q_PROPERTY(const caelestia::config::AppearanceConfig* appearance READ appearance NOTIFY sourceChanged) + Q_PROPERTY(const caelestia::config::GeneralConfig* general READ general NOTIFY sourceChanged) + Q_PROPERTY(const caelestia::config::BackgroundConfig* background READ background NOTIFY sourceChanged) + Q_PROPERTY(const caelestia::config::BarConfig* bar READ bar NOTIFY sourceChanged) + Q_PROPERTY(const caelestia::config::BorderConfig* border READ border NOTIFY sourceChanged) + Q_PROPERTY(const caelestia::config::DashboardConfig* dashboard READ dashboard NOTIFY sourceChanged) + Q_PROPERTY(const caelestia::config::ControlCenterConfig* controlCenter READ controlCenter NOTIFY sourceChanged) + Q_PROPERTY(const caelestia::config::LauncherConfig* launcher READ launcher NOTIFY sourceChanged) + Q_PROPERTY(const caelestia::config::NotifsConfig* notifs READ notifs NOTIFY sourceChanged) + Q_PROPERTY(const caelestia::config::OsdConfig* osd READ osd NOTIFY sourceChanged) + Q_PROPERTY(const caelestia::config::SessionConfig* session READ session NOTIFY sourceChanged) + Q_PROPERTY(const caelestia::config::WInfoConfig* winfo READ winfo NOTIFY sourceChanged) + Q_PROPERTY(const caelestia::config::LockConfig* lock READ lock NOTIFY sourceChanged) + Q_PROPERTY(const caelestia::config::UtilitiesConfig* utilities READ utilities NOTIFY sourceChanged) + Q_PROPERTY(const caelestia::config::SidebarConfig* sidebar READ sidebar NOTIFY sourceChanged) + Q_PROPERTY(const caelestia::config::ServiceConfig* services READ services NOTIFY sourceChanged) + Q_PROPERTY(const caelestia::config::UserPaths* paths READ paths NOTIFY sourceChanged) + +public: + explicit Config(QObject* parent = nullptr); + + [[nodiscard]] QString screen() const; + void inheritScreen(const QString& screen); + + [[nodiscard]] const AppearanceConfig* appearance() const; + [[nodiscard]] const GeneralConfig* general() const; + [[nodiscard]] const BackgroundConfig* background() const; + [[nodiscard]] const BarConfig* bar() const; + [[nodiscard]] const BorderConfig* border() const; + [[nodiscard]] const DashboardConfig* dashboard() const; + [[nodiscard]] const ControlCenterConfig* controlCenter() const; + [[nodiscard]] const LauncherConfig* launcher() const; + [[nodiscard]] const NotifsConfig* notifs() const; + [[nodiscard]] const OsdConfig* osd() const; + [[nodiscard]] const SessionConfig* session() const; + [[nodiscard]] const WInfoConfig* winfo() const; + [[nodiscard]] const LockConfig* lock() const; + [[nodiscard]] const UtilitiesConfig* utilities() const; + [[nodiscard]] const SidebarConfig* sidebar() const; + [[nodiscard]] const ServiceConfig* services() const; + [[nodiscard]] const UserPaths* paths() const; + + [[nodiscard]] Q_INVOKABLE static GlobalConfig* forScreen(const QString& screen); + + static Config* qmlAttachedProperties(QObject* object); + +signals: + void sourceChanged(); + +protected: + void attachedParentChange( + QQuickAttachedPropertyPropagator* newParent, QQuickAttachedPropertyPropagator* oldParent) override; + +private: + void classBegin() override; + void componentComplete() override; + + void propagateScreen(); + + bool m_complete = false; + QString m_screen; + GlobalConfig* m_config = nullptr; +}; + +} // namespace caelestia::config diff --git a/plugin/src/Caelestia/Config/configobject.cpp b/plugin/src/Caelestia/Config/configobject.cpp new file mode 100644 index 000000000..030ecabe8 --- /dev/null +++ b/plugin/src/Caelestia/Config/configobject.cpp @@ -0,0 +1,316 @@ +#include "configobject.hpp" + +#include +#include +#include +#include +#include +#include + +namespace caelestia::config { + +Q_LOGGING_CATEGORY(lcConfig, "caelestia.config", QtInfoMsg) + +// ConfigObject + +ConfigObject::ConfigObject(QObject* parent) + : QObject(parent) {} + +void ConfigObject::loadFromJson(const QJsonObject& obj) { + const auto* meta = metaObject(); + + qCDebug(lcConfig) << "Loading JSON into" << meta->className() << "with" << obj.keys().size() + << "keys:" << obj.keys(); + + for (int i = meta->propertyOffset(); i < meta->propertyCount(); ++i) { + auto prop = meta->property(i); + const auto key = QString::fromUtf8(prop.name()); + + if (!obj.contains(key)) + continue; + + if (isGlobalOnly(key)) + qCWarning(lcConfig, "Option '%s' is global-only and will be ignored in per-monitor config", + qUtf8Printable(propertyPath(key))); + + const auto jsonVal = obj.value(key); + + // Recurse into sub-objects + auto current = prop.read(this); + auto* subObj = current.value(); + + if (subObj) { + qCDebug(lcConfig) << " Recursing into sub-object" << key; + subObj->loadFromJson(jsonVal.toObject()); + continue; + } + + // Skip read-only properties + if (!prop.isWritable()) + continue; + + // Handle QStringList explicitly (QJsonArray → QStringList needs manual conversion) + if (prop.metaType().id() == QMetaType::QStringList) { + QStringList list; + const auto jsonArr = jsonVal.toArray(); + for (const auto& v : jsonArr) + list.append(v.toString()); + prop.write(this, QVariant::fromValue(list)); + m_loadedKeys.insert(key); + qCDebug(lcConfig) << " Loaded" << key << "=" << list; + continue; + } + + // For all other types, let Qt's variant conversion handle it + prop.write(this, jsonVal.toVariant()); + m_loadedKeys.insert(key); + qCDebug(lcConfig) << " Loaded" << key << "=" << jsonVal.toVariant(); + } +} + +QJsonObject ConfigObject::toJsonObject() const { + QJsonObject obj; + const auto* meta = metaObject(); + + for (int i = meta->propertyOffset(); i < meta->propertyCount(); ++i) { + const auto prop = meta->property(i); + + if (!prop.isReadable()) + continue; + + const auto key = QString::fromUtf8(prop.name()); + + if (isGlobalOnly(key)) + continue; + + const auto value = prop.read(this); + + // Recurse into sub-objects — include only if they have loaded keys + if (value.canView()) { + auto* const subObj = value.value(); + if (subObj) { + auto subJson = subObj->toJsonObject(); + if (!subJson.isEmpty()) + obj.insert(key, subJson); + } + continue; + } + + // Only include properties that were explicitly loaded + if (!m_loadedKeys.contains(key)) + continue; + + if (!prop.isWritable()) + continue; + + if (prop.metaType().id() == QMetaType::QStringList) { + QJsonArray arr; + const auto strList = value.toStringList(); + for (const auto& s : strList) + arr.append(s); + obj.insert(key, arr); + continue; + } + + if (prop.metaType().id() == QMetaType::QVariantList) { + obj.insert(key, QJsonArray::fromVariantList(value.toList())); + continue; + } + + obj.insert(key, QJsonValue::fromVariant(value)); + } + + return obj; +} + +void ConfigObject::clearLoadedKeys() { + m_loadedKeys.clear(); + + const auto* meta = metaObject(); + for (int i = meta->propertyOffset(); i < meta->propertyCount(); ++i) { + auto prop = meta->property(i); + if (isGlobalOnly(QString::fromUtf8(prop.name()))) + continue; + auto value = prop.read(this); + auto* subObj = value.value(); + if (subObj) + subObj->clearLoadedKeys(); + } +} + +void ConfigObject::syncFromGlobal(ConfigObject* global) { + m_global = global; + + const auto* meta = metaObject(); + qCDebug(lcConfig) << "Syncing" << meta->className() << "from global, loaded keys:" << m_loadedKeys; + + // Connect batched change signal (single connection per ConfigObject pair) + connect(global, &ConfigObject::propertiesChanged, this, &ConfigObject::onGlobalPropertiesChanged); + + // Initial sync: copy all non-loaded property values from global + for (int i = meta->propertyOffset(); i < meta->propertyCount(); ++i) { + auto prop = meta->property(i); + const auto key = QString::fromUtf8(prop.name()); + + if (isGlobalOnly(key)) + continue; + + auto current = prop.read(this); + auto* subObj = current.value(); + + if (subObj) { + auto globalVal = prop.read(global); + auto* globalSub = globalVal.value(); + if (globalSub) + subObj->syncFromGlobal(globalSub); + continue; + } + + if (!prop.isWritable()) + continue; + + if (!m_loadedKeys.contains(key)) { + auto val = prop.read(global); + prop.write(this, val); + m_loadedKeys.remove(key); // setter added it — remove since this is a synced value + qCDebug(lcConfig) << " Synced" << key << "=" << val << "from global"; + } else { + qCDebug(lcConfig) << " Keeping loaded" << key << "=" << prop.read(this); + } + } +} + +void ConfigObject::resyncFromGlobal() { + if (!m_global) + return; + + const auto* meta = metaObject(); + for (int i = meta->propertyOffset(); i < meta->propertyCount(); ++i) { + auto prop = meta->property(i); + const auto key = QString::fromUtf8(prop.name()); + + if (isGlobalOnly(key)) + continue; + + auto current = prop.read(this); + auto* subObj = current.value(); + + if (subObj) { + subObj->resyncFromGlobal(); + continue; + } + + if (!prop.isWritable()) + continue; + + if (!m_loadedKeys.contains(key)) { + prop.write(this, prop.read(m_global)); + m_loadedKeys.remove(key); // setter added it — remove since this is a synced value + } + } +} + +QString ConfigObject::propertyPath(const QString& name) const { + QStringList parts; + parts.append(name); + + const QObject* obj = this; + while (auto* parentObj = obj->parent()) { + auto* parentConfig = qobject_cast(parentObj); + if (!parentConfig) + break; + + // Find which property name this child is on the parent + const auto* meta = parentConfig->metaObject(); + bool found = false; + for (int i = meta->propertyOffset(); i < meta->propertyCount(); ++i) { + auto prop = meta->property(i); + auto val = prop.read(parentObj); + if (val.value() == obj) { + parts.prepend(QString::fromUtf8(prop.name())); + found = true; + break; + } + } + + if (!found) + break; + + obj = parentObj; + } + + return parts.join(QLatin1Char('.')); +} + +bool ConfigObject::isPropertyLoaded(const QString& name) const { + return m_loadedKeys.contains(name); +} + +bool ConfigObject::isOverlay() const { + return m_global != nullptr; +} + +bool ConfigObject::isGlobalOnly(const QString& name) const { + return isOverlay() && m_globalOnlyKeys.contains(name); +} + +void ConfigObject::markPropertyLoaded(const QString& name) { + m_loadedKeys.insert(name); +} + +void ConfigObject::resetOption(const QString& name) { + m_loadedKeys.remove(name); + + // If synced from global, re-copy the global value + if (m_global) { + int idx = metaObject()->indexOfProperty(name.toUtf8().constData()); + if (idx >= 0) { + auto prop = metaObject()->property(idx); + if (prop.isWritable()) + prop.write(this, prop.read(m_global)); + } + } +} + +void ConfigObject::onGlobalPropertiesChanged(const QMap& changed) { + for (auto it = changed.begin(); it != changed.end(); ++it) { + if (m_loadedKeys.contains(it.key()) || isGlobalOnly(it.key())) + continue; + + int idx = metaObject()->indexOfProperty(it.key().toUtf8().constData()); + if (idx >= 0) { + metaObject()->property(idx).write(this, it.value()); + m_loadedKeys.remove(it.key()); // setter added it — remove since this is a synced value + qCDebug(lcConfig) << metaObject()->className() << "synced" << it.key() << "=" << it.value() + << "from global change"; + } + } +} + +void ConfigObject::markGlobalOnly(const QString& name) { + m_globalOnlyKeys.insert(name); +} + +void ConfigObject::notifyPropertyChanged(const QString& name, const QVariant& value) { + m_pendingChanges.insert(name, value); + + if (!m_batchTimer) { + m_batchTimer = new QTimer(this); + m_batchTimer->setSingleShot(true); + m_batchTimer->setInterval(0); + connect(m_batchTimer, &QTimer::timeout, this, &ConfigObject::emitBatchedChanges); + } + + m_batchTimer->start(); +} + +void ConfigObject::emitBatchedChanges() { + if (m_pendingChanges.isEmpty()) + return; + + auto changes = std::move(m_pendingChanges); + m_pendingChanges.clear(); + emit propertiesChanged(changes); +} + +} // namespace caelestia::config diff --git a/plugin/src/Caelestia/Config/configobject.hpp b/plugin/src/Caelestia/Config/configobject.hpp new file mode 100644 index 000000000..c4f4428b3 --- /dev/null +++ b/plugin/src/Caelestia/Config/configobject.hpp @@ -0,0 +1,143 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace caelestia::config { + +inline QVariantMap vmap(std::initializer_list> entries) { + QVariantMap map; + for (const auto& [key, value] : entries) + map.insert(std::move(key), std::move(value)); + return map; +} + +} // namespace caelestia::config + +// Declares a serialized config property with getter, setter (change-detected), signal, and member. +#define CONFIG_PROPERTY(Type, name, ...) \ + Q_PROPERTY(Type name READ name WRITE set_##name NOTIFY name##Changed) \ + \ +public: \ + [[nodiscard]] Type name() const { \ + return m_##name; \ + } \ + void set_##name(const Type& val) { \ + if (caelestia::config::ConfigObject::updateMember(m_##name, val)) { \ + markPropertyLoaded(QStringLiteral(#name)); \ + Q_EMIT name##Changed(); \ + notifyPropertyChanged(QStringLiteral(#name), QVariant::fromValue(m_##name)); \ + } \ + } \ + Q_SIGNAL void name##Changed(); \ + \ +private: \ + Type m_##name __VA_OPT__(= __VA_ARGS__); + +// Declares a CONSTANT sub-object property. Initialize the member in the constructor. +#define CONFIG_SUBOBJECT(Type, name) \ + Q_PROPERTY(caelestia::config::Type* name READ name CONSTANT) \ + \ +public: \ + [[nodiscard]] Type* name() const { \ + return m_##name; \ + } \ + \ +private: \ + Type* m_##name = nullptr; + +// Like CONFIG_PROPERTY but warns on read/write when accessed on a per-monitor overlay. +#define CONFIG_GLOBAL_PROPERTY(Type, name, ...) \ + Q_PROPERTY(Type name READ name WRITE set_##name NOTIFY name##Changed) \ + \ +public: \ + [[nodiscard]] Type name() const { \ + if (isOverlay()) \ + qCWarning(caelestia::config::lcConfig, "Reading global-only option '%s' on per-monitor overlay", \ + qUtf8Printable(propertyPath(QStringLiteral(#name)))); \ + return m_##name; \ + } \ + void set_##name(const Type& val) { \ + if (isOverlay()) \ + qCWarning(caelestia::config::lcConfig, "Writing global-only option '%s' on per-monitor overlay", \ + qUtf8Printable(propertyPath(QStringLiteral(#name)))); \ + if (caelestia::config::ConfigObject::updateMember(m_##name, val)) { \ + markPropertyLoaded(QStringLiteral(#name)); \ + Q_EMIT name##Changed(); \ + notifyPropertyChanged(QStringLiteral(#name), QVariant::fromValue(m_##name)); \ + } \ + } \ + Q_SIGNAL void name##Changed(); \ + \ +private: \ + Type m_##name __VA_OPT__(= __VA_ARGS__); \ + const bool m_##name##_go = [this] { \ + markGlobalOnly(QStringLiteral(#name)); \ + return true; \ + }(); + +namespace caelestia::config { + +Q_DECLARE_LOGGING_CATEGORY(lcConfig) + +class ConfigObject : public QObject { + Q_OBJECT + +public: + explicit ConfigObject(QObject* parent = nullptr); + + void loadFromJson(const QJsonObject& obj); + [[nodiscard]] QJsonObject toJsonObject() const; + + // Per-monitor overlay support (Qt Resolve Mask pattern). + void syncFromGlobal(ConfigObject* global); + void resyncFromGlobal(); + void clearLoadedKeys(); + + [[nodiscard]] bool isPropertyLoaded(const QString& name) const; + [[nodiscard]] QString propertyPath(const QString& name) const; + [[nodiscard]] bool isOverlay() const; + // Returns true only on overlays — global singleton always returns false. + [[nodiscard]] bool isGlobalOnly(const QString& name) const; + + Q_INVOKABLE void resetOption(const QString& name); + + template static bool updateMember(T& member, const T& value) { + if constexpr (std::is_floating_point_v) { + if (qFuzzyCompare(member + 1.0, value + 1.0)) + return false; + } else { + if (member == value) + return false; + } + member = value; + return true; + } + +signals: + void propertiesChanged(const QMap& changed); + +protected: + void markPropertyLoaded(const QString& name); + void markGlobalOnly(const QString& name); + void notifyPropertyChanged(const QString& name, const QVariant& value); + +private: + void onGlobalPropertiesChanged(const QMap& changed); + void emitBatchedChanges(); + + // Per-monitor overlay state + ConfigObject* m_global = nullptr; + QSet m_loadedKeys; + QSet m_globalOnlyKeys; + QMap m_pendingChanges; + QTimer* m_batchTimer = nullptr; +}; + +} // namespace caelestia::config diff --git a/plugin/src/Caelestia/Config/controlcenterconfig.hpp b/plugin/src/Caelestia/Config/controlcenterconfig.hpp new file mode 100644 index 000000000..80b40b87a --- /dev/null +++ b/plugin/src/Caelestia/Config/controlcenterconfig.hpp @@ -0,0 +1,18 @@ +#pragma once + +#include "configobject.hpp" + +namespace caelestia::config { + +// ControlCenterConfig has no serialized properties (serializer returns {}) +// All properties are in AdvancedConfig.controlCenter +class ControlCenterConfig : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + +public: + explicit ControlCenterConfig(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +} // namespace caelestia::config diff --git a/plugin/src/Caelestia/Config/dashboardconfig.hpp b/plugin/src/Caelestia/Config/dashboardconfig.hpp new file mode 100644 index 000000000..cb872d1dd --- /dev/null +++ b/plugin/src/Caelestia/Config/dashboardconfig.hpp @@ -0,0 +1,44 @@ +#pragma once + +#include "configobject.hpp" + +namespace caelestia::config { + +class DashboardPerformance : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(bool, showBattery, true) + CONFIG_PROPERTY(bool, showGpu, true) + CONFIG_PROPERTY(bool, showCpu, true) + CONFIG_PROPERTY(bool, showMemory, true) + CONFIG_PROPERTY(bool, showStorage, true) + CONFIG_PROPERTY(bool, showNetwork, true) + +public: + explicit DashboardPerformance(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +class DashboardConfig : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(bool, enabled, true) + CONFIG_PROPERTY(bool, showOnHover, true) + CONFIG_PROPERTY(bool, showDashboard, true) + CONFIG_PROPERTY(bool, showMedia, true) + CONFIG_PROPERTY(bool, showPerformance, true) + CONFIG_PROPERTY(bool, showWeather, true) + CONFIG_GLOBAL_PROPERTY(int, mediaUpdateInterval, 500) + CONFIG_GLOBAL_PROPERTY(int, resourceUpdateInterval, 1000) + CONFIG_PROPERTY(int, dragThreshold, 50) + CONFIG_SUBOBJECT(DashboardPerformance, performance) + +public: + explicit DashboardConfig(QObject* parent = nullptr) + : ConfigObject(parent) + , m_performance(new DashboardPerformance(this)) {} +}; + +} // namespace caelestia::config diff --git a/plugin/src/Caelestia/Config/generalconfig.hpp b/plugin/src/Caelestia/Config/generalconfig.hpp new file mode 100644 index 000000000..859302fef --- /dev/null +++ b/plugin/src/Caelestia/Config/generalconfig.hpp @@ -0,0 +1,108 @@ +#pragma once + +#include "configobject.hpp" + +#include +#include +#include + +namespace caelestia::config { + +using Qt::StringLiterals::operator""_s; + +class GeneralApps : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_GLOBAL_PROPERTY(QStringList, terminal, { u"foot"_s }) + CONFIG_GLOBAL_PROPERTY(QStringList, audio, { u"pavucontrol"_s }) + CONFIG_GLOBAL_PROPERTY(QStringList, playback, { u"mpv"_s }) + CONFIG_GLOBAL_PROPERTY(QStringList, explorer, { u"thunar"_s }) + +public: + explicit GeneralApps(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +class GeneralIdle : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_GLOBAL_PROPERTY(bool, lockBeforeSleep, true) + CONFIG_GLOBAL_PROPERTY(bool, inhibitWhenAudio, true) + CONFIG_GLOBAL_PROPERTY(QVariantList, timeouts, + { + vmap({ + { u"timeout"_s, 180 }, + { u"idleAction"_s, u"lock"_s }, + }), + vmap({ + { u"timeout"_s, 300 }, + { u"idleAction"_s, u"dpms off"_s }, + { u"returnAction"_s, u"dpms on"_s }, + }), + vmap({ + { u"timeout"_s, 600 }, + { u"idleAction"_s, QStringList{ u"systemctl"_s, u"suspend-then-hibernate"_s } }, + }), + }) + +public: + explicit GeneralIdle(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +class GeneralBattery : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_GLOBAL_PROPERTY(QVariantList, warnLevels, + { + vmap({ + { u"level"_s, 20 }, + { u"title"_s, u"Low battery"_s }, + { u"message"_s, u"You might want to plug in a charger"_s }, + { u"icon"_s, u"battery_android_frame_2"_s }, + }), + vmap({ + { u"level"_s, 10 }, + { u"title"_s, u"Did you see the previous message?"_s }, + { u"message"_s, u"You should probably plug in a charger now"_s }, + { u"icon"_s, u"battery_android_frame_1"_s }, + }), + vmap({ + { u"level"_s, 5 }, + { u"title"_s, u"Critical battery level"_s }, + { u"message"_s, u"PLUG THE CHARGER RIGHT NOW!!"_s }, + { u"icon"_s, u"battery_android_alert"_s }, + { u"critical"_s, true }, + }), + }) + CONFIG_GLOBAL_PROPERTY(int, criticalLevel, 3) + +public: + explicit GeneralBattery(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +class GeneralConfig : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_GLOBAL_PROPERTY(QString, logo) + CONFIG_PROPERTY(bool, showOverFullscreen, false) + CONFIG_PROPERTY(qreal, mediaGifSpeedAdjustment, 300) + CONFIG_PROPERTY(qreal, sessionGifSpeed, 0.7) + CONFIG_SUBOBJECT(GeneralApps, apps) + CONFIG_SUBOBJECT(GeneralIdle, idle) + CONFIG_SUBOBJECT(GeneralBattery, battery) + +public: + explicit GeneralConfig(QObject* parent = nullptr) + : ConfigObject(parent) + , m_apps(new GeneralApps(this)) + , m_idle(new GeneralIdle(this)) + , m_battery(new GeneralBattery(this)) {} +}; + +} // namespace caelestia::config diff --git a/plugin/src/Caelestia/Config/launcherconfig.hpp b/plugin/src/Caelestia/Config/launcherconfig.hpp new file mode 100644 index 000000000..049e213ae --- /dev/null +++ b/plugin/src/Caelestia/Config/launcherconfig.hpp @@ -0,0 +1,135 @@ +#pragma once + +#include "configobject.hpp" + +#include +#include +#include + +namespace caelestia::config { + +using Qt::StringLiterals::operator""_s; + +class LauncherUseFuzzy : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_GLOBAL_PROPERTY(bool, apps, false) + CONFIG_GLOBAL_PROPERTY(bool, actions, false) + CONFIG_GLOBAL_PROPERTY(bool, schemes, false) + CONFIG_GLOBAL_PROPERTY(bool, variants, false) + CONFIG_GLOBAL_PROPERTY(bool, wallpapers, false) + +public: + explicit LauncherUseFuzzy(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +class LauncherConfig : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(bool, enabled, true) + CONFIG_PROPERTY(bool, showOnHover, false) + CONFIG_PROPERTY(int, maxShown, 7) + CONFIG_PROPERTY(int, maxWallpapers, 9) + CONFIG_GLOBAL_PROPERTY(QString, specialPrefix, u"@"_s) + CONFIG_GLOBAL_PROPERTY(QString, actionPrefix, u">"_s) + CONFIG_GLOBAL_PROPERTY(bool, enableDangerousActions, false) + CONFIG_PROPERTY(int, dragThreshold, 50) + CONFIG_GLOBAL_PROPERTY(bool, vimKeybinds, false) + CONFIG_GLOBAL_PROPERTY(QStringList, favouriteApps) + CONFIG_GLOBAL_PROPERTY(QStringList, hiddenApps) + CONFIG_SUBOBJECT(LauncherUseFuzzy, useFuzzy) + CONFIG_GLOBAL_PROPERTY(QVariantList, actions, + { + vmap({ + { u"name"_s, u"Calculator"_s }, + { u"icon"_s, u"calculate"_s }, + { u"description"_s, u"Do simple math equations (powered by Qalc)"_s }, + { u"command"_s, QStringList{ u"autocomplete"_s, u"calc"_s } }, + }), + vmap({ + { u"name"_s, u"Scheme"_s }, + { u"icon"_s, u"palette"_s }, + { u"description"_s, u"Change the current colour scheme"_s }, + { u"command"_s, QStringList{ u"autocomplete"_s, u"scheme"_s } }, + }), + vmap({ + { u"name"_s, u"Wallpaper"_s }, + { u"icon"_s, u"image"_s }, + { u"description"_s, u"Change the current wallpaper"_s }, + { u"command"_s, QStringList{ u"autocomplete"_s, u"wallpaper"_s } }, + }), + vmap({ + { u"name"_s, u"Variant"_s }, + { u"icon"_s, u"colors"_s }, + { u"description"_s, u"Change the current scheme variant"_s }, + { u"command"_s, QStringList{ u"autocomplete"_s, u"variant"_s } }, + }), + vmap({ + { u"name"_s, u"Random"_s }, + { u"icon"_s, u"casino"_s }, + { u"description"_s, u"Switch to a random wallpaper"_s }, + { u"command"_s, QStringList{ u"caelestia"_s, u"wallpaper"_s, u"-r"_s } }, + }), + vmap({ + { u"name"_s, u"Light"_s }, + { u"icon"_s, u"light_mode"_s }, + { u"description"_s, u"Change the scheme to light mode"_s }, + { u"command"_s, QStringList{ u"setMode"_s, u"light"_s } }, + }), + vmap({ + { u"name"_s, u"Dark"_s }, + { u"icon"_s, u"dark_mode"_s }, + { u"description"_s, u"Change the scheme to dark mode"_s }, + { u"command"_s, QStringList{ u"setMode"_s, u"dark"_s } }, + }), + vmap({ + { u"name"_s, u"Shutdown"_s }, + { u"icon"_s, u"power_settings_new"_s }, + { u"description"_s, u"Shutdown the system"_s }, + { u"command"_s, QStringList{ u"systemctl"_s, u"poweroff"_s } }, + { u"dangerous"_s, true }, + }), + vmap({ + { u"name"_s, u"Reboot"_s }, + { u"icon"_s, u"cached"_s }, + { u"description"_s, u"Reboot the system"_s }, + { u"command"_s, QStringList{ u"systemctl"_s, u"reboot"_s } }, + { u"dangerous"_s, true }, + }), + vmap({ + { u"name"_s, u"Logout"_s }, + { u"icon"_s, u"exit_to_app"_s }, + { u"description"_s, u"Log out of the current session"_s }, + { u"command"_s, QStringList{ u"loginctl"_s, u"terminate-user"_s, u""_s } }, + { u"dangerous"_s, true }, + }), + vmap({ + { u"name"_s, u"Lock"_s }, + { u"icon"_s, u"lock"_s }, + { u"description"_s, u"Lock the current session"_s }, + { u"command"_s, QStringList{ u"loginctl"_s, u"lock-session"_s } }, + }), + vmap({ + { u"name"_s, u"Sleep"_s }, + { u"icon"_s, u"bedtime"_s }, + { u"description"_s, u"Suspend then hibernate"_s }, + { u"command"_s, QStringList{ u"systemctl"_s, u"suspend-then-hibernate"_s } }, + }), + vmap({ + { u"name"_s, u"Settings"_s }, + { u"icon"_s, u"settings"_s }, + { u"description"_s, u"Configure the shell"_s }, + { u"command"_s, QStringList{ u"caelestia"_s, u"shell"_s, u"controlCenter"_s, u"open"_s } }, + }), + }) + +public: + explicit LauncherConfig(QObject* parent = nullptr) + : ConfigObject(parent) + , m_useFuzzy(new LauncherUseFuzzy(this)) {} +}; + +} // namespace caelestia::config diff --git a/plugin/src/Caelestia/Config/lockconfig.hpp b/plugin/src/Caelestia/Config/lockconfig.hpp new file mode 100644 index 000000000..0d8aa6ba7 --- /dev/null +++ b/plugin/src/Caelestia/Config/lockconfig.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include "configobject.hpp" + +namespace caelestia::config { + +class LockConfig : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(bool, recolourLogo, false) + CONFIG_GLOBAL_PROPERTY(bool, enableFprint, true) + CONFIG_GLOBAL_PROPERTY(int, maxFprintTries, 3) + CONFIG_PROPERTY(bool, hideNotifs, false) + +public: + explicit LockConfig(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +} // namespace caelestia::config diff --git a/plugin/src/Caelestia/Config/monitorconfigmanager.cpp b/plugin/src/Caelestia/Config/monitorconfigmanager.cpp new file mode 100644 index 000000000..fb1745ec6 --- /dev/null +++ b/plugin/src/Caelestia/Config/monitorconfigmanager.cpp @@ -0,0 +1,64 @@ +#include "monitorconfigmanager.hpp" +#include "config.hpp" +#include "tokens.hpp" + +#include + +namespace caelestia::config { + +namespace { + +QString monitorConfigDir(const QString& screen) { + return QStandardPaths::writableLocation(QStandardPaths::GenericConfigLocation) + + QStringLiteral("/caelestia/monitors/") + screen + QStringLiteral("/"); +} + +} // namespace + +MonitorConfigManager::MonitorConfigManager(QObject* parent) + : QObject(parent) {} + +MonitorConfigManager* MonitorConfigManager::instance() { + static MonitorConfigManager instance; + return &instance; +} + +MonitorConfigManager* MonitorConfigManager::create(QQmlEngine*, QJSEngine*) { + QQmlEngine::setObjectOwnership(instance(), QQmlEngine::CppOwnership); + return instance(); +} + +GlobalConfig* MonitorConfigManager::configForScreen(const QString& screen) { + auto& overlay = m_overlays[screen]; + if (!overlay.config) { + auto dir = monitorConfigDir(screen); + overlay.config = new GlobalConfig(GlobalConfig::instance(), dir + QStringLiteral("shell.json"), screen, this); + + auto* const global = GlobalConfig::instance(); + connect(overlay.config, &GlobalConfig::loaded, global, &GlobalConfig::loaded); + connect(overlay.config, &GlobalConfig::saved, global, &GlobalConfig::saved); + connect(overlay.config, &GlobalConfig::loadFailed, global, &GlobalConfig::loadFailed); + connect(overlay.config, &GlobalConfig::saveFailed, global, &GlobalConfig::saveFailed); + connect(overlay.config, &GlobalConfig::unknownOption, global, &GlobalConfig::unknownOption); + } + return overlay.config; +} + +TokenConfig* MonitorConfigManager::tokensForScreen(const QString& screen) { + auto& overlay = m_overlays[screen]; + if (!overlay.tokens) { + auto dir = monitorConfigDir(screen); + overlay.tokens = + new TokenConfig(TokenConfig::instance(), dir + QStringLiteral("shell-tokens.json"), screen, this); + + auto* const global = TokenConfig::instance(); + connect(overlay.tokens, &TokenConfig::loaded, global, &TokenConfig::loaded); + connect(overlay.tokens, &TokenConfig::saved, global, &TokenConfig::saved); + connect(overlay.tokens, &TokenConfig::loadFailed, global, &TokenConfig::loadFailed); + connect(overlay.tokens, &TokenConfig::saveFailed, global, &TokenConfig::saveFailed); + connect(overlay.tokens, &TokenConfig::unknownOption, global, &TokenConfig::unknownOption); + } + return overlay.tokens; +} + +} // namespace caelestia::config diff --git a/plugin/src/Caelestia/Config/monitorconfigmanager.hpp b/plugin/src/Caelestia/Config/monitorconfigmanager.hpp new file mode 100644 index 000000000..70bbdc0d3 --- /dev/null +++ b/plugin/src/Caelestia/Config/monitorconfigmanager.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include + +namespace caelestia::config { + +class GlobalConfig; +class TokenConfig; + +class MonitorConfigManager : public QObject { + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + +public: + static MonitorConfigManager* instance(); + static MonitorConfigManager* create(QQmlEngine*, QJSEngine*); + + [[nodiscard]] Q_INVOKABLE GlobalConfig* configForScreen(const QString& screen); + [[nodiscard]] Q_INVOKABLE TokenConfig* tokensForScreen(const QString& screen); + +private: + explicit MonitorConfigManager(QObject* parent = nullptr); + + struct ScreenOverlay { + GlobalConfig* config = nullptr; + TokenConfig* tokens = nullptr; + }; + + QHash m_overlays; +}; + +} // namespace caelestia::config diff --git a/plugin/src/Caelestia/Config/notifsconfig.hpp b/plugin/src/Caelestia/Config/notifsconfig.hpp new file mode 100644 index 000000000..cdac94bdf --- /dev/null +++ b/plugin/src/Caelestia/Config/notifsconfig.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include "configobject.hpp" + +#include + +namespace caelestia::config { + +class NotifsConfig : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_GLOBAL_PROPERTY(bool, expire, true) + CONFIG_GLOBAL_PROPERTY(QString, fullscreen, QStringLiteral("on")) + CONFIG_GLOBAL_PROPERTY(int, defaultExpireTimeout, 5000) + CONFIG_GLOBAL_PROPERTY(int, fullscreenExpireTimeout, 2000) + CONFIG_PROPERTY(qreal, clearThreshold, 0.3) + CONFIG_PROPERTY(int, expandThreshold, 20) + CONFIG_GLOBAL_PROPERTY(bool, actionOnClick, false) + CONFIG_PROPERTY(int, groupPreviewNum, 3) + CONFIG_PROPERTY(bool, openExpanded, false) + +public: + explicit NotifsConfig(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +} // namespace caelestia::config diff --git a/plugin/src/Caelestia/Config/osdconfig.hpp b/plugin/src/Caelestia/Config/osdconfig.hpp new file mode 100644 index 000000000..18770294f --- /dev/null +++ b/plugin/src/Caelestia/Config/osdconfig.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include "configobject.hpp" + +namespace caelestia::config { + +class OsdConfig : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(bool, enabled, true) + CONFIG_PROPERTY(int, hideDelay, 2000) + CONFIG_PROPERTY(bool, enableBrightness, true) + CONFIG_PROPERTY(bool, enableMicrophone, false) + +public: + explicit OsdConfig(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +} // namespace caelestia::config diff --git a/plugin/src/Caelestia/Config/rootconfig.cpp b/plugin/src/Caelestia/Config/rootconfig.cpp new file mode 100644 index 000000000..9e976ab2a --- /dev/null +++ b/plugin/src/Caelestia/Config/rootconfig.cpp @@ -0,0 +1,258 @@ +#include "rootconfig.hpp" + +#include +#include +#include +#include +#include +#include + +namespace caelestia::config { + +namespace { + +QString watchRoot() { + return QStandardPaths::writableLocation(QStandardPaths::GenericConfigLocation); +} + +} // namespace + +RootConfig::RootConfig(QObject* parent) + : ConfigObject(parent) {} + +bool RootConfig::recentlySaved() const { + return m_recentlySaved; +} + +QStringList RootConfig::collectUnknownKeys(const ConfigObject* obj, const QJsonObject& json) { + QStringList unknown; + const auto* meta = obj->metaObject(); + + QSet known; + for (int i = meta->propertyOffset(); i < meta->propertyCount(); ++i) + known.insert(QString::fromUtf8(meta->property(i).name())); + + for (auto it = json.begin(); it != json.end(); ++it) { + if (!known.contains(it.key())) { + unknown.append(it.key()); + } else if (it.value().isObject()) { + int idx = meta->indexOfProperty(it.key().toUtf8().constData()); + if (idx >= 0) { + auto prop = meta->property(idx); + auto value = prop.read(obj); + auto* subObj = value.value(); + if (subObj) { + const auto subUnknown = collectUnknownKeys(subObj, it.value().toObject()); + for (const auto& subKey : subUnknown) + unknown.append(it.key() + QStringLiteral(".") + subKey); + } + } + } + } + + return unknown; +} + +void RootConfig::setupFileBackend(const QString& path, const QString& screen) { + m_filePath = path; + m_screen = screen; + + m_watcher = new QFileSystemWatcher(this); + m_saveTimer = new QTimer(this); + m_cooldownTimer = new QTimer(this); + m_retryTimer = new QTimer(this); + + m_retryTimer->setSingleShot(true); + m_retryTimer->setInterval(50); + connect(m_retryTimer, &QTimer::timeout, this, &RootConfig::reload); + + m_saveTimer->setSingleShot(true); + m_saveTimer->setInterval(500); + connect(m_saveTimer, &QTimer::timeout, this, [this] { + QDir().mkpath(QFileInfo(m_filePath).absolutePath()); + + QFile file(m_filePath); + if (!file.open(QIODevice::WriteOnly)) { + auto err = QStringLiteral("Failed to write %1: %2").arg(m_filePath, file.errorString()); + qCWarning(lcConfig, "%s", qUtf8Printable(err)); + emit saveFailed(err, m_screen); + return; + } + + auto json = toJsonObject(); + file.write(QJsonDocument(json).toJson(QJsonDocument::Indented)); + file.close(); + + // Update watches — save may have created directories + updateWatch(); + + emit saved(m_screen); + }); + + m_cooldownTimer->setSingleShot(true); + m_cooldownTimer->setInterval(2000); + connect(m_cooldownTimer, &QTimer::timeout, this, [this] { + m_recentlySaved = false; + }); + + m_reloadDebounce = new QTimer(this); + m_reloadDebounce->setSingleShot(true); + m_reloadDebounce->setInterval(50); + connect(m_reloadDebounce, &QTimer::timeout, this, &RootConfig::reload); + + // Auto-save when any property changes (debounced by the save timer) + connectAutoSave(this); + + connect(m_watcher, &QFileSystemWatcher::directoryChanged, this, &RootConfig::onWatcherEvent); + connect(m_watcher, &QFileSystemWatcher::fileChanged, this, &RootConfig::onWatcherEvent); + + qCDebug(lcConfig) << "Setting up file backend for" << metaObject()->className() << "at" << path; + + updateWatch(); + + // Load immediately so values are available during construction. + // Defer signal emissions to next event loop tick so QML has time to connect. + auto result = reloadFromFile(); + QTimer::singleShot(0, this, [this, result] { + emitLoadSignals(result, false); + }); +} + +void RootConfig::connectAutoSave(ConfigObject* obj) { + connect(obj, &ConfigObject::propertiesChanged, this, [this] { + if (!m_loading) + saveToFile(); + }); + + // Recurse into sub-objects + const auto* meta = obj->metaObject(); + for (int i = meta->propertyOffset(); i < meta->propertyCount(); ++i) { + auto prop = meta->property(i); + auto value = prop.read(obj); + auto* subObj = value.value(); + if (subObj) + connectAutoSave(subObj); + } +} + +void RootConfig::updateWatch() { + auto targetDir = QFileInfo(m_filePath).absolutePath(); + + // Find the nearest existing directory, walking up toward the watch root + auto dir = targetDir; + while (!QFile::exists(dir) && dir != watchRoot() && !dir.isEmpty()) { + auto parent = QFileInfo(dir).absolutePath(); + if (parent == dir) + break; // reached filesystem root + dir = parent; + } + + // Update directory watch if it changed + if (dir != m_watchedDir) { + if (!m_watchedDir.isEmpty()) + m_watcher->removePath(m_watchedDir); + + m_watchedDir = dir; + + if (QFile::exists(dir)) + m_watcher->addPath(dir); + } + + // Watch the file itself if it exists (for in-place modifications) + if (QFile::exists(m_filePath)) { + if (!m_watcher->files().contains(m_filePath)) + m_watcher->addPath(m_filePath); + } +} + +void RootConfig::onWatcherEvent() { + // Re-evaluate what to watch — directories may have been created or deleted + updateWatch(); + + if (!m_recentlySaved) + m_reloadDebounce->start(); +} + +void RootConfig::saveToFile() { + if (!m_saveTimer) + return; + m_saveTimer->start(); + m_recentlySaved = true; + m_cooldownTimer->start(); +} + +std::optional RootConfig::reloadFromFile() { + QFile file(m_filePath); + + if (!file.exists()) { + qCDebug(lcConfig) << "File does not exist:" << m_filePath; + return std::nullopt; + } + + if (!file.open(QIODevice::ReadOnly)) { + auto err = QStringLiteral("Failed to open %1: %2").arg(m_filePath, file.errorString()); + qCDebug(lcConfig, "%s", qUtf8Printable(err)); + return err; + } + + QJsonParseError error{}; + auto doc = QJsonDocument::fromJson(file.readAll(), &error); + + if (error.error != QJsonParseError::NoError) { + if (m_retryTimer && m_parseRetries < 3) { + m_parseRetries++; + qCDebug(lcConfig, "Failed to parse %s: %s - retrying (%d/3)", qUtf8Printable(m_filePath), + qUtf8Printable(error.errorString()), m_parseRetries); + m_retryTimer->start(); + return std::nullopt; // pending retry — no signal + } + + qCWarning(lcConfig, "Failed to parse %s: %s", qUtf8Printable(m_filePath), qUtf8Printable(error.errorString())); + m_parseRetries = 0; + return QStringLiteral("JSON parse error: %1").arg(error.errorString()); + } + + m_parseRetries = 0; + + qCDebug(lcConfig) << "Reloading" << metaObject()->className() << "from" << m_filePath; + + m_loading = true; + + clearLoadedKeys(); + + auto jsonObj = doc.object(); + loadFromJson(jsonObj); + + m_loading = false; + + // Collect unknown keys — caller is responsible for emitting signals + m_lastUnknownKeys = collectUnknownKeys(this, jsonObj); + + return QString(); // success +} + +void RootConfig::save() { + saveToFile(); +} + +void RootConfig::emitLoadSignals(const std::optional& result, bool emitLoaded) { + if (!result.has_value()) + return; + + for (const auto& key : std::as_const(m_lastUnknownKeys)) + emit unknownOption(key, m_screen); + m_lastUnknownKeys.clear(); + + if (result->isEmpty()) { + if (emitLoaded) + emit loaded(m_screen); + } else { + emit loadFailed(*result, m_screen); + } +} + +void RootConfig::reload() { + emitLoadSignals(reloadFromFile()); +} + +} // namespace caelestia::config diff --git a/plugin/src/Caelestia/Config/rootconfig.hpp b/plugin/src/Caelestia/Config/rootconfig.hpp new file mode 100644 index 000000000..bf9b8e2c3 --- /dev/null +++ b/plugin/src/Caelestia/Config/rootconfig.hpp @@ -0,0 +1,59 @@ +#pragma once + +#include "configobject.hpp" + +#include +#include +#include + +namespace caelestia::config { + +// Intermediate base for singleton config roots (GlobalConfig, TokenConfig). +// Provides file-backed persistence, save/reload, and lifecycle signals. +class RootConfig : public ConfigObject { + Q_OBJECT + +public: + explicit RootConfig(QObject* parent = nullptr); + + void setupFileBackend(const QString& path, const QString& screen = {}); + void saveToFile(); + // Returns nullopt if retrying, empty string on success, error message on failure. + [[nodiscard]] std::optional reloadFromFile(); + + [[nodiscard]] bool recentlySaved() const; + + Q_INVOKABLE void save(); + Q_INVOKABLE void reload(); + +signals: + void loaded(const QString& screen); + void loadFailed(const QString& error, const QString& screen); + void saved(const QString& screen); + void saveFailed(const QString& error, const QString& screen); + void unknownOption(const QString& key, const QString& screen); + +private: + static QStringList collectUnknownKeys(const ConfigObject* obj, const QJsonObject& json); + void emitLoadSignals(const std::optional& result, bool emitLoaded = true); + void updateWatch(); + void onWatcherEvent(); + + void connectAutoSave(ConfigObject* obj); + + QString m_filePath; + QString m_screen; + QString m_watchedDir; + bool m_recentlySaved = false; + bool m_loading = false; + + QFileSystemWatcher* m_watcher = nullptr; + QTimer* m_saveTimer = nullptr; + QTimer* m_cooldownTimer = nullptr; + QTimer* m_retryTimer = nullptr; + QTimer* m_reloadDebounce = nullptr; + int m_parseRetries = 0; + QStringList m_lastUnknownKeys; +}; + +} // namespace caelestia::config diff --git a/plugin/src/Caelestia/Config/serviceconfig.hpp b/plugin/src/Caelestia/Config/serviceconfig.hpp new file mode 100644 index 000000000..b97c69c1e --- /dev/null +++ b/plugin/src/Caelestia/Config/serviceconfig.hpp @@ -0,0 +1,43 @@ +#pragma once + +#include "configobject.hpp" + +#include +#include + +namespace caelestia::config { + +using Qt::StringLiterals::operator""_s; + +class ServiceConfig : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_GLOBAL_PROPERTY(QString, weatherLocation) + // Guess based on locale + CONFIG_GLOBAL_PROPERTY(bool, useFahrenheit, + QLocale().measurementSystem() == QLocale::ImperialUSSystem || + QLocale().measurementSystem() == QLocale::ImperialUKSystem) + // This is always false by default cause apparently even imperial system users don't use it for perf temps? + CONFIG_GLOBAL_PROPERTY(bool, useFahrenheitPerformance, false) + // Attempt to guess based on locale + CONFIG_GLOBAL_PROPERTY( + bool, useTwelveHourClock, QLocale().timeFormat(QLocale::ShortFormat).toLower().contains(u"a"_s)) + CONFIG_GLOBAL_PROPERTY(QString, gpuType) + CONFIG_GLOBAL_PROPERTY(int, visualiserBars, 45) + CONFIG_GLOBAL_PROPERTY(qreal, audioIncrement, 0.1) + CONFIG_GLOBAL_PROPERTY(qreal, brightnessIncrement, 0.1) + CONFIG_GLOBAL_PROPERTY(qreal, maxVolume, 1.0) + CONFIG_GLOBAL_PROPERTY(bool, smartScheme, true) + CONFIG_GLOBAL_PROPERTY(QString, defaultPlayer, u"Spotify"_s) + CONFIG_GLOBAL_PROPERTY(QVariantList, playerAliases, + { vmap({ { u"from"_s, u"com.github.th_ch.youtube_music"_s }, { u"to"_s, u"YT Music"_s } }) }) + CONFIG_GLOBAL_PROPERTY(bool, showLyrics, false) + CONFIG_GLOBAL_PROPERTY(QString, lyricsBackend, u"Auto"_s) + +public: + explicit ServiceConfig(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +} // namespace caelestia::config diff --git a/plugin/src/Caelestia/Config/sessionconfig.hpp b/plugin/src/Caelestia/Config/sessionconfig.hpp new file mode 100644 index 000000000..7e7548f1b --- /dev/null +++ b/plugin/src/Caelestia/Config/sessionconfig.hpp @@ -0,0 +1,57 @@ +#pragma once + +#include "configobject.hpp" + +#include +#include + +namespace caelestia::config { + +using Qt::StringLiterals::operator""_s; + +class SessionIcons : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(QString, logout, u"logout"_s) + CONFIG_PROPERTY(QString, shutdown, u"power_settings_new"_s) + CONFIG_PROPERTY(QString, hibernate, u"downloading"_s) + CONFIG_PROPERTY(QString, reboot, u"cached"_s) + +public: + explicit SessionIcons(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +class SessionCommands : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(QStringList, logout, { u"loginctl"_s, u"terminate-user"_s, u""_s }) + CONFIG_PROPERTY(QStringList, shutdown, { u"systemctl"_s, u"poweroff"_s }) + CONFIG_PROPERTY(QStringList, hibernate, { u"systemctl"_s, u"hibernate"_s }) + CONFIG_PROPERTY(QStringList, reboot, { u"systemctl"_s, u"reboot"_s }) + +public: + explicit SessionCommands(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +class SessionConfig : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(bool, enabled, true) + CONFIG_PROPERTY(int, dragThreshold, 30) + CONFIG_PROPERTY(bool, vimKeybinds, false) + CONFIG_SUBOBJECT(SessionIcons, icons) + CONFIG_SUBOBJECT(SessionCommands, commands) + +public: + explicit SessionConfig(QObject* parent = nullptr) + : ConfigObject(parent) + , m_icons(new SessionIcons(this)) + , m_commands(new SessionCommands(this)) {} +}; + +} // namespace caelestia::config diff --git a/plugin/src/Caelestia/Config/sidebarconfig.hpp b/plugin/src/Caelestia/Config/sidebarconfig.hpp new file mode 100644 index 000000000..4460872e4 --- /dev/null +++ b/plugin/src/Caelestia/Config/sidebarconfig.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include "configobject.hpp" + +namespace caelestia::config { + +class SidebarConfig : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(bool, enabled, true) + CONFIG_PROPERTY(int, dragThreshold, 80) + +public: + explicit SidebarConfig(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +} // namespace caelestia::config diff --git a/plugin/src/Caelestia/Config/tokens.cpp b/plugin/src/Caelestia/Config/tokens.cpp new file mode 100644 index 000000000..6d171cb35 --- /dev/null +++ b/plugin/src/Caelestia/Config/tokens.cpp @@ -0,0 +1,54 @@ +#include "tokens.hpp" +#include "monitorconfigmanager.hpp" + +#include +#include + +namespace caelestia::config { + +namespace { + +QString configDir() { + return QStandardPaths::writableLocation(QStandardPaths::GenericConfigLocation) + QStringLiteral("/caelestia/"); +} + +} // namespace + +TokenConfig::TokenConfig(QObject* parent) + : RootConfig(parent) + , m_appearance(new AppearanceTokens(this)) + , m_sizes(new SizeTokens(this)) { + setupFileBackend(configDir() + QStringLiteral("shell-tokens.json")); +} + +TokenConfig::TokenConfig(TokenConfig* fallback, const QString& filePath, const QString& screen, QObject* parent) + : RootConfig(parent) + , m_appearance(new AppearanceTokens(this)) + , m_sizes(new SizeTokens(this)) { + if (!filePath.isEmpty()) + setupFileBackend(filePath, screen); + if (fallback) + syncFromGlobal(fallback); +} + +TokenConfig* TokenConfig::instance() { + static TokenConfig instance; + return &instance; +} + +TokenConfig* TokenConfig::defaults() { + if (!m_defaults) + m_defaults = new TokenConfig(nullptr, QString(), QString(), this); + return m_defaults; +} + +TokenConfig* TokenConfig::forScreen(const QString& screen) { + return MonitorConfigManager::instance()->tokensForScreen(screen); +} + +TokenConfig* TokenConfig::create(QQmlEngine*, QJSEngine*) { + QQmlEngine::setObjectOwnership(instance(), QQmlEngine::CppOwnership); + return instance(); +} + +} // namespace caelestia::config diff --git a/plugin/src/Caelestia/Config/tokens.hpp b/plugin/src/Caelestia/Config/tokens.hpp new file mode 100644 index 000000000..28bac1f1a --- /dev/null +++ b/plugin/src/Caelestia/Config/tokens.hpp @@ -0,0 +1,351 @@ +#pragma once + +#include "rootconfig.hpp" + +#include +#include + +namespace caelestia::config { + +class AnimCurves : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_GLOBAL_PROPERTY(QList, emphasized) + CONFIG_GLOBAL_PROPERTY(QList, emphasizedAccel) + CONFIG_GLOBAL_PROPERTY(QList, emphasizedDecel) + CONFIG_GLOBAL_PROPERTY(QList, standard) + CONFIG_GLOBAL_PROPERTY(QList, standardAccel) + CONFIG_GLOBAL_PROPERTY(QList, standardDecel) + CONFIG_GLOBAL_PROPERTY(QList, expressiveFastSpatial) + CONFIG_GLOBAL_PROPERTY(QList, expressiveDefaultSpatial) + CONFIG_GLOBAL_PROPERTY(QList, expressiveSlowSpatial) + CONFIG_GLOBAL_PROPERTY(QList, expressiveFastEffects) + CONFIG_GLOBAL_PROPERTY(QList, expressiveDefaultEffects) + CONFIG_GLOBAL_PROPERTY(QList, expressiveSlowEffects) + +public: + explicit AnimCurves(QObject* parent = nullptr) + : ConfigObject(parent) + , m_emphasized({ 0.05, 0, 2.0 / 15.0, 0.06, 1.0 / 6.0, 0.4, 5.0 / 24.0, 0.82, 0.25, 1, 1, 1 }) + , m_emphasizedAccel({ 0.3, 0, 0.8, 0.15, 1, 1 }) + , m_emphasizedDecel({ 0.05, 0.7, 0.1, 1, 1, 1 }) + , m_standard({ 0.2, 0, 0, 1, 1, 1 }) + , m_standardAccel({ 0.3, 0, 1, 1, 1, 1 }) + , m_standardDecel({ 0, 0, 0, 1, 1, 1 }) + , m_expressiveFastSpatial({ 0.42, 1.67, 0.21, 0.9, 1, 1 }) + , m_expressiveDefaultSpatial({ 0.38, 1.21, 0.22, 1, 1, 1 }) + , m_expressiveSlowSpatial({ 0.39, 1.29, 0.35, 0.98, 1, 1 }) + , m_expressiveFastEffects({ 0.31, 0.94, 0.34, 1, 1, 1 }) + , m_expressiveDefaultEffects({ 0.34, 0.8, 0.34, 1, 1, 1 }) + , m_expressiveSlowEffects({ 0.34, 0.88, 0.34, 1, 1, 1 }) {} +}; + +class RoundingTokens : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(int, extraSmall, 4) + CONFIG_PROPERTY(int, small, 12) + CONFIG_PROPERTY(int, normal, 17) + CONFIG_PROPERTY(int, large, 25) + CONFIG_PROPERTY(int, full, 1000) + +public: + explicit RoundingTokens(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +class SpacingTokens : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(int, small, 7) + CONFIG_PROPERTY(int, smaller, 10) + CONFIG_PROPERTY(int, normal, 12) + CONFIG_PROPERTY(int, larger, 15) + CONFIG_PROPERTY(int, large, 20) + +public: + explicit SpacingTokens(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +class PaddingTokens : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(int, small, 5) + CONFIG_PROPERTY(int, smaller, 7) + CONFIG_PROPERTY(int, normal, 10) + CONFIG_PROPERTY(int, larger, 12) + CONFIG_PROPERTY(int, large, 15) + +public: + explicit PaddingTokens(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +class FontSizeTokens : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(int, small, 11) + CONFIG_PROPERTY(int, smaller, 12) + CONFIG_PROPERTY(int, normal, 13) + CONFIG_PROPERTY(int, larger, 15) + CONFIG_PROPERTY(int, large, 18) + CONFIG_PROPERTY(int, extraLarge, 28) + +public: + explicit FontSizeTokens(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +class AnimDurationTokens : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_GLOBAL_PROPERTY(int, small, 200) + CONFIG_GLOBAL_PROPERTY(int, normal, 400) + CONFIG_GLOBAL_PROPERTY(int, large, 600) + CONFIG_GLOBAL_PROPERTY(int, extraLarge, 1000) + CONFIG_GLOBAL_PROPERTY(int, expressiveFastSpatial, 350) + CONFIG_GLOBAL_PROPERTY(int, expressiveDefaultSpatial, 500) + CONFIG_GLOBAL_PROPERTY(int, expressiveSlowSpatial, 650) + CONFIG_GLOBAL_PROPERTY(int, expressiveFastEffects, 150) + CONFIG_GLOBAL_PROPERTY(int, expressiveDefaultEffects, 200) + CONFIG_GLOBAL_PROPERTY(int, expressiveSlowEffects, 300) + +public: + explicit AnimDurationTokens(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +class AppearanceTokens : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_SUBOBJECT(AnimCurves, curves) + CONFIG_SUBOBJECT(RoundingTokens, rounding) + CONFIG_SUBOBJECT(SpacingTokens, spacing) + CONFIG_SUBOBJECT(PaddingTokens, padding) + CONFIG_SUBOBJECT(FontSizeTokens, fontSize) + CONFIG_SUBOBJECT(AnimDurationTokens, animDurations) + +public: + explicit AppearanceTokens(QObject* parent = nullptr) + : ConfigObject(parent) + , m_curves(new AnimCurves(this)) + , m_rounding(new RoundingTokens(this)) + , m_spacing(new SpacingTokens(this)) + , m_padding(new PaddingTokens(this)) + , m_fontSize(new FontSizeTokens(this)) + , m_animDurations(new AnimDurationTokens(this)) {} +}; + +class BarTokens : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(int, innerWidth, 40) + CONFIG_PROPERTY(int, windowPreviewSize, 400) + CONFIG_PROPERTY(int, trayMenuWidth, 300) + CONFIG_PROPERTY(int, batteryWidth, 250) + CONFIG_PROPERTY(int, networkWidth, 320) + CONFIG_PROPERTY(int, kbLayoutWidth, 320) + +public: + explicit BarTokens(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +class DashboardTokens : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(int, tabIndicatorHeight, 3) + CONFIG_PROPERTY(int, tabIndicatorSpacing, 5) + CONFIG_PROPERTY(int, infoWidth, 200) + CONFIG_PROPERTY(int, infoIconSize, 25) + CONFIG_PROPERTY(int, dateTimeWidth, 110) + CONFIG_PROPERTY(int, mediaWidth, 200) + CONFIG_PROPERTY(int, mediaProgressSweep, 180) + CONFIG_PROPERTY(int, mediaProgressThickness, 8) + CONFIG_PROPERTY(int, resourceProgressThickness, 10) + CONFIG_PROPERTY(int, weatherWidth, 250) + CONFIG_PROPERTY(int, mediaCoverArtSize, 150) + CONFIG_PROPERTY(int, mediaVisualiserSize, 80) + CONFIG_PROPERTY(int, resourceSize, 200) + +public: + explicit DashboardTokens(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +class LauncherTokens : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(int, itemWidth, 600) + CONFIG_PROPERTY(int, itemHeight, 57) + CONFIG_PROPERTY(int, wallpaperWidth, 280) + CONFIG_PROPERTY(int, wallpaperHeight, 200) + +public: + explicit LauncherTokens(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +class NotifsTokens : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(int, width, 400) + CONFIG_GLOBAL_PROPERTY(int, image, 41) + CONFIG_PROPERTY(int, badge, 20) + +public: + explicit NotifsTokens(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +class OsdTokens : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(int, sliderWidth, 30) + CONFIG_PROPERTY(int, sliderHeight, 150) + +public: + explicit OsdTokens(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +class SessionTokens : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(int, button, 80) + +public: + explicit SessionTokens(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +class SidebarTokens : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(int, width, 430) + +public: + explicit SidebarTokens(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +class UtilitiesTokens : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(int, width, 430) + CONFIG_PROPERTY(int, toastWidth, 430) + +public: + explicit UtilitiesTokens(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +class LockTokens : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(qreal, heightMult, 0.7) + CONFIG_PROPERTY(qreal, ratio, 16.0 / 9.0) + CONFIG_PROPERTY(int, centerWidth, 600) + +public: + explicit LockTokens(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +class WInfoTokens : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(qreal, heightMult, 0.7) + CONFIG_PROPERTY(qreal, detailsWidth, 500) + +public: + explicit WInfoTokens(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +class ControlCenterTokens : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(qreal, heightMult, 0.7) + CONFIG_PROPERTY(qreal, ratio, 16.0 / 9.0) + +public: + explicit ControlCenterTokens(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +class SizeTokens : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_SUBOBJECT(BarTokens, bar) + CONFIG_SUBOBJECT(DashboardTokens, dashboard) + CONFIG_SUBOBJECT(LauncherTokens, launcher) + CONFIG_SUBOBJECT(NotifsTokens, notifs) + CONFIG_SUBOBJECT(OsdTokens, osd) + CONFIG_SUBOBJECT(SessionTokens, session) + CONFIG_SUBOBJECT(SidebarTokens, sidebar) + CONFIG_SUBOBJECT(UtilitiesTokens, utilities) + CONFIG_SUBOBJECT(LockTokens, lock) + CONFIG_SUBOBJECT(WInfoTokens, winfo) + CONFIG_SUBOBJECT(ControlCenterTokens, controlCenter) + +public: + explicit SizeTokens(QObject* parent = nullptr) + : ConfigObject(parent) + , m_bar(new BarTokens(this)) + , m_dashboard(new DashboardTokens(this)) + , m_launcher(new LauncherTokens(this)) + , m_notifs(new NotifsTokens(this)) + , m_osd(new OsdTokens(this)) + , m_session(new SessionTokens(this)) + , m_sidebar(new SidebarTokens(this)) + , m_utilities(new UtilitiesTokens(this)) + , m_lock(new LockTokens(this)) + , m_winfo(new WInfoTokens(this)) + , m_controlCenter(new ControlCenterTokens(this)) {} +}; + +class TokenConfig : public RootConfig { + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + CONFIG_SUBOBJECT(AppearanceTokens, appearance) + CONFIG_SUBOBJECT(SizeTokens, sizes) + +public: + static TokenConfig* instance(); + [[nodiscard]] Q_INVOKABLE TokenConfig* defaults(); + [[nodiscard]] Q_INVOKABLE static TokenConfig* forScreen(const QString& screen); + static TokenConfig* create(QQmlEngine*, QJSEngine*); + +private: + friend class MonitorConfigManager; + explicit TokenConfig(QObject* parent = nullptr); + explicit TokenConfig( + TokenConfig* fallback, const QString& filePath, const QString& screen = {}, QObject* parent = nullptr); + + TokenConfig* m_defaults = nullptr; +}; + +} // namespace caelestia::config diff --git a/plugin/src/Caelestia/Config/tokensattached.cpp b/plugin/src/Caelestia/Config/tokensattached.cpp new file mode 100644 index 000000000..16f863b8c --- /dev/null +++ b/plugin/src/Caelestia/Config/tokensattached.cpp @@ -0,0 +1,118 @@ +#include "tokensattached.hpp" +#include "anim.hpp" +#include "appearanceconfig.hpp" +#include "config.hpp" +#include "monitorconfigmanager.hpp" +#include "tokens.hpp" + +#include + +namespace caelestia::config { + +namespace { + +const AppearanceConfig* resolveAppearance(GlobalConfig* config, bool complete, const char* prop, QObject* parent) { + if (config) + return config->appearance(); + if ((complete || !qobject_cast(parent)) && parent) + qCWarning(lcConfig, "Tokens.%s accessed without a screen set on %s", prop, parent->metaObject()->className()); + return GlobalConfig::instance()->appearance(); +} + +} // namespace + +Tokens::Tokens(QObject* parent) + : QQuickAttachedPropertyPropagator(parent) + , m_anim(new AnimTokens(this)) { + bindAnim(); + initialize(); +} + +void Tokens::classBegin() {} + +void Tokens::componentComplete() { + m_complete = true; +} + +QString Tokens::screen() const { + return m_screen; +} + +void Tokens::inheritScreen(const QString& screen) { + if (screen == m_screen) + return; + + m_screen = screen; + + if (m_screen.isEmpty()) { + m_config = nullptr; + m_tokens = nullptr; + } else { + m_config = MonitorConfigManager::instance()->configForScreen(m_screen); + m_tokens = MonitorConfigManager::instance()->tokensForScreen(m_screen); + } + + propagateScreen(); + emit sourceChanged(); +} + +void Tokens::propagateScreen() { + const auto children = attachedChildren(); + for (auto* const child : children) { + auto* const tokens = qobject_cast(child); + if (tokens) + tokens->inheritScreen(m_screen); + } +} + +void Tokens::attachedParentChange( + QQuickAttachedPropertyPropagator* newParent, QQuickAttachedPropertyPropagator* oldParent) { + Q_UNUSED(oldParent); + auto* const tokens = qobject_cast(newParent); + if (tokens) + inheritScreen(tokens->screen()); +} + +void Tokens::bindAnim() { + m_anim->bindDurations(GlobalConfig::instance()->appearance()->anim()->durations()); + m_anim->bindCurves(TokenConfig::instance()->appearance()->curves()); +} + +#define TOKENS_ATTACHED_GETTER(Type, name) \ + const Type* Tokens::name() const { \ + auto* a = resolveAppearance(m_config, m_complete, #name, parent()); \ + return a ? a->name() : nullptr; \ + } + +TOKENS_ATTACHED_GETTER(AppearanceRounding, rounding) +TOKENS_ATTACHED_GETTER(AppearanceSpacing, spacing) +TOKENS_ATTACHED_GETTER(AppearancePadding, padding) +TOKENS_ATTACHED_GETTER(AppearanceFont, font) + +#undef TOKENS_ATTACHED_GETTER + +const AppearanceTransparency* Tokens::transparency() const { + return GlobalConfig::instance()->appearance()->transparency(); // Transparency is always global +} + +const SizeTokens* Tokens::sizes() const { + if (m_tokens) + return m_tokens->sizes(); + if ((m_complete || !qobject_cast(parent())) && parent()) + qCWarning(lcConfig, "Tokens.sizes accessed without a screen set on %s", parent()->metaObject()->className()); + return TokenConfig::instance()->sizes(); +} + +const AnimTokens* Tokens::anim() const { + return m_anim; +} + +TokenConfig* Tokens::forScreen(const QString& screen) { + return TokenConfig::forScreen(screen); +} + +Tokens* Tokens::qmlAttachedProperties(QObject* object) { + return new Tokens(object); +} + +} // namespace caelestia::config diff --git a/plugin/src/Caelestia/Config/tokensattached.hpp b/plugin/src/Caelestia/Config/tokensattached.hpp new file mode 100644 index 000000000..810b0c0de --- /dev/null +++ b/plugin/src/Caelestia/Config/tokensattached.hpp @@ -0,0 +1,78 @@ +#pragma once + +#include +#include +#include + +namespace caelestia::config { + +class AnimTokens; +class AppearanceFont; +class AppearancePadding; +class AppearanceRounding; +class AppearanceSpacing; +class AppearanceTransparency; +class GlobalConfig; +class SizeTokens; +class TokenConfig; + +class Tokens : public QQuickAttachedPropertyPropagator, public QQmlParserStatus { + Q_OBJECT + Q_INTERFACES(QQmlParserStatus) + QML_ELEMENT + QML_UNCREATABLE("") + QML_ATTACHED(Tokens) + Q_MOC_INCLUDE("anim.hpp") + Q_MOC_INCLUDE("appearanceconfig.hpp") + Q_MOC_INCLUDE("tokens.hpp") + + Q_PROPERTY(QString screen READ screen WRITE inheritScreen NOTIFY sourceChanged) + Q_PROPERTY(const caelestia::config::AppearanceRounding* rounding READ rounding NOTIFY sourceChanged) + Q_PROPERTY(const caelestia::config::AppearanceSpacing* spacing READ spacing NOTIFY sourceChanged) + Q_PROPERTY(const caelestia::config::AppearancePadding* padding READ padding NOTIFY sourceChanged) + Q_PROPERTY(const caelestia::config::AppearanceFont* font READ font NOTIFY sourceChanged) + Q_PROPERTY(const caelestia::config::AppearanceTransparency* transparency READ transparency NOTIFY sourceChanged) + Q_PROPERTY(const caelestia::config::SizeTokens* sizes READ sizes NOTIFY sourceChanged) + Q_PROPERTY(const caelestia::config::AnimTokens* anim READ anim NOTIFY sourceChanged) + +public: + explicit Tokens(QObject* parent = nullptr); + + [[nodiscard]] QString screen() const; + void inheritScreen(const QString& screen); + + [[nodiscard]] const AppearanceRounding* rounding() const; + [[nodiscard]] const AppearanceSpacing* spacing() const; + [[nodiscard]] const AppearancePadding* padding() const; + [[nodiscard]] const AppearanceFont* font() const; + [[nodiscard]] const AppearanceTransparency* transparency() const; + + [[nodiscard]] const SizeTokens* sizes() const; + [[nodiscard]] const AnimTokens* anim() const; + + [[nodiscard]] Q_INVOKABLE static TokenConfig* forScreen(const QString& screen); + + static Tokens* qmlAttachedProperties(QObject* object); + +signals: + void sourceChanged(); + +protected: + void attachedParentChange( + QQuickAttachedPropertyPropagator* newParent, QQuickAttachedPropertyPropagator* oldParent) override; + +private: + void classBegin() override; + void componentComplete() override; + + void propagateScreen(); + void bindAnim(); + + bool m_complete = false; + QString m_screen; + GlobalConfig* m_config = nullptr; + TokenConfig* m_tokens = nullptr; + AnimTokens* m_anim = nullptr; +}; + +} // namespace caelestia::config diff --git a/plugin/src/Caelestia/Config/userpaths.hpp b/plugin/src/Caelestia/Config/userpaths.hpp new file mode 100644 index 000000000..da6973a78 --- /dev/null +++ b/plugin/src/Caelestia/Config/userpaths.hpp @@ -0,0 +1,30 @@ +#pragma once + +#include "configobject.hpp" + +#include +#include +#include + +namespace caelestia::config { + +using Qt::StringLiterals::operator""_s; + +class UserPaths : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_GLOBAL_PROPERTY( + QString, wallpaperDir, QStandardPaths::writableLocation(QStandardPaths::PicturesLocation) + u"/Wallpapers"_s) + CONFIG_GLOBAL_PROPERTY(QString, lyricsDir, QDir::homePath() + u"/Music/lyrics/"_s) + CONFIG_PROPERTY(QString, sessionGif, u"root:/assets/kurukuru.gif"_s) + CONFIG_PROPERTY(QString, mediaGif, u"root:/assets/bongocat.gif"_s) + CONFIG_PROPERTY(QString, noNotifsPic, u"root:/assets/dino.png"_s) + CONFIG_PROPERTY(QString, lockNoNotifsPic, u"root:/assets/dino.png"_s) + +public: + explicit UserPaths(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +} // namespace caelestia::config diff --git a/plugin/src/Caelestia/Config/utilitiesconfig.hpp b/plugin/src/Caelestia/Config/utilitiesconfig.hpp new file mode 100644 index 000000000..153f21d5c --- /dev/null +++ b/plugin/src/Caelestia/Config/utilitiesconfig.hpp @@ -0,0 +1,88 @@ +#pragma once + +#include "configobject.hpp" + +#include +#include + +namespace caelestia::config { + +using Qt::StringLiterals::operator""_s; + +class UtilitiesToasts : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(QString, fullscreen, u"off"_s) + CONFIG_GLOBAL_PROPERTY(bool, configLoaded, true) + CONFIG_GLOBAL_PROPERTY(bool, chargingChanged, true) + CONFIG_GLOBAL_PROPERTY(bool, gameModeChanged, true) + CONFIG_GLOBAL_PROPERTY(bool, dndChanged, true) + CONFIG_GLOBAL_PROPERTY(bool, audioOutputChanged, true) + CONFIG_GLOBAL_PROPERTY(bool, audioInputChanged, true) + CONFIG_GLOBAL_PROPERTY(bool, capsLockChanged, true) + CONFIG_GLOBAL_PROPERTY(bool, numLockChanged, true) + CONFIG_GLOBAL_PROPERTY(bool, kbLayoutChanged, true) + CONFIG_GLOBAL_PROPERTY(bool, kbLimit, true) + CONFIG_GLOBAL_PROPERTY(bool, vpnChanged, true) + CONFIG_GLOBAL_PROPERTY(bool, nowPlaying, false) + +public: + explicit UtilitiesToasts(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +class UtilitiesVpn : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_GLOBAL_PROPERTY(bool, enabled, false) + CONFIG_GLOBAL_PROPERTY(QVariantList, provider) + +public: + explicit UtilitiesVpn(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +class UtilitiesRecording : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(QString, videoMode, u"fullscreen"_s) + CONFIG_PROPERTY(bool, recordSystem, true) + CONFIG_PROPERTY(bool, recordMicrophone, false) + +public: + explicit UtilitiesRecording(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +class UtilitiesConfig : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(bool, enabled, true) + CONFIG_PROPERTY(int, maxToasts, 4) + CONFIG_SUBOBJECT(UtilitiesToasts, toasts) + CONFIG_SUBOBJECT(UtilitiesVpn, vpn) + CONFIG_SUBOBJECT(UtilitiesRecording, recording) + CONFIG_PROPERTY(QVariantList, quickToggles, + { + vmap({ { u"id"_s, u"wifi"_s }, { u"enabled"_s, true } }), + vmap({ { u"id"_s, u"bluetooth"_s }, { u"enabled"_s, true } }), + vmap({ { u"id"_s, u"mic"_s }, { u"enabled"_s, true } }), + vmap({ { u"id"_s, u"settings"_s }, { u"enabled"_s, true } }), + vmap({ { u"id"_s, u"gameMode"_s }, { u"enabled"_s, true } }), + vmap({ { u"id"_s, u"dnd"_s }, { u"enabled"_s, true } }), + vmap({ { u"id"_s, u"vpn"_s }, { u"enabled"_s, false } }), + }) + +public: + explicit UtilitiesConfig(QObject* parent = nullptr) + : ConfigObject(parent) + , m_toasts(new UtilitiesToasts(this)) + , m_vpn(new UtilitiesVpn(this)) + , m_recording(new UtilitiesRecording(this)) {} +}; + +} // namespace caelestia::config diff --git a/plugin/src/Caelestia/Config/winfoconfig.hpp b/plugin/src/Caelestia/Config/winfoconfig.hpp new file mode 100644 index 000000000..30d4f5e6e --- /dev/null +++ b/plugin/src/Caelestia/Config/winfoconfig.hpp @@ -0,0 +1,18 @@ +#pragma once + +#include "configobject.hpp" + +namespace caelestia::config { + +// WInfoConfig has no serialized properties (serializer returns {}) +// All properties are in AdvancedConfig.winfo +class WInfoConfig : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + +public: + explicit WInfoConfig(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +} // namespace caelestia::config diff --git a/plugin/src/Caelestia/Images/CMakeLists.txt b/plugin/src/Caelestia/Images/CMakeLists.txt new file mode 100644 index 000000000..d869667d8 --- /dev/null +++ b/plugin/src/Caelestia/Images/CMakeLists.txt @@ -0,0 +1,11 @@ +qml_module(caelestia-images + URI Caelestia.Images + SOURCES + cachingimageprovider.cpp + imagecacher.cpp + iutils.cpp + LIBRARIES + Qt::Gui + Qt::Quick + Qt::Concurrent +) diff --git a/plugin/src/Caelestia/Images/cachingimageprovider.cpp b/plugin/src/Caelestia/Images/cachingimageprovider.cpp new file mode 100644 index 000000000..768e05dae --- /dev/null +++ b/plugin/src/Caelestia/Images/cachingimageprovider.cpp @@ -0,0 +1,120 @@ +#include "cachingimageprovider.hpp" + +#include "imagecacher.hpp" + +#include +#include +#include +#include +#include +#include + +Q_LOGGING_CATEGORY(lcCProv, "caelestia.images.cacheprovider", QtInfoMsg) + +namespace caelestia::images { + +namespace { + +class CachingImageResponse final : public QQuickImageResponse, public QRunnable { +public: + CachingImageResponse(const QString& id, const QSize& requestedSize, ImageCacher::FillMode fillMode) + : m_id(id) + , m_requestedSize(requestedSize) + , m_fillMode(fillMode) { + setAutoDelete(false); + } + + [[nodiscard]] QQuickTextureFactory* textureFactory() const override { + return QQuickTextureFactory::textureFactoryForImage(m_image); + } + + [[nodiscard]] QString errorString() const override { return m_error; } + + void run() override { + process(); + emit finished(); + } + +private: + void process() { + QString path = QString::fromUtf8(m_id.toUtf8().percentDecoded()); + if (!path.startsWith(QLatin1Char('/'))) + path.prepend(QLatin1Char('/')); + + if (!QFileInfo::exists(path)) { + m_error = QStringLiteral("Source file does not exist: ") + path; + qCWarning(lcCProv).noquote() << m_error; + return; + } + + QSize size = m_requestedSize; + const bool needsW = size.width() <= 0; + const bool needsH = size.height() <= 0; + + // If both dimensions are missing, return the original directly + if (needsW && needsH) { + qCDebug(lcCProv).noquote() << "Given source size is invalid, returning original:" << path; + m_image = QImage(path); + if (m_image.isNull()) { + m_error = QStringLiteral("Failed to decode source: ") + path; + qCWarning(lcCProv).noquote() << m_error; + } + return; + } + + // If one dimension is missing, derive it from the source aspect ratio + if (needsW || needsH) { + const QImageReader sourceReader(path); + const QSize sourceSize = sourceReader.size(); + if (!sourceSize.isValid() || sourceSize.isEmpty()) { + m_error = QStringLiteral("Could not determine source size for: ") + path; + qCWarning(lcCProv).noquote() << m_error; + return; + } + + if (needsW) + size.setWidth(qRound(size.height() * sourceSize.width() / static_cast(sourceSize.height()))); + else + size.setHeight(qRound(size.width() * sourceSize.height() / static_cast(sourceSize.width()))); + } + + // Try to use cached image + const auto cachePath = ImageCacher::cachePathFor(path, size, m_fillMode); + if (!cachePath.isEmpty()) { + QImageReader cacheReader(cachePath); + if (cacheReader.canRead()) { + m_image = cacheReader.read(); + if (!m_image.isNull()) + return; + } + } + + // Schedule cache job (this call will return the original image, but later ones will use cache) + ImageCacher::instance()->schedule(path, cachePath, size, m_fillMode); + + m_image = QImage(path); + if (m_image.isNull()) { + m_error = QStringLiteral("Failed to decode source: ") + path; + qCWarning(lcCProv).noquote() << m_error; + } + } + + QString m_id; + QSize m_requestedSize; + ImageCacher::FillMode m_fillMode; + QImage m_image; + QString m_error; +}; + +} // namespace + +CachingImageProvider::CachingImageProvider(FillMode fillMode) + : m_fillMode(fillMode) {} + +QQuickImageResponse* CachingImageProvider::requestImageResponse(const QString& id, const QSize& requestedSize) { + auto* const response = new CachingImageResponse(id, requestedSize, m_fillMode); + QThreadPool::globalInstance()->start(response); + return response; +} + +} // namespace caelestia::images diff --git a/plugin/src/Caelestia/Images/cachingimageprovider.hpp b/plugin/src/Caelestia/Images/cachingimageprovider.hpp new file mode 100644 index 000000000..97082c765 --- /dev/null +++ b/plugin/src/Caelestia/Images/cachingimageprovider.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include "imagecacher.hpp" + +#include + +namespace caelestia::images { + +class CachingImageProvider : public QQuickAsyncImageProvider { +public: + using FillMode = ImageCacher::FillMode; + + explicit CachingImageProvider(FillMode fillMode); + + QQuickImageResponse* requestImageResponse(const QString& id, const QSize& requestedSize) override; + +private: + FillMode m_fillMode; +}; + +} // namespace caelestia::images diff --git a/plugin/src/Caelestia/Images/imagecacher.cpp b/plugin/src/Caelestia/Images/imagecacher.cpp new file mode 100644 index 000000000..dfef8f0b8 --- /dev/null +++ b/plugin/src/Caelestia/Images/imagecacher.cpp @@ -0,0 +1,159 @@ +#include "imagecacher.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +Q_LOGGING_CATEGORY(lcCacher, "caelestia.images.cacher", QtInfoMsg) + +namespace caelestia::images { + +namespace { + +QString sha256sum(const QString& path) { + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + qCWarning(lcCacher).noquote() << "sha256sum: failed to open" << path; + return {}; + } + + QCryptographicHash hash(QCryptographicHash::Sha256); + hash.addData(&file); + file.close(); + + return hash.result().toHex(); +} + +QString fillSuffix(ImageCacher::FillMode fillMode) { + switch (fillMode) { + case ImageCacher::FillMode::Crop: + return QStringLiteral("crop"); + case ImageCacher::FillMode::Fit: + return QStringLiteral("fit"); + default: + return QStringLiteral("stretch"); + } +} + +} // namespace + +const QString& ImageCacher::cacheDir() { + static const QString s_dir = [] { + QString cache = qEnvironmentVariable("XDG_CACHE_HOME"); + if (cache.isEmpty()) + cache = QDir::homePath() + QStringLiteral("/.cache"); + return cache + QStringLiteral("/caelestia/imagecache"); + }(); + return s_dir; +} + +QString ImageCacher::cachePathFor(const QString& sourcePath, const QSize& size, FillMode fillMode) { + const QString sha = sha256sum(sourcePath); + if (sha.isEmpty()) + return {}; + + const QString filename = + QStringLiteral("%1@%2x%3-%4.png") + .arg(sha, QString::number(size.width()), QString::number(size.height()), fillSuffix(fillMode)); + + return cacheDir() + QLatin1Char('/') + filename; +} + +ImageCacher* ImageCacher::instance() { + static ImageCacher s_instance; + return &s_instance; +} + +ImageCacher::ImageCacher(QObject* parent) + : QObject(parent) {} + +void ImageCacher::schedule(const QString& sourcePath, const QSize& size, FillMode fillMode) { + schedule(sourcePath, cachePathFor(sourcePath, size, fillMode), size, fillMode); +} + +void ImageCacher::schedule(const QString& sourcePath, const QString& cachePath, const QSize& size, FillMode fillMode) { + if (cachePath.isEmpty()) + return; + + { + QMutexLocker locker(&m_mutex); + if (m_inflight.contains(cachePath)) + return; + m_inflight.insert(cachePath); + } + + QThreadPool::globalInstance()->start([this, sourcePath, cachePath, size, fillMode]() { + runJob(sourcePath, cachePath, size, fillMode); + QMutexLocker locker(&m_mutex); + m_inflight.remove(cachePath); + }); +} + +void ImageCacher::runJob(const QString& sourcePath, const QString& cachePath, const QSize& size, FillMode fillMode) { + if (QFile::exists(cachePath)) { + return; + } + + QImage image(sourcePath); + if (image.isNull()) { + qCWarning(lcCacher).noquote() << "Failed to decode source" << sourcePath; + return; + } + + Qt::AspectRatioMode scaleMode; + switch (fillMode) { + case FillMode::Crop: + scaleMode = Qt::KeepAspectRatioByExpanding; + break; + case FillMode::Fit: + scaleMode = Qt::KeepAspectRatio; + break; + case FillMode::Stretch: + scaleMode = Qt::IgnoreAspectRatio; + break; + } + + image.convertTo(QImage::Format_ARGB32); + image = image.scaled(size, scaleMode, Qt::SmoothTransformation); + + if (image.isNull()) { + qCWarning(lcCacher).noquote() << "Failed to scale" << sourcePath; + return; + } + + QImage canvas; + if (fillMode == FillMode::Stretch) { + canvas = image; + } else { + canvas = QImage(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(); + } + + const QString parent = QFileInfo(cachePath).absolutePath(); + if (!QDir().mkpath(parent)) { + qCWarning(lcCacher).noquote() << "Failed to create cache dir" << parent; + return; + } + + QSaveFile saveFile(cachePath); + if (!saveFile.open(QIODevice::WriteOnly) || !canvas.save(&saveFile, "PNG") || !saveFile.commit()) { + qCWarning( + lcCacher, "Failed to save to %s: %s", qUtf8Printable(cachePath), qUtf8Printable(saveFile.errorString())); + return; + } + + qCDebug(lcCacher).noquote() << "Saved to" << cachePath; +} + +} // namespace caelestia::images diff --git a/plugin/src/Caelestia/Images/imagecacher.hpp b/plugin/src/Caelestia/Images/imagecacher.hpp new file mode 100644 index 000000000..796084193 --- /dev/null +++ b/plugin/src/Caelestia/Images/imagecacher.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace caelestia::images { + +class ImageCacher : public QObject { + Q_OBJECT + +public: + enum class FillMode { + Crop, + Fit, + Stretch, + }; + + static ImageCacher* instance(); + + static const QString& cacheDir(); + static QString cachePathFor(const QString& sourcePath, const QSize& size, FillMode fillMode); + + void schedule(const QString& sourcePath, const QSize& size, FillMode fillMode); + void schedule(const QString& sourcePath, const QString& cachePath, const QSize& size, FillMode fillMode); + +private: + explicit ImageCacher(QObject* parent = nullptr); + + static void runJob(const QString& sourcePath, const QString& cachePath, const QSize& size, FillMode fillMode); + + QMutex m_mutex; + QSet m_inflight; +}; + +} // namespace caelestia::images diff --git a/plugin/src/Caelestia/Images/iutils.cpp b/plugin/src/Caelestia/Images/iutils.cpp new file mode 100644 index 000000000..996f93dd1 --- /dev/null +++ b/plugin/src/Caelestia/Images/iutils.cpp @@ -0,0 +1,42 @@ +#include "iutils.hpp" + +#include "cachingimageprovider.hpp" + +namespace caelestia::images { + +IUtils* IUtils::create(QQmlEngine* engine, QJSEngine* jsEngine) { + Q_UNUSED(jsEngine); + + engine->addImageProvider(QStringLiteral("ccache"), new CachingImageProvider(CachingImageProvider::FillMode::Crop)); + engine->addImageProvider(QStringLiteral("fcache"), new CachingImageProvider(CachingImageProvider::FillMode::Fit)); + engine->addImageProvider( + QStringLiteral("scache"), new CachingImageProvider(CachingImageProvider::FillMode::Stretch)); + + return new IUtils(engine); +} + +QUrl IUtils::urlForPath(const QString& path, int fillMode) { + if (path.isEmpty()) + return QUrl(); + + QString prefix; + switch (fillMode) { + case 1: // Image.PreserveAspectFit + prefix = QStringLiteral("fcache"); + break; + case 2: // Image.PreserveAspectCrop + prefix = QStringLiteral("ccache"); + break; + default: // Image.Stretch or any other ones + prefix = QStringLiteral("scache"); + break; + } + + QUrl url; + url.setScheme(QStringLiteral("image")); + url.setHost(prefix); + url.setPath(path.startsWith(QLatin1Char('/')) ? path : QLatin1Char('/') + path); + return url; +} + +} // namespace caelestia::images diff --git a/plugin/src/Caelestia/Images/iutils.hpp b/plugin/src/Caelestia/Images/iutils.hpp new file mode 100644 index 000000000..2187c8d02 --- /dev/null +++ b/plugin/src/Caelestia/Images/iutils.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include +#include +#include + +namespace caelestia::images { + +class IUtils : public QObject { + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + +public: + static IUtils* create(QQmlEngine* engine, QJSEngine* jsEngine); + + Q_INVOKABLE static QUrl urlForPath(const QString& path, int fillMode); + +private: + explicit IUtils(QObject* parent = nullptr) + : QObject(parent) {}; +}; + +} // namespace caelestia::images diff --git a/plugin/src/Caelestia/Internal/CMakeLists.txt b/plugin/src/Caelestia/Internal/CMakeLists.txt index bdc58dbf7..f4bbc5fdc 100644 --- a/plugin/src/Caelestia/Internal/CMakeLists.txt +++ b/plugin/src/Caelestia/Internal/CMakeLists.txt @@ -1,15 +1,17 @@ qml_module(caelestia-internal URI Caelestia.Internal SOURCES - cachingimagemanager.hpp cachingimagemanager.cpp - circularindicatormanager.hpp circularindicatormanager.cpp - hyprdevices.hpp hyprdevices.cpp - hyprextras.hpp hyprextras.cpp - logindmanager.hpp logindmanager.cpp + arcgauge.cpp + circularbuffer.cpp + circularindicatormanager.cpp + hyprdevices.cpp + hyprextras.cpp + logindmanager.cpp + sparklineitem.cpp + visualiserbars.cpp LIBRARIES Qt::Gui Qt::Quick - Qt::Concurrent Qt::Network Qt::DBus ) diff --git a/plugin/src/Caelestia/Internal/arcgauge.cpp b/plugin/src/Caelestia/Internal/arcgauge.cpp new file mode 100644 index 000000000..d534f5f73 --- /dev/null +++ b/plugin/src/Caelestia/Internal/arcgauge.cpp @@ -0,0 +1,119 @@ +#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 diff --git a/plugin/src/Caelestia/Internal/arcgauge.hpp b/plugin/src/Caelestia/Internal/arcgauge.hpp new file mode 100644 index 000000000..4ccb1fd01 --- /dev/null +++ b/plugin/src/Caelestia/Internal/arcgauge.hpp @@ -0,0 +1,61 @@ +#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 diff --git a/plugin/src/Caelestia/Internal/cachingimagemanager.cpp b/plugin/src/Caelestia/Internal/cachingimagemanager.cpp deleted file mode 100644 index 1c15cd203..000000000 --- a/plugin/src/Caelestia/Internal/cachingimagemanager.cpp +++ /dev/null @@ -1,223 +0,0 @@ -#include "cachingimagemanager.hpp" - -#include -#include -#include -#include -#include -#include -#include -#include - -namespace caelestia::internal { - -qreal CachingImageManager::effectiveScale() const { - if (m_item && m_item->window()) { - return m_item->window()->devicePixelRatio(); - } - - return 1.0; -} - -QSize CachingImageManager::effectiveSize() const { - if (!m_item) { - return QSize(); - } - - const qreal scale = effectiveScale(); - const QSize size = QSizeF(m_item->width() * scale, m_item->height() * scale).toSize(); - m_item->setProperty("sourceSize", size); - return size; -} - -QQuickItem* CachingImageManager::item() const { - return m_item; -} - -void CachingImageManager::setItem(QQuickItem* item) { - if (m_item == item) { - return; - } - - if (m_widthConn) { - disconnect(m_widthConn); - } - if (m_heightConn) { - disconnect(m_heightConn); - } - - m_item = item; - emit itemChanged(); - - if (item) { - m_widthConn = connect(item, &QQuickItem::widthChanged, this, [this]() { - updateSource(); - }); - m_heightConn = connect(item, &QQuickItem::heightChanged, this, [this]() { - updateSource(); - }); - updateSource(); - } -} - -QUrl CachingImageManager::cacheDir() const { - return m_cacheDir; -} - -void CachingImageManager::setCacheDir(const QUrl& cacheDir) { - if (m_cacheDir == cacheDir) { - return; - } - - m_cacheDir = cacheDir; - if (!m_cacheDir.path().endsWith("/")) { - m_cacheDir.setPath(m_cacheDir.path() + "/"); - } - emit cacheDirChanged(); -} - -QString CachingImageManager::path() const { - return m_path; -} - -void CachingImageManager::setPath(const QString& path) { - if (m_path == path) { - return; - } - - m_path = path; - emit pathChanged(); - - if (!path.isEmpty()) { - updateSource(path); - } -} - -void CachingImageManager::updateSource() { - updateSource(m_path); -} - -void CachingImageManager::updateSource(const QString& path) { - if (path.isEmpty() || path == m_shaPath) { - // Path is empty or already calculating sha for path - return; - } - - m_shaPath = path; - - const auto future = QtConcurrent::run(&CachingImageManager::sha256sum, path); - - const auto watcher = new QFutureWatcher(this); - - connect(watcher, &QFutureWatcher::finished, this, [watcher, path, this]() { - if (m_path != path) { - // Object is destroyed or path has changed, ignore - watcher->deleteLater(); - return; - } - - const QSize size = effectiveSize(); - - if (!m_item || !size.width() || !size.height()) { - watcher->deleteLater(); - return; - } - - const QString fillMode = m_item->property("fillMode").toString(); - // clang-format off - const QString filename = QString("%1@%2x%3-%4.png") - .arg(watcher->result()).arg(size.width()).arg(size.height()) - .arg(fillMode == "PreserveAspectCrop" ? "crop" : fillMode == "PreserveAspectFit" ? "fit" : "stretch"); - // clang-format on - - const QUrl cache = m_cacheDir.resolved(QUrl(filename)); - if (m_cachePath == cache) { - watcher->deleteLater(); - return; - } - - m_cachePath = cache; - emit cachePathChanged(); - - if (!cache.isLocalFile()) { - qWarning() << "CachingImageManager::updateSource: cachePath" << cache << "is not a local file"; - watcher->deleteLater(); - return; - } - - const QImageReader reader(cache.toLocalFile()); - if (reader.canRead()) { - m_item->setProperty("source", cache); - } else { - m_item->setProperty("source", QUrl::fromLocalFile(path)); - createCache(path, cache.toLocalFile(), fillMode, size); - } - - // Clear current running sha if same - if (m_shaPath == path) { - m_shaPath = QString(); - } - - watcher->deleteLater(); - }); - - watcher->setFuture(future); -} - -QUrl CachingImageManager::cachePath() const { - return m_cachePath; -} - -void CachingImageManager::createCache( - const QString& path, const QString& cache, const QString& fillMode, const QSize& size) const { - QThreadPool::globalInstance()->start([path, cache, fillMode, size] { - QImage image(path); - - if (image.isNull()) { - qWarning() << "CachingImageManager::createCache: failed to read" << path; - return; - } - - image.convertTo(QImage::Format_ARGB32); - - if (fillMode == "PreserveAspectCrop") { - image = image.scaled(size, Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation); - } else if (fillMode == "PreserveAspectFit") { - image = image.scaled(size, Qt::KeepAspectRatio, Qt::SmoothTransformation); - } else { - image = image.scaled(size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); - } - - if (fillMode == "PreserveAspectCrop" || fillMode == "PreserveAspectFit") { - QImage canvas(size, QImage::Format_ARGB32); - canvas.fill(Qt::transparent); - - QPainter painter(&canvas); - painter.drawImage((size.width() - image.width()) / 2, (size.height() - image.height()) / 2, image); - painter.end(); - - image = canvas; - } - - const QString parent = QFileInfo(cache).absolutePath(); - if (!QDir().mkpath(parent) || !image.save(cache)) { - qWarning() << "CachingImageManager::createCache: failed to save to" << cache; - } - }); -} - -QString CachingImageManager::sha256sum(const QString& path) { - QFile file(path); - if (!file.open(QIODevice::ReadOnly)) { - qWarning() << "CachingImageManager::sha256sum: failed to open" << path; - return ""; - } - - QCryptographicHash hash(QCryptographicHash::Sha256); - hash.addData(&file); - file.close(); - - return hash.result().toHex(); -} - -} // namespace caelestia::internal diff --git a/plugin/src/Caelestia/Internal/cachingimagemanager.hpp b/plugin/src/Caelestia/Internal/cachingimagemanager.hpp deleted file mode 100644 index 3611699b6..000000000 --- a/plugin/src/Caelestia/Internal/cachingimagemanager.hpp +++ /dev/null @@ -1,65 +0,0 @@ -#pragma once - -#include -#include -#include - -namespace caelestia::internal { - -class CachingImageManager : public QObject { - Q_OBJECT - QML_ELEMENT - - Q_PROPERTY(QQuickItem* item READ item WRITE setItem NOTIFY itemChanged REQUIRED) - Q_PROPERTY(QUrl cacheDir READ cacheDir WRITE setCacheDir NOTIFY cacheDirChanged REQUIRED) - - Q_PROPERTY(QString path READ path WRITE setPath NOTIFY pathChanged) - Q_PROPERTY(QUrl cachePath READ cachePath NOTIFY cachePathChanged) - -public: - explicit CachingImageManager(QObject* parent = nullptr) - : QObject(parent) - , m_item(nullptr) {} - - [[nodiscard]] QQuickItem* item() const; - void setItem(QQuickItem* item); - - [[nodiscard]] QUrl cacheDir() const; - void setCacheDir(const QUrl& cacheDir); - - [[nodiscard]] QString path() const; - void setPath(const QString& path); - - [[nodiscard]] QUrl cachePath() const; - - Q_INVOKABLE void updateSource(); - Q_INVOKABLE void updateSource(const QString& path); - -signals: - void itemChanged(); - void cacheDirChanged(); - - void pathChanged(); - void cachePathChanged(); - void usingCacheChanged(); - -private: - QString m_shaPath; - - QQuickItem* m_item; - QUrl m_cacheDir; - - QString m_path; - QUrl m_cachePath; - - QMetaObject::Connection m_widthConn; - QMetaObject::Connection m_heightConn; - - [[nodiscard]] qreal effectiveScale() const; - [[nodiscard]] QSize effectiveSize() const; - - void createCache(const QString& path, const QString& cache, const QString& fillMode, const QSize& size) const; - [[nodiscard]] static QString sha256sum(const QString& path); -}; - -} // namespace caelestia::internal diff --git a/plugin/src/Caelestia/Internal/circularbuffer.cpp b/plugin/src/Caelestia/Internal/circularbuffer.cpp new file mode 100644 index 000000000..9701e7fac --- /dev/null +++ b/plugin/src/Caelestia/Internal/circularbuffer.cpp @@ -0,0 +1,94 @@ +#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 diff --git a/plugin/src/Caelestia/Internal/circularbuffer.hpp b/plugin/src/Caelestia/Internal/circularbuffer.hpp new file mode 100644 index 000000000..ab2dba56e --- /dev/null +++ b/plugin/src/Caelestia/Internal/circularbuffer.hpp @@ -0,0 +1,44 @@ +#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 diff --git a/plugin/src/Caelestia/Internal/circularindicatormanager.cpp b/plugin/src/Caelestia/Internal/circularindicatormanager.cpp index 434b75620..212499792 100644 --- a/plugin/src/Caelestia/Internal/circularindicatormanager.cpp +++ b/plugin/src/Caelestia/Internal/circularindicatormanager.cpp @@ -153,8 +153,10 @@ void CircularIndicatorManager::updateRetreat(qreal progress) { spinRotation += m_curve.valueForProgress(getFractionInRange(playtime, spinDelay, DURATION_SPIN_IN_MS)) * SPIN_ROTATION_DEGREES; } + const auto oldRotation = m_rotation; m_rotation = constantRotation + spinRotation; - emit rotationChanged(); + if (!qFuzzyCompare(m_rotation + 1.0, oldRotation + 1.0)) + emit rotationChanged(); // Grow active indicator. qreal fraction = @@ -182,6 +184,8 @@ void CircularIndicatorManager::updateRetreat(qreal progress) { 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; @@ -204,8 +208,10 @@ void CircularIndicatorManager::updateAdvance(qreal progress) { m_startFraction /= 360.0; m_endFraction /= 360.0; - emit startFractionChanged(); - emit endFractionChanged(); + 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 diff --git a/plugin/src/Caelestia/Internal/hyprextras.cpp b/plugin/src/Caelestia/Internal/hyprextras.cpp index 5308524d9..c73b6e0ba 100644 --- a/plugin/src/Caelestia/Internal/hyprextras.cpp +++ b/plugin/src/Caelestia/Internal/hyprextras.cpp @@ -1,10 +1,14 @@ #include "hyprextras.hpp" +#include "hyprdevices.hpp" #include #include #include +#include #include +Q_LOGGING_CATEGORY(lcHypr, "caelestia.internal.hypr", QtInfoMsg) + namespace caelestia::internal::hypr { HyprExtras::HyprExtras(QObject* parent) @@ -16,8 +20,7 @@ HyprExtras::HyprExtras(QObject* parent) , 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."; + qCWarning(lcHypr) << "$HYPRLAND_INSTANCE_SIGNATURE is unset. Unable to connect to Hyprland socket."; return; } @@ -26,8 +29,7 @@ HyprExtras::HyprExtras(QObject* parent) hyprDir = "/tmp/hypr/" + his; if (!QDir(hyprDir).exists()) { - qWarning() << "HyprExtras::HyprExtras: Hyprland socket directory does not exist. Unable to connect to " - "Hyprland socket."; + qCWarning(lcHypr) << "Hyprland socket directory does not exist. Unable to connect to Hyprland socket."; return; } } @@ -62,7 +64,7 @@ void HyprExtras::message(const QString& message) { makeRequest(message, [](bool success, const QByteArray& res) { if (!success) { - qWarning() << "HyprExtras::message: request error:" << QString::fromUtf8(res); + qCWarning(lcHypr) << "message: request error:" << QString::fromUtf8(res); } }); } @@ -74,7 +76,7 @@ void HyprExtras::batchMessage(const QStringList& messages) { makeRequest("[[BATCH]]" + messages.join(";"), [](bool success, const QByteArray& res) { if (!success) { - qWarning() << "HyprExtras::batchMessage: request error:" << QString::fromUtf8(res); + qCWarning(lcHypr) << "batchMessage: request error:" << QString::fromUtf8(res); } }); } @@ -84,16 +86,18 @@ void HyprExtras::applyOptions(const QVariantHash& options) { return; } - QString request = "[[BATCH]]"; + QString request; + request.reserve(12 + options.size() * 40); + request += QLatin1String("[[BATCH]]"); for (auto it = options.constBegin(); it != options.constEnd(); ++it) { - request += QString("keyword %1 %2;").arg(it.key(), it.value().toString()); + 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); + qCWarning(lcHypr) << "applyOptions: request error" << QString::fromUtf8(res); } }); } @@ -143,15 +147,15 @@ void HyprExtras::refreshDevices() { void HyprExtras::socketError(QLocalSocket::LocalSocketError error) const { if (!m_socketValid) { - qWarning() << "HyprExtras::socketError: unable to connect to Hyprland event socket:" << error; + qCWarning(lcHypr) << "socketError: unable to connect to Hyprland event socket:" << error; } else { - qWarning() << "HyprExtras::socketError: Hyprland event socket error:" << error; + qCWarning(lcHypr) << "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."; + qCWarning(lcHypr) << "socketStateChanged: Hyprland event socket disconnected."; } m_socketValid = state == QLocalSocket::ConnectedState; @@ -204,7 +208,7 @@ HyprExtras::SocketPtr HyprExtras::makeRequest( }); QObject::connect(socket.data(), &QLocalSocket::errorOccurred, this, [=](QLocalSocket::LocalSocketError err) { - qWarning() << "HyprExtras::makeRequest: error making request:" << err << "| request:" << request; + qCWarning(lcHypr) << "makeRequest: error making request:" << err << "| request:" << request; callback(false, {}); socket->close(); }); diff --git a/plugin/src/Caelestia/Internal/hyprextras.hpp b/plugin/src/Caelestia/Internal/hyprextras.hpp index 14563c0a3..48eceea92 100644 --- a/plugin/src/Caelestia/Internal/hyprextras.hpp +++ b/plugin/src/Caelestia/Internal/hyprextras.hpp @@ -1,15 +1,19 @@ #pragma once -#include "hyprdevices.hpp" #include #include #include +#include +#include namespace caelestia::internal::hypr { +class HyprDevices; + class HyprExtras : public QObject { Q_OBJECT QML_ELEMENT + Q_MOC_INCLUDE("hyprdevices.hpp") Q_PROPERTY(QVariantHash options READ options NOTIFY optionsChanged) Q_PROPERTY(caelestia::internal::hypr::HyprDevices* devices READ devices CONSTANT) diff --git a/plugin/src/Caelestia/Internal/logindmanager.cpp b/plugin/src/Caelestia/Internal/logindmanager.cpp index 4194ee1e7..740005d9d 100644 --- a/plugin/src/Caelestia/Internal/logindmanager.cpp +++ b/plugin/src/Caelestia/Internal/logindmanager.cpp @@ -4,6 +4,9 @@ #include #include #include +#include + +Q_LOGGING_CATEGORY(lcLogindManager, "caelestia.internal.logindmanager", QtInfoMsg) namespace caelestia::internal { @@ -11,7 +14,7 @@ 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(); + qCWarning(lcLogindManager) << "Failed to connect to system bus:" << bus.lastError().message(); return; } @@ -19,14 +22,13 @@ LogindManager::LogindManager(QObject* parent) "PrepareForSleep", this, SLOT(handlePrepareForSleep(bool))); if (!ok) { - qWarning() << "LogindManager::LogindManager: failed to connect to PrepareForSleep signal:" - << bus.lastError().message(); + qCWarning(lcLogindManager) << "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"; + qCWarning(lcLogindManager) << "Failed to get session path"; return; } const auto sessionPath = reply.value().path(); @@ -35,14 +37,14 @@ LogindManager::LogindManager(QObject* parent) SLOT(handleLockRequested())); if (!ok) { - qWarning() << "LogindManager::LogindManager: failed to connect to Lock signal:" << bus.lastError().message(); + qCWarning(lcLogindManager) << "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(); + qCWarning(lcLogindManager) << "Failed to connect to Unlock signal:" << bus.lastError().message(); } } diff --git a/plugin/src/Caelestia/Internal/sparklineitem.cpp b/plugin/src/Caelestia/Internal/sparklineitem.cpp new file mode 100644 index 000000000..4e6b07194 --- /dev/null +++ b/plugin/src/Caelestia/Internal/sparklineitem.cpp @@ -0,0 +1,216 @@ +#include "sparklineitem.hpp" +#include "circularbuffer.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 diff --git a/plugin/src/Caelestia/Internal/sparklineitem.hpp b/plugin/src/Caelestia/Internal/sparklineitem.hpp new file mode 100644 index 000000000..22632a906 --- /dev/null +++ b/plugin/src/Caelestia/Internal/sparklineitem.hpp @@ -0,0 +1,91 @@ +#pragma once + +#include +#include +#include +#include + +namespace caelestia::internal { + +class CircularBuffer; + +class SparklineItem : public QQuickPaintedItem { + Q_OBJECT + QML_ELEMENT + Q_MOC_INCLUDE("circularbuffer.hpp") + + 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 diff --git a/plugin/src/Caelestia/Internal/visualiserbars.cpp b/plugin/src/Caelestia/Internal/visualiserbars.cpp new file mode 100644 index 000000000..926468b07 --- /dev/null +++ b/plugin/src/Caelestia/Internal/visualiserbars.cpp @@ -0,0 +1,198 @@ +#include "visualiserbars.hpp" + +#include +#include +#include +#include +#include +#include + +namespace caelestia::internal { + +VisualiserBars::VisualiserBars(QQuickItem* parent) + : QQuickPaintedItem(parent) { + setAntialiasing(true); +} + +void VisualiserBars::advance(qreal dt) { + if (m_displayValues.isEmpty() || m_settled) + return; + + // dt is in seconds (from FrameAnimation.frameTime), convert to ms + const qreal dtMs = dt * 1000.0; + const qreal tau = m_animationDuration / 3.0; + const qreal alpha = 1.0 - std::exp(-dtMs / tau); + + bool allSettled = true; + + for (qsizetype i = 0; i < m_displayValues.size(); ++i) { + const double diff = m_targetValues[i] - m_displayValues[i]; + + if (std::abs(diff) > 0.001) { + m_displayValues[i] += diff * alpha; + allSettled = false; + } else { + m_displayValues[i] = m_targetValues[i]; + } + } + + update(); + + if (allSettled && !m_settled) { + m_settled = true; + emit settledChanged(); + } +} + +void VisualiserBars::paint(QPainter* painter) { + if (m_displayValues.isEmpty()) + return; + + painter->setRenderHint(QPainter::Antialiasing, true); + painter->setPen(Qt::NoPen); + + const qreal h = height(); + const qreal maxBarHeight = h * 0.4; + + QLinearGradient gradient(0, h - maxBarHeight, 0, h); + gradient.setColorAt(0, m_primaryColor); + gradient.setColorAt(1, m_secondaryColor); + painter->setBrush(gradient); + + drawSide(painter, false); + drawSide(painter, true); +} + +void VisualiserBars::drawSide(QPainter* painter, bool rightSide) { + const qreal w = width(); + const qreal h = height(); + const auto count = m_displayValues.size(); + + if (count == 0) + return; + + const qreal sideWidth = w * 0.4; + const qreal slotWidth = sideWidth / static_cast(count); + const qreal barWidth = slotWidth - m_spacing; + + if (barWidth <= 0) + return; + + const qreal sideOffset = rightSide ? w * 0.6 : 0; + const qreal maxBarHeight = h * 0.4; + + for (qsizetype i = 0; i < count; ++i) { + const qsizetype valueIndex = rightSide ? i : (count - i - 1); + const qreal value = std::clamp(m_displayValues[valueIndex], 0.0, 1.0); + const qreal barHeight = value * maxBarHeight; + + if (barHeight <= 0) + continue; + + const qreal x = static_cast(i) * slotWidth + sideOffset; + const qreal y = h - barHeight; + const qreal r = std::min({ m_rounding, barWidth / 2.0, barHeight }); + + QPainterPath path; + path.moveTo(x, h); + path.lineTo(x, y + r); + + if (r > 0) { + path.arcTo(x, y, r * 2, r * 2, 180, -90); + path.lineTo(x + barWidth - r, y); + path.arcTo(x + barWidth - r * 2, y, r * 2, r * 2, 90, -90); + } else { + path.lineTo(x, y); + path.lineTo(x + barWidth, y); + } + + path.lineTo(x + barWidth, h); + path.closeSubpath(); + + painter->drawPath(path); + } +} + +QVector VisualiserBars::values() const { + return m_targetValues; +} + +void VisualiserBars::setValues(const QVector& values) { + m_targetValues = values; + + if (m_displayValues.size() != values.size()) { + m_displayValues.resize(values.size(), 0.0); + } + + if (m_settled) { + m_settled = false; + emit settledChanged(); + } + + emit valuesChanged(); +} + +bool VisualiserBars::settled() const { + return m_settled; +} + +QColor VisualiserBars::primaryColor() const { + return m_primaryColor; +} + +void VisualiserBars::setPrimaryColor(const QColor& color) { + if (m_primaryColor == color) + return; + m_primaryColor = color; + emit primaryColorChanged(); + update(); +} + +QColor VisualiserBars::secondaryColor() const { + return m_secondaryColor; +} + +void VisualiserBars::setSecondaryColor(const QColor& color) { + if (m_secondaryColor == color) + return; + m_secondaryColor = color; + emit secondaryColorChanged(); + update(); +} + +qreal VisualiserBars::rounding() const { + return m_rounding; +} + +void VisualiserBars::setRounding(qreal rounding) { + if (qFuzzyCompare(m_rounding, rounding)) + return; + m_rounding = rounding; + emit roundingChanged(); + update(); +} + +qreal VisualiserBars::spacing() const { + return m_spacing; +} + +void VisualiserBars::setSpacing(qreal spacing) { + if (qFuzzyCompare(m_spacing, spacing)) + return; + m_spacing = spacing; + emit spacingChanged(); + update(); +} + +int VisualiserBars::animationDuration() const { + return m_animationDuration; +} + +void VisualiserBars::setAnimationDuration(int duration) { + if (m_animationDuration == duration) + return; + m_animationDuration = duration; + emit animationDurationChanged(); +} + +} // namespace caelestia::internal diff --git a/plugin/src/Caelestia/Internal/visualiserbars.hpp b/plugin/src/Caelestia/Internal/visualiserbars.hpp new file mode 100644 index 000000000..95c071243 --- /dev/null +++ b/plugin/src/Caelestia/Internal/visualiserbars.hpp @@ -0,0 +1,72 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace caelestia::internal { + +class VisualiserBars : public QQuickPaintedItem { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(QVector values READ values WRITE setValues NOTIFY valuesChanged) + Q_PROPERTY(QColor primaryColor READ primaryColor WRITE setPrimaryColor NOTIFY primaryColorChanged) + Q_PROPERTY(QColor secondaryColor READ secondaryColor WRITE setSecondaryColor NOTIFY secondaryColorChanged) + Q_PROPERTY(qreal rounding READ rounding WRITE setRounding NOTIFY roundingChanged) + Q_PROPERTY(qreal spacing READ spacing WRITE setSpacing NOTIFY spacingChanged) + Q_PROPERTY(int animationDuration READ animationDuration WRITE setAnimationDuration NOTIFY animationDurationChanged) + Q_PROPERTY(bool settled READ settled NOTIFY settledChanged) + +public: + explicit VisualiserBars(QQuickItem* parent = nullptr); + + void paint(QPainter* painter) override; + + Q_INVOKABLE void advance(qreal dt); + + [[nodiscard]] QVector values() const; + void setValues(const QVector& values); + + [[nodiscard]] QColor primaryColor() const; + void setPrimaryColor(const QColor& color); + + [[nodiscard]] QColor secondaryColor() const; + void setSecondaryColor(const QColor& color); + + [[nodiscard]] qreal rounding() const; + void setRounding(qreal rounding); + + [[nodiscard]] qreal spacing() const; + void setSpacing(qreal spacing); + + [[nodiscard]] int animationDuration() const; + void setAnimationDuration(int duration); + + [[nodiscard]] bool settled() const; + +signals: + void valuesChanged(); + void primaryColorChanged(); + void secondaryColorChanged(); + void roundingChanged(); + void spacingChanged(); + void animationDurationChanged(); + void settledChanged(); + +private: + void drawSide(QPainter* painter, bool rightSide); + + QVector m_targetValues; + QVector m_displayValues; + QColor m_primaryColor; + QColor m_secondaryColor; + qreal m_rounding = 0.0; + qreal m_spacing = 0.0; + int m_animationDuration = 200; + bool m_settled = true; +}; + +} // namespace caelestia::internal diff --git a/plugin/src/Caelestia/Models/filesystemmodel.cpp b/plugin/src/Caelestia/Models/filesystemmodel.cpp index e387ecd00..267a43946 100644 --- a/plugin/src/Caelestia/Models/filesystemmodel.cpp +++ b/plugin/src/Caelestia/Models/filesystemmodel.cpp @@ -57,8 +57,8 @@ bool FileSystemEntry::isImage() const { QString FileSystemEntry::mimeType() const { if (!m_mimeTypeInitialised) { - const QMimeDatabase db; - m_mimeType = db.mimeTypeForFile(m_path).name(); + static const QMimeDatabase s_db; + m_mimeType = s_db.mimeTypeForFile(m_path).name(); m_mimeTypeInitialised = true; } return m_mimeType; @@ -219,7 +219,7 @@ 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]() { + auto future = QtConcurrent::run([showHidden, path]() { QDir::Filters filters = QDir::Dirs | QDir::NoDotAndDotDot; if (showHidden) { filters |= QDir::Hidden; @@ -232,16 +232,12 @@ void FileSystemModel::watchDirIfRecursive(const QString& path) { } return dirs; }); - const auto watcher = new QFutureWatcher(this); - connect(watcher, &QFutureWatcher::finished, this, [currentDir, showHidden, watcher, this]() { - const auto paths = watcher->result(); + future.then(this, [currentDir, showHidden, this](const QStringList& paths) { 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); } } @@ -295,7 +291,7 @@ void FileSystemModel::updateEntriesForDir(const QString& dir) { oldPaths << entry->path(); } - const auto future = QtConcurrent::run([=](QPromise, QSet>>& promise) { + auto future = QtConcurrent::run([=](QPromise, QSet>>& promise) { const auto flags = recursive ? QDirIterator::Subdirectories : QDirIterator::NoIteratorFlags; std::optional iter; @@ -353,7 +349,7 @@ void FileSystemModel::updateEntriesForDir(const QString& dir) { newPaths.insert(path); } - if (promise.isCanceled() || newPaths == oldPaths) { + if (promise.isCanceled()) { return; } @@ -365,23 +361,17 @@ void FileSystemModel::updateEntriesForDir(const QString& dir) { } 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); + future + .then(this, + [dir, this](QPair, QSet> result) { + m_futures.remove(dir); + if (!result.first.isEmpty() || !result.second.isEmpty()) { + applyChanges(result.first, result.second); + } + }) + .onCanceled(this, [dir, this]() { + m_futures.remove(dir); + }); } void FileSystemModel::applyChanges(const QSet& removedPaths, const QSet& addedPaths) { diff --git a/plugin/src/Caelestia/Models/filesystemmodel.hpp b/plugin/src/Caelestia/Models/filesystemmodel.hpp index cf8eae822..c3315858a 100644 --- a/plugin/src/Caelestia/Models/filesystemmodel.hpp +++ b/plugin/src/Caelestia/Models/filesystemmodel.hpp @@ -132,7 +132,7 @@ class FileSystemModel : public QAbstractListModel { bool m_recursive; bool m_watchChanges; bool m_showHidden; - bool m_sortReverse; + bool m_sortReverse = false; Filter m_filter; QStringList m_nameFilters; diff --git a/plugin/src/Caelestia/Services/audiocollector.cpp b/plugin/src/Caelestia/Services/audiocollector.cpp index 15634059e..69309f756 100644 --- a/plugin/src/Caelestia/Services/audiocollector.cpp +++ b/plugin/src/Caelestia/Services/audiocollector.cpp @@ -3,13 +3,16 @@ #include "service.hpp" #include #include -#include +#include #include #include #include #include #include +Q_LOGGING_CATEGORY(lcAc, "caelestia.services.ac", QtInfoMsg) +Q_LOGGING_CATEGORY(lcAcWorker, "caelestia.services.ac.worker", QtInfoMsg) + namespace caelestia::services { PipeWireWorker::PipeWireWorker(std::stop_token token, AudioCollector* collector) @@ -23,13 +26,19 @@ PipeWireWorker::PipeWireWorker(std::stop_token token, AudioCollector* collector) m_loop = pw_main_loop_new(nullptr); if (!m_loop) { - qWarning() << "PipeWireWorker::init: failed to create PipeWire main loop"; + qCWarning(lcAcWorker) << "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); + if (!m_timer) { + qCWarning(lcAcWorker) << "init: failed to create timer"; + pw_main_loop_destroy(m_loop); + pw_deinit(); + return; + } pw_loop_update_timer(pw_main_loop_get_loop(m_loop), m_timer, &timeout, &timeout, false); auto props = pw_properties_new( @@ -55,6 +64,7 @@ PipeWireWorker::PipeWireWorker(std::stop_token token, AudioCollector* collector) params[0] = spa_format_audio_raw_build(&b, SPA_PARAM_EnumFormat, &info); pw_stream_events events{}; + events.version = PW_VERSION_STREAM_EVENTS; events.state_changed = [](void* data, pw_stream_state, pw_stream_state state, const char*) { auto* self = static_cast(data); self->streamStateChanged(state); @@ -65,13 +75,19 @@ PipeWireWorker::PipeWireWorker(std::stop_token token, AudioCollector* collector) }; m_stream = pw_stream_new_simple(pw_main_loop_get_loop(m_loop), "caelestia-shell", props, &events, this); + if (!m_stream) { + qCWarning(lcAcWorker) << "init: failed to create stream"; + pw_main_loop_destroy(m_loop); + pw_deinit(); + return; + } 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"; + qCWarning(lcAcWorker) << "init: failed to connect stream"; pw_stream_destroy(m_stream); pw_main_loop_destroy(m_loop); pw_deinit(); @@ -221,7 +237,7 @@ AudioCollector::AudioCollector(QObject* parent) , m_writeBuffer(&m_buffer2) {} AudioCollector::~AudioCollector() { - stop(); + AudioCollector::stop(); } void AudioCollector::start() { diff --git a/plugin/src/Caelestia/Services/audioprovider.cpp b/plugin/src/Caelestia/Services/audioprovider.cpp index 1fac9eea8..d2916f45e 100644 --- a/plugin/src/Caelestia/Services/audioprovider.cpp +++ b/plugin/src/Caelestia/Services/audioprovider.cpp @@ -2,9 +2,12 @@ #include "audiocollector.hpp" #include "service.hpp" -#include +#include #include +Q_LOGGING_CATEGORY(lcAp, "caelestia.services.ap", QtInfoMsg) +Q_LOGGING_CATEGORY(lcApProcessor, "caelestia.services.ap.processor", QtInfoMsg) + namespace caelestia::services { AudioProcessor::AudioProcessor(QObject* parent) @@ -48,7 +51,7 @@ AudioProvider::~AudioProvider() { void AudioProvider::init() { if (!m_processor) { - qWarning() << "AudioProvider::init: attempted to init with no processor set"; + qCWarning(lcAp) << "init: attempted to init with no processor set"; return; } diff --git a/plugin/src/Caelestia/Services/audioprovider.hpp b/plugin/src/Caelestia/Services/audioprovider.hpp index 5bf9bb00d..4b85929f4 100644 --- a/plugin/src/Caelestia/Services/audioprovider.hpp +++ b/plugin/src/Caelestia/Services/audioprovider.hpp @@ -23,7 +23,7 @@ public slots: virtual void process() = 0; private: - QTimer* m_timer; + QTimer* m_timer = nullptr; }; class AudioProvider : public Service { diff --git a/plugin/src/Caelestia/Services/beattracker.cpp b/plugin/src/Caelestia/Services/beattracker.cpp index 93addc679..649705799 100644 --- a/plugin/src/Caelestia/Services/beattracker.cpp +++ b/plugin/src/Caelestia/Services/beattracker.cpp @@ -19,7 +19,9 @@ BeatProcessor::~BeatProcessor() { if (m_in) { del_fvec(m_in); } - del_fvec(m_out); + if (m_out) { + del_fvec(m_out); + } } void BeatProcessor::process() { diff --git a/plugin/src/Caelestia/Services/cavaprovider.cpp b/plugin/src/Caelestia/Services/cavaprovider.cpp index 7b6cc1f20..a57f4040a 100644 --- a/plugin/src/Caelestia/Services/cavaprovider.cpp +++ b/plugin/src/Caelestia/Services/cavaprovider.cpp @@ -4,7 +4,10 @@ #include "audioprovider.hpp" #include #include -#include +#include + +Q_LOGGING_CATEGORY(lcCava, "caelestia.services.cava", QtInfoMsg) +Q_LOGGING_CATEGORY(lcCavaProcessor, "caelestia.services.cava.processor", QtInfoMsg) namespace caelestia::services { @@ -57,7 +60,7 @@ void CavaProcessor::process() { void CavaProcessor::setBars(int bars) { if (bars < 0) { - qWarning() << "CavaProcessor::setBars: bars must be greater than 0. Setting to 0."; + qCWarning(lcCavaProcessor) << "setBars: bars must be greater than 0. Setting to 0."; bars = 0; } @@ -109,7 +112,7 @@ int CavaProvider::bars() const { void CavaProvider::setBars(int bars) { if (bars < 0) { - qWarning() << "CavaProvider::setBars: bars must be greater than 0. Setting to 0."; + qCWarning(lcCava) << "setBars: bars must be greater than 0. Setting to 0."; bars = 0; } diff --git a/plugin/src/Caelestia/Services/service.cpp b/plugin/src/Caelestia/Services/service.cpp index bc2156717..4e1921e87 100644 --- a/plugin/src/Caelestia/Services/service.cpp +++ b/plugin/src/Caelestia/Services/service.cpp @@ -1,6 +1,5 @@ #include "service.hpp" -#include #include namespace caelestia::services { diff --git a/plugin/src/Caelestia/appdb.cpp b/plugin/src/Caelestia/appdb.cpp index 6e37e16f3..8d80ced97 100644 --- a/plugin/src/Caelestia/appdb.cpp +++ b/plugin/src/Caelestia/appdb.cpp @@ -1,9 +1,12 @@ #include "appdb.hpp" +#include #include #include #include +Q_LOGGING_CATEGORY(lcAppDb, "caelestia.appdb", QtInfoMsg) + namespace caelestia { AppEntry::AppEntry(QObject* entry, unsigned int frequency, QObject* parent) @@ -11,7 +14,7 @@ AppEntry::AppEntry(QObject* entry, unsigned int frequency, QObject* parent) , m_entry(entry) , m_frequency(frequency) { const auto mo = m_entry->metaObject(); - const auto tmo = metaObject(); + const auto tmo = &AppEntry::staticMetaObject; for (const auto& prop : { "name", "comment", "execString", "startupClass", "genericName", "categories", "keywords" }) { @@ -162,6 +165,39 @@ void AppDb::setEntries(const QObjectList& entries) { m_timer->start(); } +QStringList AppDb::favouriteApps() const { + return m_favouriteApps; +} + +void AppDb::setFavouriteApps(const QStringList& favApps) { + if (m_favouriteApps == favApps) { + return; + } + + m_favouriteApps = favApps; + emit favouriteAppsChanged(); + m_favouriteAppsRegex.clear(); + m_favouriteAppsRegex.reserve(m_favouriteApps.size()); + for (const QString& item : std::as_const(m_favouriteApps)) { + const QRegularExpression re(regexifyString(item)); + if (re.isValid()) { + m_favouriteAppsRegex << re; + } else { + qCWarning(lcAppDb) << "setFavouriteApps: regular expression is not valid:" << re.pattern(); + } + } + + emit appsChanged(); +} + +QString AppDb::regexifyString(const QString& original) const { + if (original.startsWith('^') && original.endsWith('$')) + return original; + + const QString escaped = QRegularExpression::escape(original); + return QStringLiteral("^%1$").arg(escaped); +} + QQmlListProperty AppDb::apps() { return QQmlListProperty(this, &getSortedApps()); } @@ -179,28 +215,48 @@ void AppDb::incrementFrequency(const QString& id) { auto* app = m_apps.value(id); if (app) { const auto before = getSortedApps(); - app->incrementFrequency(); - - if (before != getSortedApps()) { + getSortedApps(); + if (before != m_sortedApps) { emit appsChanged(); } } else { - qWarning() << "AppDb::incrementFrequency: could not find app with id" << id; + qCWarning(lcAppDb) << "incrementFrequency: could not find app with id" << id; } } QList& AppDb::getSortedApps() const { m_sortedApps = m_apps.values(); - std::sort(m_sortedApps.begin(), m_sortedApps.end(), [](AppEntry* a, AppEntry* b) { - if (a->frequency() != b->frequency()) { + + // Pre-compute favourite status to avoid repeated regex matching during sort + QSet favSet; + favSet.reserve(m_sortedApps.size()); + for (const auto* app : std::as_const(m_sortedApps)) { + if (isFavourite(app)) + favSet.insert(app->id()); + } + + std::sort(m_sortedApps.begin(), m_sortedApps.end(), [&favSet](AppEntry* a, AppEntry* b) { + const bool aIsFav = favSet.contains(a->id()); + const bool bIsFav = favSet.contains(b->id()); + if (aIsFav != bIsFav) + return aIsFav; + if (a->frequency() != b->frequency()) return a->frequency() > b->frequency(); - } return a->name().localeAwareCompare(b->name()) < 0; }); return m_sortedApps; } +bool AppDb::isFavourite(const AppEntry* app) const { + for (const QRegularExpression& re : m_favouriteAppsRegex) { + if (re.match(app->id()).hasMatch()) { + return true; + } + } + return false; +} + quint32 AppDb::getFrequency(const QString& id) const { auto db = QSqlDatabase::database(m_uuid); QSqlQuery query(db); @@ -222,7 +278,8 @@ void AppDb::updateAppFrequencies() { app->setFrequency(getFrequency(app->id())); } - if (before != getSortedApps()) { + getSortedApps(); + if (before != m_sortedApps) { emit appsChanged(); } } @@ -249,11 +306,13 @@ void AppDb::updateApps() { newIds.insert(entry->property("id").toString()); } - for (auto it = m_apps.keyBegin(); it != m_apps.keyEnd(); ++it) { - const auto& id = *it; - if (!newIds.contains(id)) { + for (auto it = m_apps.begin(); it != m_apps.end();) { + if (!newIds.contains(it.key())) { dirty = true; - m_apps.take(id)->deleteLater(); + it.value()->deleteLater(); + it = m_apps.erase(it); + } else { + ++it; } } diff --git a/plugin/src/Caelestia/appdb.hpp b/plugin/src/Caelestia/appdb.hpp index 5f9b96044..ce5f27062 100644 --- a/plugin/src/Caelestia/appdb.hpp +++ b/plugin/src/Caelestia/appdb.hpp @@ -4,6 +4,7 @@ #include #include #include +#include #include namespace caelestia { @@ -66,6 +67,7 @@ class AppDb : public QObject { Q_PROPERTY(QString uuid READ uuid CONSTANT) Q_PROPERTY(QString path READ path WRITE setPath NOTIFY pathChanged REQUIRED) Q_PROPERTY(QObjectList entries READ entries WRITE setEntries NOTIFY entriesChanged REQUIRED) + Q_PROPERTY(QStringList favouriteApps READ favouriteApps WRITE setFavouriteApps NOTIFY favouriteAppsChanged REQUIRED) Q_PROPERTY(QQmlListProperty apps READ apps NOTIFY appsChanged) public: @@ -79,6 +81,9 @@ class AppDb : public QObject { [[nodiscard]] QObjectList entries() const; void setEntries(const QObjectList& entries); + [[nodiscard]] QStringList favouriteApps() const; + void setFavouriteApps(const QStringList& favApps); + [[nodiscard]] QQmlListProperty apps(); Q_INVOKABLE void incrementFrequency(const QString& id); @@ -86,6 +91,7 @@ class AppDb : public QObject { signals: void pathChanged(); void entriesChanged(); + void favouriteAppsChanged(); void appsChanged(); private: @@ -94,10 +100,14 @@ class AppDb : public QObject { const QString m_uuid; QString m_path; QObjectList m_entries; + QStringList m_favouriteApps; // unedited string list from qml + QList m_favouriteAppsRegex; // pre-regexified m_favouriteApps list QHash m_apps; mutable QList m_sortedApps; + QString regexifyString(const QString& original) const; QList& getSortedApps() const; + bool isFavourite(const AppEntry* app) const; quint32 getFrequency(const QString& id) const; void updateAppFrequencies(); void updateApps(); diff --git a/plugin/src/Caelestia/cutils.cpp b/plugin/src/Caelestia/cutils.cpp index 6e3bfa99c..28a0e1784 100644 --- a/plugin/src/Caelestia/cutils.cpp +++ b/plugin/src/Caelestia/cutils.cpp @@ -6,8 +6,11 @@ #include #include #include +#include #include +Q_LOGGING_CATEGORY(lcCUtils, "caelestia.cutils", QtInfoMsg) + namespace caelestia { void CUtils::saveItem(QQuickItem* target, const QUrl& path) { @@ -32,17 +35,17 @@ void CUtils::saveItem(QQuickItem* target, const QUrl& path, const QRect& rect, Q void CUtils::saveItem(QQuickItem* target, const QUrl& path, const QRect& rect, QJSValue onSaved, QJSValue onFailed) { if (!target) { - qWarning() << "CUtils::saveItem: a target is required"; + qCWarning(lcCUtils) << "saveItem: a target is required"; return; } if (!path.isLocalFile()) { - qWarning() << "CUtils::saveItem:" << path << "is not a local file"; + qCWarning(lcCUtils) << "saveItem:" << path << "is not a local file"; return; } if (!target->window()) { - qWarning() << "CUtils::saveItem: unable to save target" << target << "without a window"; + qCWarning(lcCUtils) << "saveItem: unable to save target" << target << "without a window"; return; } @@ -75,13 +78,20 @@ void CUtils::saveItem(QQuickItem* target, const QUrl& path, const QRect& rect, Q QObject::connect(watcher, &QFutureWatcher::finished, this, [=]() { if (watcher->result()) { if (onSaved.isCallable()) { - onSaved.call( - { QJSValue(path.toLocalFile()), engine->toScriptValue(QVariant::fromValue(path)) }); + QJSValueList args = { QJSValue(path.toLocalFile()) }; + if (engine) { + args << engine->toScriptValue(QVariant::fromValue(path)); + } + onSaved.call(args); } } else { - qWarning() << "CUtils::saveItem: failed to save" << path; + qCWarning(lcCUtils) << "saveItem: failed to save" << path; if (onFailed.isCallable()) { - onFailed.call({ engine->toScriptValue(QVariant::fromValue(path)) }); + if (engine) { + onFailed.call({ engine->toScriptValue(QVariant::fromValue(path)) }); + } else { + onFailed.call(); + } } } watcher->deleteLater(); @@ -92,17 +102,17 @@ void CUtils::saveItem(QQuickItem* target, const QUrl& path, const QRect& rect, Q bool CUtils::copyFile(const QUrl& source, const QUrl& target, bool overwrite) const { if (!source.isLocalFile()) { - qWarning() << "CUtils::copyFile: source" << source << "is not a local file"; + qCWarning(lcCUtils) << "copyFile: source" << source << "is not a local file"; return false; } if (!target.isLocalFile()) { - qWarning() << "CUtils::copyFile: target" << target << "is not a local file"; + qCWarning(lcCUtils) << "copyFile: target" << target << "is not a local file"; return false; } if (overwrite && QFile::exists(target.toLocalFile())) { if (!QFile::remove(target.toLocalFile())) { - qWarning() << "CUtils::copyFile: overwrite was specified but failed to remove" << target.toLocalFile(); + qCWarning(lcCUtils) << "copyFile: overwrite was specified but failed to remove" << target.toLocalFile(); return false; } } @@ -112,7 +122,7 @@ bool CUtils::copyFile(const QUrl& source, const QUrl& target, bool overwrite) co bool CUtils::deleteFile(const QUrl& path) const { if (!path.isLocalFile()) { - qWarning() << "CUtils::deleteFile: path" << path << "is not a local file"; + qCWarning(lcCUtils) << "deleteFile: path" << path << "is not a local file"; return false; } @@ -121,7 +131,7 @@ bool CUtils::deleteFile(const QUrl& path) const { QString CUtils::toLocalFile(const QUrl& url) const { if (!url.isLocalFile()) { - qWarning() << "CUtils::toLocalFile: given url is not a local file" << url; + qCWarning(lcCUtils) << "toLocalFile: given url is not a local file" << url; return QString(); } diff --git a/plugin/src/Caelestia/imageanalyser.cpp b/plugin/src/Caelestia/imageanalyser.cpp index 880b0785c..7f3ba5261 100644 --- a/plugin/src/Caelestia/imageanalyser.cpp +++ b/plugin/src/Caelestia/imageanalyser.cpp @@ -4,8 +4,11 @@ #include #include #include +#include #include +Q_LOGGING_CATEGORY(lcImageAnalyser, "caelestia.imageanalyser", QtInfoMsg) + namespace caelestia { ImageAnalyser::ImageAnalyser(QObject* parent) @@ -134,6 +137,11 @@ void ImageAnalyser::update() { if (m_sourceItem) { const QSharedPointer grabResult = m_sourceItem->grabToImage(); + if (!grabResult) { + QObject::connect(m_sourceItem, &QQuickItem::windowChanged, this, &ImageAnalyser::requestUpdate, + Qt::SingleShotConnection); + return; + } QObject::connect(grabResult.data(), &QQuickItemGrabResult::ready, this, [grabResult, this]() { m_futureWatcher->setFuture(QtConcurrent::run(&ImageAnalyser::analyse, grabResult->image(), m_rescaleSize)); }); @@ -147,7 +155,7 @@ void ImageAnalyser::update() { void ImageAnalyser::analyse(QPromise& promise, const QImage& image, int rescaleSize) { if (image.isNull()) { - qWarning() << "ImageAnalyser::analyse: image is null"; + qCWarning(lcImageAnalyser) << "analyse: image is null"; return; } @@ -191,14 +199,14 @@ void ImageAnalyser::analyse(QPromise& promise, const QImage& imag continue; } - const quint32 mr = static_cast(pixel[0] & 0xF8); + const quint32 mr = static_cast(pixel[2] & 0xF8); const quint32 mg = static_cast(pixel[1] & 0xF8); - const quint32 mb = static_cast(pixel[2] & 0xF8); + const quint32 mb = static_cast(pixel[0] & 0xF8); ++colours[(mr << 16) | (mg << 8) | mb]; - const qreal r = pixel[0] / 255.0; + const qreal r = pixel[2] / 255.0; const qreal g = pixel[1] / 255.0; - const qreal b = pixel[2] / 255.0; + const qreal b = pixel[0] / 255.0; totalLuminance += std::sqrt(0.299 * r * r + 0.587 * g * g + 0.114 * b * b); ++count; } diff --git a/plugin/src/Caelestia/imageanalyser.hpp b/plugin/src/Caelestia/imageanalyser.hpp index bbea2b32e..63fbf9691 100644 --- a/plugin/src/Caelestia/imageanalyser.hpp +++ b/plugin/src/Caelestia/imageanalyser.hpp @@ -4,6 +4,7 @@ #include #include #include +#include #include namespace caelestia { @@ -48,7 +49,7 @@ class ImageAnalyser : public QObject { QFutureWatcher* const m_futureWatcher; QString m_source; - QQuickItem* m_sourceItem; + QPointer m_sourceItem; int m_rescaleSize; QColor m_dominantColour; diff --git a/plugin/src/Caelestia/qalculator.cpp b/plugin/src/Caelestia/qalculator.cpp index 44e8d21e2..c72421793 100644 --- a/plugin/src/Caelestia/qalculator.cpp +++ b/plugin/src/Caelestia/qalculator.cpp @@ -1,13 +1,19 @@ #include "qalculator.hpp" #include +#include namespace caelestia { +QMutex Qalculator::s_calculatorMutex; + Qalculator::Qalculator(QObject* parent) : QObject(parent) { if (!CALCULATOR) { - new Calculator(); + // Calculator constructor sets the global `calculator` pointer (CALCULATOR macro), + // but we need to assign it to a var so compiler doesn't flag it as a leak + static const auto* const instance = new Calculator(); + Q_UNUSED(instance) CALCULATOR->loadExchangeRates(); CALCULATOR->loadGlobalDefinitions(); CALCULATOR->loadLocalDefinitions(); @@ -19,6 +25,8 @@ QString Qalculator::eval(const QString& expr, bool printExpr) const { return QString(); } + QMutexLocker locker(&s_calculatorMutex); + EvaluationOptions eo; PrintOptions po; @@ -49,4 +57,92 @@ QString Qalculator::eval(const QString& expr, bool printExpr) const { return QString::fromStdString(result); } +void Qalculator::evalAsync(const QString& expr) { + const quint64 gen = ++m_generation; + + if (expr.isEmpty()) { + if (!m_result.isEmpty()) { + m_result.clear(); + emit resultChanged(); + } + if (!m_rawResult.isEmpty()) { + m_rawResult.clear(); + emit rawResultChanged(); + } + if (m_busy) { + m_busy = false; + emit busyChanged(); + } + return; + } + + if (!m_busy) { + m_busy = true; + emit busyChanged(); + } + + QtConcurrent::run([expr]() -> QPair { + QMutexLocker locker(&s_calculatorMutex); + + EvaluationOptions eo; + PrintOptions po; + + std::string parsed; + std::string result = CALCULATOR->calculateAndPrint( + CALCULATOR->unlocalizeExpression(expr.toStdString(), eo.parse_options), 100, eo, po, &parsed); + + std::string error; + while (CALCULATOR->message()) { + if (!CALCULATOR->message()->message().empty()) { + if (CALCULATOR->message()->type() == MESSAGE_ERROR) { + error += "error: "; + } else if (CALCULATOR->message()->type() == MESSAGE_WARNING) { + error += "warning: "; + } + error += CALCULATOR->message()->message(); + } + CALCULATOR->nextMessage(); + } + + if (!error.empty()) { + const QString errorStr = QString::fromStdString(error); + return { errorStr, errorStr }; + } + + const QString rawStr = QString::fromStdString(result); + return { QString("%1 = %2").arg(parsed).arg(result), rawStr }; + }).then(this, [this, gen](QPair result) { + if (gen != m_generation) { + return; + } + + const auto& [formatted, raw] = result; + + if (m_result != formatted) { + m_result = formatted; + emit resultChanged(); + } + if (m_rawResult != raw) { + m_rawResult = raw; + emit rawResultChanged(); + } + if (m_busy) { + m_busy = false; + emit busyChanged(); + } + }); +} + +QString Qalculator::result() const { + return m_result; +} + +QString Qalculator::rawResult() const { + return m_rawResult; +} + +bool Qalculator::busy() const { + return m_busy; +} + } // namespace caelestia diff --git a/plugin/src/Caelestia/qalculator.hpp b/plugin/src/Caelestia/qalculator.hpp index a07a8a2fc..b2f5517f7 100644 --- a/plugin/src/Caelestia/qalculator.hpp +++ b/plugin/src/Caelestia/qalculator.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -10,10 +11,32 @@ class Qalculator : public QObject { QML_ELEMENT QML_SINGLETON + Q_PROPERTY(QString result READ result NOTIFY resultChanged) + Q_PROPERTY(QString rawResult READ rawResult NOTIFY rawResultChanged) + Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) + public: explicit Qalculator(QObject* parent = nullptr); Q_INVOKABLE QString eval(const QString& expr, bool printExpr = true) const; + Q_INVOKABLE void evalAsync(const QString& expr); + + [[nodiscard]] QString result() const; + [[nodiscard]] QString rawResult() const; + [[nodiscard]] bool busy() const; + +signals: + void resultChanged(); + void rawResultChanged(); + void busyChanged(); + +private: + static QMutex s_calculatorMutex; + + QString m_result; + QString m_rawResult; + bool m_busy = false; + quint64 m_generation = 0; }; } // namespace caelestia diff --git a/plugin/src/Caelestia/requests.cpp b/plugin/src/Caelestia/requests.cpp index 2ceddb351..862dea7ff 100644 --- a/plugin/src/Caelestia/requests.cpp +++ b/plugin/src/Caelestia/requests.cpp @@ -1,22 +1,41 @@ #include "requests.hpp" +#include +#include #include +#include #include #include +Q_LOGGING_CATEGORY(lcRequests, "caelestia.requests", QtInfoMsg) + namespace caelestia { Requests::Requests(QObject* parent) : QObject(parent) , m_manager(new QNetworkAccessManager(this)) {} -void Requests::get(const QUrl& url, QJSValue onSuccess, QJSValue onError) const { +void Requests::get(const QUrl& url, QJSValue onSuccess, QJSValue onError, QJSValue headers) const { if (!onSuccess.isCallable()) { - qWarning() << "Requests::get: onSuccess is not callable"; + qCWarning(lcRequests) << "get: onSuccess is not callable"; return; } QNetworkRequest request(url); + request.setAttribute(QNetworkRequest::CacheLoadControlAttribute, QNetworkRequest::AlwaysNetwork); + request.setAttribute(QNetworkRequest::CookieSaveControlAttribute, QNetworkRequest::Manual); + request.setRawHeader("Cache-Control", "no-cache, no-store"); + request.setRawHeader("Pragma", "no-cache"); + request.setRawHeader("Connection", "close"); + + if (headers.isObject()) { + QJSValueIterator it(headers); + while (it.hasNext()) { + it.next(); + request.setRawHeader(it.name().toUtf8(), it.value().toString().toUtf8()); + } + } + auto reply = m_manager->get(request); QObject::connect(reply, &QNetworkReply::finished, [reply, onSuccess, onError]() { @@ -25,11 +44,15 @@ void Requests::get(const QUrl& url, QJSValue onSuccess, QJSValue onError) const } else if (onError.isCallable()) { onError.call({ reply->errorString() }); } else { - qWarning() << "Requests::get: request failed with error" << reply->errorString(); + qCWarning(lcRequests) << "get: request failed with error" << reply->errorString(); } reply->deleteLater(); }); } +void Requests::resetCookies() const { + m_manager->setCookieJar(new QNetworkCookieJar(m_manager)); +} + } // namespace caelestia diff --git a/plugin/src/Caelestia/requests.hpp b/plugin/src/Caelestia/requests.hpp index 1db2f4cf6..d07d7e8f0 100644 --- a/plugin/src/Caelestia/requests.hpp +++ b/plugin/src/Caelestia/requests.hpp @@ -14,7 +14,9 @@ class Requests : public QObject { public: explicit Requests(QObject* parent = nullptr); - Q_INVOKABLE void get(const QUrl& url, QJSValue callback, QJSValue onError = QJSValue()) const; + Q_INVOKABLE void get( + const QUrl& url, QJSValue callback, QJSValue onError = QJSValue(), QJSValue headers = QJSValue()) const; + Q_INVOKABLE void resetCookies() const; private: QNetworkAccessManager* m_manager; diff --git a/plugin/src/Caelestia/toaster.cpp b/plugin/src/Caelestia/toaster.cpp index 978805de0..b51c77f8d 100644 --- a/plugin/src/Caelestia/toaster.cpp +++ b/plugin/src/Caelestia/toaster.cpp @@ -1,6 +1,5 @@ #include "toaster.hpp" -#include #include #include diff --git a/scripts/qml-lint-conventions.py b/scripts/qml-lint-conventions.py new file mode 100755 index 000000000..cef46657c --- /dev/null +++ b/scripts/qml-lint-conventions.py @@ -0,0 +1,648 @@ +#!/usr/bin/env python3 +"""Checks QML files for Qt coding convention violations. + +https://doc.qt.io/qt-6/qml-codingconventions.html + +Required ordering within each QML object (with blank line between sections): + 1. id + 2. property declarations + 3. signal declarations + 4. JavaScript functions + 5. object properties (bindings) + 6. child objects + 7. component definitions +""" + +import re +import sys +from enum import IntEnum +from pathlib import Path + +RED = "\033[0;31m" +YELLOW = "\033[0;33m" +CYAN = "\033[0;36m" +GREEN = "\033[0;32m" +MAGENTA = "\033[0;35m" +BOLD = "\033[1m" +RESET = "\033[0m" + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +class Section(IntEnum): + ID = 0 + PROPERTY = 1 + SIGNAL = 2 + FUNCTION = 3 + BINDING = 4 + CHILD = 5 + COMPONENT_DEF = 6 + + +SECTION_NAMES = { + Section.ID: "id", + Section.PROPERTY: "property declarations", + Section.SIGNAL: "signal declarations", + Section.FUNCTION: "functions", + Section.BINDING: "bindings", + Section.CHILD: "child objects", + Section.COMPONENT_DEF: "component definitions", +} + +RULE_COLOURS = { + "file-structure": RED, + "import-order": GREEN, + "section-order": YELLOW, + "missing-section-separator": CYAN, + "blank-after-open-brace": MAGENTA, + "blank-before-close-brace": MAGENTA, +} + +IMPORT_RE = re.compile(r"^import\s+(\S+)") + + +def import_group(module: str) -> tuple[int, int] | None: + """Return (group, depth) for a module import, or None to skip.""" + if module.startswith('"'): + return None + depth = module.count(".") + 1 + if module == "QtQuick" or module.startswith("QtQuick."): + return (1, depth) + if module.startswith("Qt"): + return (2, depth) + if module == "Quickshell" or module.startswith("Quickshell."): + return (3, depth) + if module == "M3Shapes": + return (4, depth) + if module == "Caelestia" or module.startswith("Caelestia."): + return (5, depth) + if module == "qs.components" or module.startswith("qs.components."): + return (6, depth) + if module == "qs.services": + return (7, depth) + if module == "qs.config": + return (8, depth) + if module == "qs.utils": + return (9, depth) + if module == "qs.modules" or module.startswith("qs.modules."): + return (10, depth) + return None + + +def parse_imports(lines: list[str]) -> tuple[int | None, int | None, list[str], list[tuple[str, int, int, str]]]: + """Parse the import block, returning (first_idx, last_idx, relative_imports, module_imports). + + module_imports entries are (line_text, group, depth, module_name). + """ + first_import = None + last_import = None + relative_imports: list[str] = [] + module_imports: list[tuple[str, int, int, str]] = [] + + for i, line in enumerate(lines): + stripped = line.strip() + if not stripped or stripped.startswith("//") or stripped.startswith("pragma "): + continue + m = IMPORT_RE.match(stripped) + if m: + if first_import is None: + first_import = i + last_import = i + module = m.group(1) + result = import_group(module) + if result is None: + relative_imports.append(line) + else: + group, depth = result + module_imports.append((line, group, depth, module)) + continue + break + + return first_import, last_import, relative_imports, module_imports + + +def check_imports(filepath: Path, lines: list[str], rel: str) -> list[Violation]: + """Check that module imports are in the required order.""" + violations = [] + _, _, _, module_imports = parse_imports(lines) + imports = [(i, *entry) for i, entry in enumerate(module_imports)] + + for j in range(1, len(imports)): + _, prev_line, prev_group, prev_depth, prev_mod = imports[j - 1] + _, curr_line, curr_group, curr_depth, curr_mod = imports[j] + + # Find actual line number for the current import + lineno = 0 + count = 0 + for li, line in enumerate(lines): + stripped = line.strip() + m = IMPORT_RE.match(stripped) + if m and import_group(m.group(1)) is not None: + if count == j: + lineno = li + 1 + break + count += 1 + + if curr_group < prev_group: + violations.append( + Violation( + rel, + lineno, + "import-order", + f"'{curr_mod}' should appear before '{prev_mod}'", + ) + ) + elif curr_group == prev_group and curr_depth < prev_depth: + violations.append( + Violation( + rel, + lineno, + "import-order", + f"'{curr_mod}' should appear before '{prev_mod}' (less nested first)", + ) + ) + + return violations + + +def fix_imports(lines: list[str]) -> list[str]: + """Sort imports and return the modified lines.""" + first, last, relative, module = parse_imports(lines) + if first is None: + return lines + + module.sort(key=lambda x: (x[1], x[2], x[3])) + sorted_imports = relative + [entry[0] for entry in module] + return lines[:first] + sorted_imports + lines[last + 1 :] + + +def check_file_structure(lines: list[str], rel: str) -> list[Violation]: + """Check file-level structure: pragmas, then imports, then content.""" + violations = [] + pragma_indices: list[int] = [] + import_indices: list[int] = [] + content_start: int | None = None + + for i, line in enumerate(lines): + stripped = line.strip() + if not stripped: + continue + if stripped.startswith("pragma "): + pragma_indices.append(i) + elif IMPORT_RE.match(stripped): + import_indices.append(i) + else: + content_start = i + break + + # Pragmas must come before imports + if pragma_indices and import_indices: + if pragma_indices[-1] > import_indices[0]: + violations.append( + Violation(rel, pragma_indices[-1] + 1, "file-structure", "pragmas should appear before imports") + ) + + # Separator between pragmas and imports + if pragma_indices and import_indices: + gap = import_indices[0] - pragma_indices[-1] - 1 + if gap == 0: + violations.append( + Violation( + rel, import_indices[0] + 1, "file-structure", "blank line expected between pragmas and imports" + ) + ) + elif gap > 1: + violations.append( + Violation( + rel, + pragma_indices[-1] + 3, + "file-structure", + "only one blank line expected between pragmas and imports", + ) + ) + + # No blank lines within imports + for j in range(1, len(import_indices)): + if import_indices[j] != import_indices[j - 1] + 1: + for gap_line in range(import_indices[j - 1] + 1, import_indices[j]): + if not lines[gap_line].strip(): + violations.append( + Violation(rel, gap_line + 1, "file-structure", "no blank lines expected within imports") + ) + + # Separator between imports/pragmas and content + last_header = import_indices[-1] if import_indices else (pragma_indices[-1] if pragma_indices else None) + if last_header is not None and content_start is not None: + gap = content_start - last_header - 1 + label = "imports" if import_indices else "pragmas" + if gap == 0: + violations.append( + Violation( + rel, content_start + 1, "file-structure", f"blank line expected between {label} and content" + ) + ) + elif gap > 1: + violations.append( + Violation( + rel, + last_header + 3, + "file-structure", + f"only one blank line expected between {label} and content", + ) + ) + + return violations + + +def fix_file_structure(lines: list[str]) -> list[str]: + """Ensure correct file structure: pragmas, blank, imports (no gaps), blank, content.""" + pragmas: list[str] = [] + imports: list[str] = [] + content_start: int | None = None + + for i, line in enumerate(lines): + stripped = line.strip() + if not stripped: + continue + if stripped.startswith("pragma "): + pragmas.append(line) + elif IMPORT_RE.match(stripped): + imports.append(line) + else: + content_start = i + break + + if content_start is None: + content_start = len(lines) + + # Skip any blank lines at the start of content + while content_start < len(lines) and not lines[content_start].strip(): + content_start += 1 + + result: list[str] = [] + has_content = content_start < len(lines) + if pragmas: + result.extend(pragmas) + if imports or has_content: + result.append("") + if imports: + result.extend(imports) + if has_content: + result.append("") + result.extend(lines[content_start:]) + return result + + +def fix_section_separators(lines: list[str]) -> list[str]: + """Insert blank lines between different sections and return modified lines.""" + insertions: list[int] = [] + scopes: dict[str, ScopeTracker] = {} + in_block_comment = False + func_skip_depth = 0 + prev_blank: dict[str, bool] = {} + + for i, line in enumerate(lines): + stripped = line.strip() + indent = get_indent(line) + + if in_block_comment: + if BLOCK_COMMENT_END.search(stripped): + in_block_comment = False + continue + if BLOCK_COMMENT_START.search(stripped) and not BLOCK_COMMENT_END.search(stripped): + in_block_comment = True + continue + + if not stripped: + for key in prev_blank: + prev_blank[key] = True + continue + + if COMMENT_LINE_RE.match(stripped): + continue + + if func_skip_depth > 0: + func_skip_depth += stripped.count("{") - stripped.count("}") + if func_skip_depth <= 0: + func_skip_depth = 0 + continue + + if stripped == "}": + to_remove = [k for k in scopes if len(k) > len(indent)] + for k in to_remove: + del scopes[k] + prev_blank.pop(k, None) + continue + + section = classify_line(stripped) + if section is None: + continue + + if indent not in scopes: + scopes[indent] = ScopeTracker() + prev_blank[indent] = True + + tracker = scopes[indent] + had_blank = prev_blank.get(indent, True) + + if tracker.last_section is not None and section != tracker.last_section and not had_blank: + insertions.append(i) + + if tracker.last_section is None or section >= tracker.last_section: + tracker.last_section = section + tracker.last_section_line = i + 1 + + prev_blank[indent] = False + + brace_count = stripped.count("{") - stripped.count("}") + if brace_count > 0 and section == Section.FUNCTION: + func_skip_depth = brace_count + if brace_count > 0 and section == Section.BINDING: + colon_idx = stripped.index(":") + after_colon = stripped[colon_idx + 1 :].strip() + if not re.match(r"^[A-Z]", after_colon): + func_skip_depth = brace_count + if brace_count > 0 and section in (Section.CHILD, Section.COMPONENT_DEF): + to_remove = [k for k in scopes if len(k) > len(indent)] + for k in to_remove: + del scopes[k] + prev_blank.pop(k, None) + + result = list(lines) + for idx in reversed(insertions): + result.insert(idx, "") + return result + + +def fix_file(filepath: Path) -> bool: + """Fix auto-fixable violations. Returns True if file was modified.""" + try: + text = filepath.read_text() + except (OSError, UnicodeDecodeError): + return False + + lines = text.splitlines() + lines = fix_imports(lines) + lines = fix_file_structure(lines) + lines = fix_section_separators(lines) + new_text = "\n".join(lines) + if text.endswith("\n"): + new_text += "\n" + if new_text != text: + filepath.write_text(new_text) + return True + return False + + +# Regexes +PROPERTY_DECL_RE = re.compile(r"^(?:required\s+|readonly\s+|default\s+)*property\s") +SIGNAL_RE = re.compile(r"^signal\s") +FUNCTION_RE = re.compile(r"^function\s") +ID_RE = re.compile(r"^id\s*:\s*[a-zA-Z_]\w*\s*$") +ENUM_RE = re.compile(r"^enum\s") +COMPONENT_DEF_RE = re.compile(r"^component\s+\w+\s*:") +COMMENT_LINE_RE = re.compile(r"^//") +BLOCK_COMMENT_START = re.compile(r"/\*") +BLOCK_COMMENT_END = re.compile(r"\*/") +BINDING_RE = re.compile(r"^[a-z][a-zA-Z0-9_.]*\s*:") +SIGNAL_HANDLER_RE = re.compile(r"^on[A-Z][a-zA-Z]*\s*:") +# Child object: starts with uppercase or is a known child-like pattern +CHILD_OBJECT_RE = re.compile(r"^[A-Z][a-zA-Z0-9_.]*\s*\{") +# Inline component: Component { ... } +INLINE_COMPONENT_RE = re.compile(r"^Component\s*\{") +# Behavior on {, NumberAnimation on {, etc. +BEHAVIOR_ON_RE = re.compile(r"^[A-Z]\w+\s+on\s+\w[\w.]*\s*\{") +# Attached signal handler: Component.onCompleted:, Drag.onDragStarted:, etc. +ATTACHED_HANDLER_RE = re.compile(r"^[A-Z]\w+\.on[A-Z]\w*\s*:") + + +class Violation: + def __init__(self, file: str, line: int, rule: str, msg: str): + self.file = file + self.line = line + self.rule = rule + self.msg = msg + + def __str__(self): + c = RULE_COLOURS.get(self.rule, "") + return f"{c}[{self.rule}]{RESET} {self.file}:{self.line}: {self.msg}" + + +class ScopeTracker: + """Tracks the current section and last-seen state for one indent level.""" + + def __init__(self): + self.last_section: Section | None = None + self.last_section_line: int = 0 + self.had_blank_before_current: bool = True # no separator needed at start + + +def get_indent(line: str) -> str: + return line[: len(line) - len(line.lstrip())] + + +def classify_line(stripped: str) -> Section | None: + """Classify a stripped QML line into a section category.""" + if ID_RE.match(stripped): + return Section.ID + if PROPERTY_DECL_RE.match(stripped): + return Section.PROPERTY + if SIGNAL_RE.match(stripped): + return Section.SIGNAL + if FUNCTION_RE.match(stripped): + return Section.FUNCTION + if ENUM_RE.match(stripped): + return Section.PROPERTY # enums go with declarations + if COMPONENT_DEF_RE.match(stripped): + return Section.COMPONENT_DEF + if BEHAVIOR_ON_RE.match(stripped): + return Section.CHILD + if CHILD_OBJECT_RE.match(stripped): + return Section.CHILD + if INLINE_COMPONENT_RE.match(stripped): + return Section.CHILD + if BINDING_RE.match(stripped) or SIGNAL_HANDLER_RE.match(stripped): + return Section.BINDING + if ATTACHED_HANDLER_RE.match(stripped): + return Section.BINDING + return None + + +def check_file(filepath: Path) -> list[Violation]: + violations = [] + rel = str(filepath.relative_to(REPO_ROOT)) + + try: + lines = filepath.read_text().splitlines() + except (OSError, UnicodeDecodeError): + return violations + + violations.extend(check_file_structure(lines, rel)) + violations.extend(check_imports(filepath, lines, rel)) + + scopes: dict[str, ScopeTracker] = {} # indent -> tracker + in_block_comment = False + func_skip_depth = 0 # brace depth for skipping function bodies only + prev_blank: dict[str, bool] = {} # indent -> was previous relevant line a blank? + + for i, line in enumerate(lines): + lineno = i + 1 + stripped = line.strip() + indent = get_indent(line) + + # Handle block comments + if in_block_comment: + if BLOCK_COMMENT_END.search(stripped): + in_block_comment = False + continue + if BLOCK_COMMENT_START.search(stripped) and not BLOCK_COMMENT_END.search(stripped): + in_block_comment = True + continue + + # Track blank lines per indent + if not stripped: + # Check: blank line right after opening brace of a QML object + if i > 0 and func_skip_depth == 0 and not in_block_comment and lines[i - 1].strip().endswith("{"): + violations.append( + Violation( + rel, + lineno, + "blank-after-open-brace", + "no blank line expected after opening brace", + ) + ) + for key in prev_blank: + prev_blank[key] = True + continue + + # Skip line comments + if COMMENT_LINE_RE.match(stripped): + continue + + # Skip inside function bodies (JS code, not QML structure) + if func_skip_depth > 0: + func_skip_depth += stripped.count("{") - stripped.count("}") + if func_skip_depth <= 0: + func_skip_depth = 0 + continue + + # Closing brace: pop all scopes deeper than this indent + # (the scope at this indent belongs to the parent object and must persist) + if stripped == "}": + # Check: blank line right before closing brace + if i > 0 and not lines[i - 1].strip(): + violations.append( + Violation( + rel, + lineno, + "blank-before-close-brace", + "no blank line expected before closing brace", + ) + ) + to_remove = [k for k in scopes if len(k) > len(indent)] + for k in to_remove: + del scopes[k] + prev_blank.pop(k, None) + continue + + section = classify_line(stripped) + if section is None: + continue + + # Get or create scope tracker for this indent + if indent not in scopes: + scopes[indent] = ScopeTracker() + prev_blank[indent] = True # treat start of object as having separator + + tracker = scopes[indent] + had_blank = prev_blank.get(indent, True) + + # --- Check 1: Section ordering --- + if tracker.last_section is not None and section < tracker.last_section: + violations.append( + Violation( + rel, + lineno, + "section-order", + f"{SECTION_NAMES[section]} should appear before " + f"{SECTION_NAMES[tracker.last_section]} " + f"(seen at line {tracker.last_section_line})", + ) + ) + + # --- Check 2: Missing blank line between different sections --- + if tracker.last_section is not None and section != tracker.last_section and not had_blank: + violations.append( + Violation( + rel, + lineno, + "missing-section-separator", + f"blank line expected between {SECTION_NAMES[tracker.last_section]} and {SECTION_NAMES[section]}", + ) + ) + + # Update tracker + if tracker.last_section is None or section >= tracker.last_section: + tracker.last_section = section + tracker.last_section_line = lineno + + prev_blank[indent] = False + + # Skip function bodies (they contain JS, not QML structure) + brace_count = stripped.count("{") - stripped.count("}") + if brace_count > 0 and section == Section.FUNCTION: + func_skip_depth = brace_count + + # Skip JS blocks in bindings (signal handlers, attached handlers, + # and expression blocks like `color: { ... }`) + if brace_count > 0 and section == Section.BINDING: + colon_idx = stripped.index(":") + after_colon = stripped[colon_idx + 1 :].strip() + # If content after : doesn't start with an uppercase type name, + # it's a JS block (not an inline QML object like `contentItem: Rect {`) + if not re.match(r"^[A-Z]", after_colon): + func_skip_depth = brace_count + + # Child object/component opening resets deeper scopes + if brace_count > 0 and section in (Section.CHILD, Section.COMPONENT_DEF): + to_remove = [k for k in scopes if len(k) > len(indent)] + for k in to_remove: + del scopes[k] + prev_blank.pop(k, None) + + return violations + + +def main(): + fix_mode = "--fix" in sys.argv + qml_files = sorted(p for p in REPO_ROOT.rglob("*.qml") if "build" not in p.parts) + + if fix_mode: + fixed = sum(1 for f in qml_files if fix_file(f)) + print(f"{BOLD}Fixed {fixed} file(s).{RESET}\n") + + print(f"{BOLD}Checking {len(qml_files)} QML files for convention violations...{RESET}\n") + + all_violations: list[Violation] = [] + for f in qml_files: + all_violations.extend(check_file(f)) + + for v in all_violations: + print(v) + + print() + if all_violations: + by_rule: dict[str, int] = {} + for v in all_violations: + by_rule[v.rule] = by_rule.get(v.rule, 0) + 1 + for rule, count in sorted(by_rule.items()): + print(f" {RULE_COLOURS.get(rule, '')}{rule}{RESET}: {count}") + print(f"\n{BOLD}Found {len(all_violations)} violation(s).{RESET}") + return 1 + else: + print(f"{BOLD}No violations found.{RESET}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/services/Audio.qml b/services/Audio.qml index 908d1563c..6268b92ed 100644 --- a/services/Audio.qml +++ b/services/Audio.qml @@ -1,11 +1,12 @@ pragma Singleton -import qs.config -import Caelestia.Services -import Caelestia +import QtQuick import Quickshell +import Quickshell.Io import Quickshell.Services.Pipewire -import QtQuick +import Caelestia +import Caelestia.Config +import Caelestia.Services Singleton { id: root @@ -13,26 +14,9 @@ Singleton { property string previousSinkName: "" property string previousSourceName: "" - readonly property var nodes: Pipewire.nodes.values.reduce((acc, node) => { - if (!node.isStream) { - if (node.isSink) - acc.sinks.push(node); - else if (node.audio) - acc.sources.push(node); - } else if (node.isStream && node.audio) { - // Application streams (output streams) - acc.streams.push(node); - } - return acc; - }, { - sources: [], - sinks: [], - streams: [] - }) - - readonly property list sinks: nodes.sinks - readonly property list sources: nodes.sources - readonly property list streams: nodes.streams + property list sinks: [] + property list sources: [] + property list streams: [] readonly property PwNode sink: Pipewire.defaultAudioSink readonly property PwNode source: Pipewire.defaultAudioSource @@ -49,31 +33,31 @@ Singleton { function setVolume(newVolume: real): void { if (sink?.ready && sink?.audio) { sink.audio.muted = false; - sink.audio.volume = Math.max(0, Math.min(Config.services.maxVolume, newVolume)); + sink.audio.volume = Math.max(0, Math.min(GlobalConfig.services.maxVolume, newVolume)); } } function incrementVolume(amount: real): void { - setVolume(volume + (amount || Config.services.audioIncrement)); + setVolume(volume + (amount || GlobalConfig.services.audioIncrement)); } function decrementVolume(amount: real): void { - setVolume(volume - (amount || Config.services.audioIncrement)); + setVolume(volume - (amount || GlobalConfig.services.audioIncrement)); } function setSourceVolume(newVolume: real): void { if (source?.ready && source?.audio) { source.audio.muted = false; - source.audio.volume = Math.max(0, Math.min(Config.services.maxVolume, newVolume)); + source.audio.volume = Math.max(0, Math.min(GlobalConfig.services.maxVolume, newVolume)); } } function incrementSourceVolume(amount: real): void { - setSourceVolume(sourceVolume + (amount || Config.services.audioIncrement)); + setSourceVolume(sourceVolume + (amount || GlobalConfig.services.audioIncrement)); } function decrementSourceVolume(amount: real): void { - setSourceVolume(sourceVolume - (amount || Config.services.audioIncrement)); + setSourceVolume(sourceVolume - (amount || GlobalConfig.services.audioIncrement)); } function setAudioSink(newSink: PwNode): void { @@ -84,10 +68,19 @@ Singleton { Pipewire.preferredDefaultAudioSource = newSource; } + function cycleNextAudioOutput(): void { + if (sinks.length === 0) + return; + + const currentIndex = sinks.findIndex(s => s === sink); + const nextIndex = (currentIndex + 1) % sinks.length; + setAudioSink(sinks[nextIndex]); + } + function setStreamVolume(stream: PwNode, newVolume: real): void { if (stream?.ready && stream?.audio) { stream.audio.muted = false; - stream.audio.volume = Math.max(0, Math.min(Config.services.maxVolume, newVolume)); + stream.audio.volume = Math.max(0, Math.min(GlobalConfig.services.maxVolume, newVolume)); } } @@ -109,7 +102,7 @@ Singleton { if (!stream) return qsTr("Unknown"); // Try application name first, then description, then name - return stream.applicationName || stream.description || stream.name || qsTr("Unknown Application"); + return stream.properties["application.name"] || stream.description || stream.name || qsTr("Unknown Application"); } onSinkChanged: { @@ -118,7 +111,7 @@ Singleton { const newSinkName = sink.description || sink.name || qsTr("Unknown Device"); - if (previousSinkName && previousSinkName !== newSinkName && Config.utilities.toasts.audioOutputChanged) + if (previousSinkName && previousSinkName !== newSinkName && GlobalConfig.utilities.toasts.audioOutputChanged) Toaster.toast(qsTr("Audio output changed"), qsTr("Now using: %1").arg(newSinkName), "volume_up"); previousSinkName = newSinkName; @@ -130,7 +123,7 @@ Singleton { const newSourceName = source.description || source.name || qsTr("Unknown Device"); - if (previousSourceName && previousSourceName !== newSourceName && Config.utilities.toasts.audioInputChanged) + if (previousSourceName && previousSourceName !== newSourceName && GlobalConfig.utilities.toasts.audioInputChanged) Toaster.toast(qsTr("Audio input changed"), qsTr("Now using: %1").arg(newSourceName), "mic"); previousSourceName = newSourceName; @@ -141,6 +134,31 @@ Singleton { previousSourceName = source?.description || source?.name || qsTr("Unknown Device"); } + Connections { + function onValuesChanged(): void { + const newSinks = []; + const newSources = []; + const newStreams = []; + + for (const node of Pipewire.nodes.values) { + if (!node.isStream) { + if (node.isSink) + newSinks.push(node); + else if (node.audio) + newSources.push(node); + } else if (node.audio) { + newStreams.push(node); + } + } + + root.sinks = newSinks; + root.sources = newSources; + root.streams = newStreams; + } + + target: Pipewire.nodes + } + PwObjectTracker { objects: [...root.sinks, ...root.sources, ...root.streams] } @@ -148,10 +166,18 @@ Singleton { CavaProvider { id: cava - bars: Config.services.visualiserBars + bars: GlobalConfig.services.visualiserBars } BeatTracker { id: beatTracker } + + IpcHandler { + function cycleOutput(): void { + root.cycleNextAudioOutput(); + } + + target: "audio" + } } diff --git a/services/Brightness.qml b/services/Brightness.qml index 12920eedd..ac34cbc1c 100644 --- a/services/Brightness.qml +++ b/services/Brightness.qml @@ -1,56 +1,62 @@ pragma Singleton pragma ComponentBehavior: Bound -import qs.config -import qs.components.misc +import QtQuick import Quickshell import Quickshell.Io -import QtQuick +import Caelestia.Config +import qs.components.misc Singleton { id: root property list ddcMonitors: [] - readonly property list monitors: variants.instances + readonly property var ddcMonitorMap: { + const map = {}; + for (const m of ddcMonitors) + map[m.connector] = m; + return map; + } + readonly property list monitors: variants.instances // qmllint disable incompatible-type property bool appleDisplayPresent: false function getMonitorForScreen(screen: ShellScreen): var { - return monitors.find(m => m.modelData === screen); + return monitors.find(m => m.modelData === screen); // qmllint disable missing-property } function getMonitor(query: string): var { if (query === "active") { - return monitors.find(m => Hypr.monitorFor(m.modelData)?.focused); + return monitors.find(m => Hypr.monitorFor(m.modelData)?.focused); // qmllint disable missing-property } if (query.startsWith("model:")) { const model = query.slice(6); - return monitors.find(m => m.modelData.model === model); + return monitors.find(m => m.modelData.model === model); // qmllint disable missing-property } if (query.startsWith("serial:")) { const serial = query.slice(7); - return monitors.find(m => m.modelData.serialNumber === serial); + return monitors.find(m => m.modelData.serialNumber === serial); // qmllint disable missing-property } if (query.startsWith("id:")) { const id = parseInt(query.slice(3), 10); - return monitors.find(m => Hypr.monitorFor(m.modelData)?.id === id); + return monitors.find(m => Hypr.monitorFor(m.modelData)?.id === id); // qmllint disable missing-property } - return monitors.find(m => m.modelData.name === query); + return monitors.find(m => m.modelData.name === query); // qmllint disable missing-property } function increaseBrightness(): void { const monitor = getMonitor("active"); if (monitor) - monitor.setBrightness(monitor.brightness + Config.services.brightnessIncrement); + monitor.setBrightness(monitor.brightness + GlobalConfig.services.brightnessIncrement); } function decreaseBrightness(): void { const monitor = getMonitor("active"); if (monitor) - monitor.setBrightness(monitor.brightness - Config.services.brightnessIncrement); + monitor.setBrightness(monitor.brightness - GlobalConfig.services.brightnessIncrement); } onMonitorsChanged: { @@ -61,7 +67,7 @@ Singleton { Variants { id: variants - model: Quickshell.screens + model: Quickshell.screens // Don't respect excluded screens cause ipc Monitor {} } @@ -86,21 +92,23 @@ Singleton { } } + // qmllint disable unresolved-type CustomShortcut { + // qmllint enable unresolved-type name: "brightnessUp" description: "Increase brightness" onPressed: root.increaseBrightness() } + // qmllint disable unresolved-type CustomShortcut { + // qmllint enable unresolved-type name: "brightnessDown" description: "Decrease brightness" onPressed: root.decreaseBrightness() } IpcHandler { - target: "brightness" - function get(): real { return getFor("active"); } @@ -149,14 +157,17 @@ Singleton { return `Set monitor ${monitor.modelData.name} brightness to ${+monitor.brightness.toFixed(2)}`; } + + target: "brightness" } component Monitor: QtObject { id: monitor required property ShellScreen modelData - readonly property bool isDdc: root.ddcMonitors.some(m => m.connector === modelData.name) - readonly property string busNum: root.ddcMonitors.find(m => m.connector === modelData.name)?.busNum ?? "" + readonly property var ddcInfo: root.ddcMonitorMap[modelData.name] ?? null + readonly property bool isDdc: ddcInfo !== null + readonly property string busNum: ddcInfo?.busNum ?? "" readonly property bool isAppleDisplay: root.appleDisplayPresent && modelData.model.startsWith("StudioDisplay") property real brightness property real queuedBrightness: NaN diff --git a/services/Colours.qml b/services/Colours.qml index cd86c8fbf..922b51dbc 100644 --- a/services/Colours.qml +++ b/services/Colours.qml @@ -1,12 +1,13 @@ pragma Singleton pragma ComponentBehavior: Bound -import qs.config -import qs.utils -import Caelestia +import QtQuick import Quickshell import Quickshell.Io -import QtQuick +import Caelestia +import Caelestia.Config +import qs.services +import qs.utils Singleton { id: root @@ -78,6 +79,21 @@ Singleton { Quickshell.execDetached(["caelestia", "scheme", "set", "--notify", "-m", mode]); } + function reloadHyprRules(): void { + const str = "keyword layerrule %1 %2, match:namespace caelestia-drawers"; + Hypr.extras.batchMessage([str.arg("blur").arg(transparency.enabled ? 1 : 0), str.arg("ignore_alpha").arg(transparency.base - 0.03)]); + } + + Component.onCompleted: debounceTimer.triggered() + + Connections { + function onConfigReloaded(): void { + root.reloadHyprRules(); + } + + target: Hypr + } + FileView { path: `${Paths.state}/scheme.json` watchChanges: true @@ -91,10 +107,20 @@ Singleton { source: Wallpapers.current } + Timer { + id: debounceTimer + + interval: 300 + onTriggered: root.reloadHyprRules() + } + component Transparency: QtObject { - readonly property bool enabled: Appearance.transparency.enabled - readonly property real base: Appearance.transparency.base - (root.light ? 0.1 : 0) - readonly property real layers: Appearance.transparency.layers + readonly property bool enabled: Tokens.transparency.enabled + readonly property real base: Math.max(0, Math.min(1, Tokens.transparency.base - (root.light ? 0.1 : 0))) + readonly property real layers: Tokens.transparency.layers + + onEnabledChanged: debounceTimer.restart() + onBaseChanged: debounceTimer.restart() } component M3TPalette: QtObject { diff --git a/services/GameMode.qml b/services/GameMode.qml index 83770b79f..6dfc791c5 100644 --- a/services/GameMode.qml +++ b/services/GameMode.qml @@ -1,11 +1,11 @@ pragma Singleton -import qs.services -import qs.config -import Caelestia +import QtQuick import Quickshell import Quickshell.Io -import QtQuick +import Caelestia +import Caelestia.Config +import qs.services Singleton { id: root @@ -28,11 +28,11 @@ Singleton { onEnabledChanged: { if (enabled) { setDynamicConfs(); - if (Config.utilities.toasts.gameModeChanged) + if (GlobalConfig.utilities.toasts.gameModeChanged) Toaster.toast(qsTr("Game mode enabled"), qsTr("Disabled Hyprland animations, blur, gaps and shadows"), "gamepad"); } else { Hypr.extras.message("reload"); - if (Config.utilities.toasts.gameModeChanged) + if (GlobalConfig.utilities.toasts.gameModeChanged) Toaster.toast(qsTr("Game mode disabled"), qsTr("Hyprland settings restored"), "gamepad"); } } @@ -40,23 +40,21 @@ Singleton { PersistentProperties { id: props - property bool enabled: Hypr.options["animations:enabled"] === 0 + property bool enabled: Hypr.options["animations:enabled"] === 0 // qmllint disable missing-property reloadableId: "gameMode" } Connections { - target: Hypr - function onConfigReloaded(): void { if (props.enabled) root.setDynamicConfs(); } + + target: Hypr } IpcHandler { - target: "gameMode" - function isEnabled(): bool { return props.enabled; } @@ -72,5 +70,7 @@ Singleton { function disable(): void { props.enabled = false; } + + target: "gameMode" } } diff --git a/services/Hypr.qml b/services/Hypr.qml index a654fdd34..181967e78 100644 --- a/services/Hypr.qml +++ b/services/Hypr.qml @@ -1,13 +1,13 @@ pragma Singleton -import qs.components.misc -import qs.config -import Caelestia -import Caelestia.Internal +import QtQuick import Quickshell import Quickshell.Hyprland import Quickshell.Io -import QtQuick +import Caelestia +import Caelestia.Config +import Caelestia.Internal +import qs.components.misc Singleton { id: root @@ -16,7 +16,10 @@ Singleton { readonly property var workspaces: Hyprland.workspaces readonly property var monitors: Hyprland.monitors - readonly property HyprlandToplevel activeToplevel: Hyprland.activeToplevel?.wayland?.activated ? Hyprland.activeToplevel : null + readonly property HyprlandToplevel activeToplevel: { + const t = Hyprland.activeToplevel; + return t?.workspace?.name.startsWith("special:") || Hyprland.focusedWorkspace?.toplevels.values.length > 0 ? t : null; + } readonly property HyprlandWorkspace focusedWorkspace: Hyprland.focusedWorkspace readonly property HyprlandMonitor focusedMonitor: Hyprland.focusedMonitor readonly property int activeWsId: focusedWorkspace?.id ?? 1 @@ -34,6 +37,7 @@ Singleton { readonly property alias devices: extras.devices property bool hadKeyboard + property string lastSpecialWorkspace: "" signal configReloaded @@ -41,6 +45,43 @@ Singleton { Hyprland.dispatch(request); } + function cycleSpecialWorkspace(direction: string): void { + const openSpecials = workspaces.values.filter(w => w.name.startsWith("special:") && w.lastIpcObject.windows > 0); + + if (openSpecials.length === 0) + return; + + const activeSpecial = focusedMonitor.lastIpcObject.specialWorkspace.name ?? ""; + + if (!activeSpecial) { + if (lastSpecialWorkspace) { + const workspace = workspaces.values.find(w => w.name === lastSpecialWorkspace); + if (workspace && workspace.lastIpcObject.windows > 0) { + dispatch(`workspace ${lastSpecialWorkspace}`); + return; + } + } + dispatch(`workspace ${openSpecials[0].name}`); + return; + } + + const currentIndex = openSpecials.findIndex(w => w.name === activeSpecial); + let nextIndex = 0; + + if (currentIndex !== -1) { + if (direction === "next") + nextIndex = (currentIndex + 1) % openSpecials.length; + else + nextIndex = (currentIndex - 1 + openSpecials.length) % openSpecials.length; + } + + dispatch(`workspace ${openSpecials[nextIndex].name}`); + } + + function monitorNames(): list { + return monitors.values.map(e => e.name); + } + function monitorFor(screen: ShellScreen): HyprlandMonitor { return Hyprland.monitorFor(screen); } @@ -52,7 +93,7 @@ Singleton { Component.onCompleted: reloadDynamicConfs() onCapsLockChanged: { - if (!Config.utilities.toasts.capsLockChanged) + if (!GlobalConfig.utilities.toasts.capsLockChanged) return; if (capsLock) @@ -62,7 +103,7 @@ Singleton { } onNumLockChanged: { - if (!Config.utilities.toasts.numLockChanged) + if (!GlobalConfig.utilities.toasts.numLockChanged) return; if (numLock) @@ -72,15 +113,13 @@ Singleton { } onKbLayoutFullChanged: { - if (hadKeyboard && Config.utilities.toasts.kbLayoutChanged) + if (hadKeyboard && GlobalConfig.utilities.toasts.kbLayoutChanged) Toaster.toast(qsTr("Keyboard layout changed"), qsTr("Layout changed to: %1").arg(kbLayoutFull), "keyboard"); hadKeyboard = !!keyboard; } Connections { - target: Hyprland - function onRawEvent(event: HyprlandEvent): void { const n = event.name; if (n.endsWith("v2")) @@ -103,6 +142,20 @@ Singleton { Hyprland.refreshToplevels(); } } + + target: Hyprland + } + + Connections { + function onLastIpcObjectChanged(): void { + const specialName = root.focusedMonitor.lastIpcObject.specialWorkspace.name; + + if (specialName && specialName.startsWith("special:")) { + root.lastSpecialWorkspace = specialName; + } + } + + target: root.focusedMonitor } FileView { @@ -139,14 +192,24 @@ Singleton { } IpcHandler { - target: "hypr" - function refreshDevices(): void { extras.refreshDevices(); } + + function cycleSpecialWorkspace(direction: string): void { + root.cycleSpecialWorkspace(direction); + } + + function listSpecialWorkspaces(): string { + return root.workspaces.values.filter(w => w.name.startsWith("special:") && w.lastIpcObject.windows > 0).map(w => w.name).join("\n"); + } + + target: "hypr" } + // qmllint disable unresolved-type CustomShortcut { + // qmllint enable unresolved-type name: "refreshDevices" description: "Reload devices" onPressed: extras.refreshDevices() diff --git a/services/Hyprctl.qml b/services/Hyprctl.qml new file mode 100644 index 000000000..8e49d3808 --- /dev/null +++ b/services/Hyprctl.qml @@ -0,0 +1,47 @@ +pragma Singleton + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + property list monitors: [] + + function update(): void { + proc.running = true; + } + + readonly property Process proc: Process { + command: ["hyprctl", "monitors", "-j"] + stdout: StdioCollector { + onStreamFinished: { + try { + root.monitors = JSON.parse(text); + } catch (e) { + console.error("Hyprctl: failed to parse monitors JSON", e); + } + } + } + } + + Timer { + interval: 2000 + running: true + repeat: true + onTriggered: root.update() + } + + Component.onCompleted: update() + + // Refresh when Hyprland reports changes + Connections { + target: Hyprland + function onRawEvent(event: HyprlandEvent): void { + if (event.name.includes("mon")) { + root.update(); + } + } + } +} diff --git a/services/IdleInhibitor.qml b/services/IdleInhibitor.qml index 29409abc1..9f556b3a4 100644 --- a/services/IdleInhibitor.qml +++ b/services/IdleInhibitor.qml @@ -35,8 +35,6 @@ Singleton { } IpcHandler { - target: "idleInhibitor" - function isEnabled(): bool { return props.enabled; } @@ -52,5 +50,7 @@ Singleton { function disable(): void { props.enabled = false; } + + target: "idleInhibitor" } } diff --git a/services/LyricsService.qml b/services/LyricsService.qml new file mode 100644 index 000000000..26a75e9b5 --- /dev/null +++ b/services/LyricsService.qml @@ -0,0 +1,403 @@ +pragma Singleton + +import "../utils/scripts/lrcparser.js" as Lrc +import QtQuick +import Quickshell +import Quickshell.Io +import Caelestia +import Caelestia.Config +import qs.utils + +Singleton { + id: root + + property var player: Players.active + property int currentIndex: -1 + property bool loading: false + property bool isManualSeeking: false + property bool lyricsVisible: GlobalConfig.services.showLyrics + property string backend: "Local" + property string preferredBackend: GlobalConfig.services.lyricsBackend + property real currentSongId: 0 + property string loadedLocalFile: "" + property real offset + property int currentRequestId: 0 + property var lyricsMap: ({}) + + readonly property string lyricsDir: Paths.absolutePath(GlobalConfig.paths.lyricsDir) + readonly property string lyricsMapFile: lyricsDir + "/lyrics_map.json" + readonly property alias model: lyricsModel + readonly property alias candidatesModel: fetchedCandidatesModel + readonly property var _netEaseHeaders: ({ + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0", + "Referer": "https://music.163.com/" + }) + + function getMetadata() { + if (!player || !player.metadata) + return null; + let artist = player.metadata["xesam:artist"]; + const title = player.metadata["xesam:title"]; + if (Array.isArray(artist)) + artist = artist.join(", "); + return { + artist: artist || "Unknown", + title: title || "Unknown" + }; + } + + function _metaKey(meta) { + return `${meta.artist} - ${meta.title}`; + } + + function savePrefs() { + let meta = getMetadata(); + if (!meta) + return; + let key = _metaKey(meta); + let existing = root.lyricsMap[key] ?? {}; + root.lyricsMap[key] = { + offset: root.offset, + backend: root.backend, + neteaseId: existing.neteaseId ?? null + }; + // reassign to notify QML bindings of the map change + root.lyricsMap = root.lyricsMap; + saveLyricsMap.command = ["sh", "-c", `mkdir -p "${root.lyricsDir}" && echo '${JSON.stringify(root.lyricsMap).replace(/'/g, "'\\''")}' > "${root.lyricsMapFile}"`]; + saveLyricsMap.running = true; + } + + function toggleVisibility() { + GlobalConfig.services.showLyrics = !GlobalConfig.services.showLyrics; + } + + function loadLyrics() { + loadDebounce.restart(); + } + + function _doLoadLyrics() { + const meta = getMetadata(); + if (!meta) { + lyricsModel.clear(); + root.currentIndex = -1; + return; + } + + loading = true; + lyricsModel.clear(); + currentIndex = -1; + root.currentSongId = 0; + + root.currentRequestId++; + let requestId = root.currentRequestId; + + let key = _metaKey(meta); + let saved = root.lyricsMap[key]; + root.offset = saved?.offset ?? 0.0; + + if (root.preferredBackend === "NetEase") { + root.backend = "NetEase"; + fetchNetEase(meta.title, meta.artist, requestId); + return; + } + + if (root.preferredBackend === "Local") { + root.backend = "Local"; + let cleanDir = lyricsDir.replace(/\/$/, ""); + let flatPath = `${cleanDir}/${meta.artist} - ${meta.title}.lrc`; + + // Search for files matching "Artist - Title.lrc" pattern + const artistStr = Array.isArray(meta.artist) ? meta.artist.join(", ") : String(meta.artist || ""); + const titleStr = Array.isArray(meta.title) ? meta.title.join(", ") : String(meta.title || ""); + const escapedTitle = titleStr.replace(/'/g, "'\\''"); + const escapedArtist = artistStr.replace(/'/g, "'\\''"); + findLyricsInSubdirs.command = ["sh", "-c", `find "${cleanDir}" -type f -iname "*${escapedArtist}*${escapedTitle}*.lrc" | head -n 1`]; + findLyricsInSubdirs.requestId = requestId; + findLyricsInSubdirs.running = true; + + lrcFile.path = ""; + lrcFile.path = flatPath; + return; + } + + // Auto mode: try local first + root.backend = "Local"; + let cleanDir = lyricsDir.replace(/\/$/, ""); + let flatPath = `${cleanDir}/${meta.artist} - ${meta.title}.lrc`; + + const artistStr = Array.isArray(meta.artist) ? meta.artist.join(", ") : String(meta.artist || ""); + const titleStr = Array.isArray(meta.title) ? meta.title.join(", ") : String(meta.title || ""); + const escapedTitle = titleStr.replace(/'/g, "'\\''"); + const escapedArtist = artistStr.replace(/'/g, "'\\''"); + findLyricsInSubdirs.command = ["sh", "-c", `find "${cleanDir}" -type f -iname "*${escapedArtist}*${escapedTitle}*.lrc" | head -n 1`]; + findLyricsInSubdirs.requestId = requestId; + findLyricsInSubdirs.running = true; + + lrcFile.path = ""; + lrcFile.path = flatPath; + fetchNetEaseCandidates(meta.title, meta.artist, requestId); + } + + function updateModel(parsedArray) { + root.currentIndex = -1; + lyricsModel.clear(); + for (let line of parsedArray) { + lyricsModel.append({ + time: line.time, + lyricLine: line.text + }); + } + } + + function fallbackToOnline() { + let meta = getMetadata(); + if (!meta) + return; + fetchNetEase(meta.title, meta.artist, root.currentRequestId); + } + + // NetEase + + // searches NetEase and populates the candidates model. returns the result array via the onResults callback + function _searchNetEase(title, artist, reqId, onResults) { + Requests.resetCookies(); + const query = encodeURIComponent(`${title} ${artist}`); + const url = `https://music.163.com/api/search/get?s=${query}&type=1&limit=5`; + + Requests.get(url, text => { + if (reqId !== root.currentRequestId) + return; + const res = JSON.parse(text); + const songs = res.result?.songs || []; + + fetchedCandidatesModel.clear(); + for (let s of songs) { + fetchedCandidatesModel.append({ + id: s.id, + title: s.name || "Unknown Title", + artist: s.artists?.map(a => a.name).join(", ") || "Unknown Artist" + }); + } + + onResults(songs); + }, err => {}, root._netEaseHeaders); + } + + // populates the candidates model only. used when a saved NetEase ID already exists and we just want to refresh the picker list. + function fetchNetEaseCandidates(title, artist, reqId) { + _searchNetEase(title, artist, reqId, _songs => {}); + } + + // searches NetEase, populates candidates, then auto-selects the best match and fetches its lyrics. + function fetchNetEase(title, artist, reqId) { + _searchNetEase(title, artist, reqId, songs => { + const bestMatch = songs.find(s => { + const inputArtist = String(artist || "").toLowerCase(); + const sArtist = String(s.artists?.[0]?.name || "").toLowerCase(); + return inputArtist.includes(sArtist) || sArtist.includes(inputArtist); + }); + + if (!bestMatch) { + return; // No reliable lyrics found + } + + let key = `${artist} - ${title}`; + root.lyricsMap[key] = { + offset: root.lyricsMap[key]?.offset ?? 0.0, + backend: "NetEase", + neteaseId: bestMatch.id + }; + root.currentSongId = bestMatch.id; + savePrefs(); + fetchNetEaseLyrics(bestMatch.id, reqId); + }); + } + + function fetchNetEaseLyrics(id, reqId) { + const url = `https://music.163.com/api/song/lyric?id=${id}&lv=1&kv=1&tv=-1`; + Requests.get(url, text => { + if (reqId !== root.currentRequestId) + return; + const res = JSON.parse(text); + if (res.lrc?.lyric) { + updateModel(Lrc.parseLrc(res.lrc.lyric)); + loading = false; + } + }); + } + + function selectCandidate(songId) { + let meta = getMetadata(); + if (!meta) + return; + root.backend = "NetEase"; + root.currentSongId = songId; + let key = _metaKey(meta); + root.lyricsMap[key] = { + offset: root.lyricsMap[key]?.offset ?? 0.0, + neteaseId: songId + }; + savePrefs(); + fetchNetEaseLyrics(songId, currentRequestId); + } + + function updatePosition() { + if (isManualSeeking || loading || !player || lyricsModel.count === 0) + return; + + let pos = player.position - root.offset; + let newIdx = -1; + for (let i = lyricsModel.count - 1; i >= 0; i--) { + if (pos >= lyricsModel.get(i).time - 0.1) { // 100ms fudge factor + newIdx = i; + break; + } + } + + if (newIdx !== currentIndex) { + root.currentIndex = newIdx; + } + } + + function jumpTo(index, time) { + root.isManualSeeking = true; + root.currentIndex = index; + + if (player) { + player.position = time + root.offset + 0.01; // compensate for rounding + } + + seekTimer.restart(); + } + + onPreferredBackendChanged: { + if (GlobalConfig.services.lyricsBackend !== preferredBackend) { + GlobalConfig.services.lyricsBackend = preferredBackend; + } + } + + ListModel { + id: lyricsModel + } + + ListModel { + id: fetchedCandidatesModel + } + + Timer { + id: seekTimer + + interval: 500 + onTriggered: root.isManualSeeking = false + } + + // If no local lyrics were loaded within the interval, fall back to NetEase + Timer { + id: fallbackTimer + + interval: 200 + onTriggered: { + if (lyricsModel.count === 0) { + root.backend = "NetEase"; + fallbackToOnline(); + } + } + } + + Timer { + id: loadDebounce + + interval: 50 + onTriggered: root._doLoadLyrics() + } + + FileView { + id: lyricsMapFileView + + path: root.lyricsMapFile + printErrors: false + onLoaded: { + try { + root.lyricsMap = JSON.parse(text()); + } catch (e) { + root.lyricsMap = {}; + } + } + } + + FileView { + id: lrcFile + + printErrors: false + onLoaded: { + fallbackTimer.stop(); + let parsed = Lrc.parseLrc(text()); + if (parsed.length > 0) { + root.backend = "Local"; + root.loadedLocalFile = path; + updateModel(parsed); + loading = false; + } else if (root.preferredBackend === "Local") { + // Local mode only - fail immediately + root.backend = "NetEase"; + fallbackToOnline(); + } + // In Auto mode, let the Process onExited handle fallback + } + } + + Connections { + function onActiveChanged() { + root.player = Players.active; + loadLyrics(); + } + + target: Players + } + + Connections { + function onMetadataChanged() { + loadLyrics(); + } + + target: root.player + ignoreUnknownSignals: true + } + + Process { + id: saveLyricsMap + + command: ["sh", "-c", `mkdir -p "${root.lyricsDir}" && echo '${JSON.stringify(root.lyricsMap)}' > "${root.lyricsMapFile}"`] + } + + Process { + id: findLyricsInSubdirs + + property int requestId: -1 + property bool foundFile: false + + stdout: SplitParser { + onRead: data => { + if (findLyricsInSubdirs.requestId === root.currentRequestId) { + const foundPath = data.trim(); + if (foundPath && foundPath.length > 0) { + findLyricsInSubdirs.foundFile = true; + fallbackTimer.stop(); + root.loadedLocalFile = foundPath; + lrcFile.path = ""; + lrcFile.path = foundPath; + } + } + } + } + + onExited: (exitCode, exitStatus) => { // qmllint disable signal-handler-parameters + if (requestId === root.currentRequestId && !foundFile && root.preferredBackend === "Auto") { + if (lyricsModel.count === 0) { + fallbackTimer.restart(); + } + } + foundFile = false; + } + } +} diff --git a/services/Monitors.qml b/services/Monitors.qml new file mode 100644 index 000000000..c880ada5e --- /dev/null +++ b/services/Monitors.qml @@ -0,0 +1,122 @@ +pragma Singleton + +import qs.services +import Quickshell +import QtQuick + +Singleton { + id: root + + property bool identifying: false + + // Auto-dismiss identify overlay after 5 seconds + Timer { + id: identifyTimer + interval: 5000 + onTriggered: root.identifying = false + } + + function toggleIdentification(): void { + identifying = !identifying; + if (identifying) + identifyTimer.restart(); + else + identifyTimer.stop(); + } + + function stopIdentification(): void { + identifying = false; + identifyTimer.stop(); + } + + function sourceMonitors(): var { + if ((Hyprctl.monitors?.length ?? 0) > 0) + return Hyprctl.monitors; + return Hypr.monitors.values ?? []; + } + + // Safely iterate monitor data — .find() doesn't work on UntypedObjectModel + function findMonitorByName(name: string): var { + const monitors = sourceMonitors(); + for (let i = 0; i < monitors.length; i++) { + if (monitors[i].name === name) + return monitors[i]; + } + return null; + } + + function findMonitorById(id: int): var { + const monitors = sourceMonitors(); + for (let i = 0; i < monitors.length; i++) { + if (monitors[i].id === id) + return monitors[i]; + } + return null; + } + + // Build the monitor string Hyprland expects: + // NAME,WIDTHxHEIGHT@RATE,XxY,SCALE[,transform,N] + function monitorStr(mon: var, overrideScale: real, overrideTransform: int, overrideRefreshRate: real): string { + const scale = overrideScale >= 0 ? overrideScale : (mon.scale || 1); + const transform = overrideTransform >= 0 ? overrideTransform : (mon.transform || 0); + const rr = (overrideRefreshRate > 0 ? overrideRefreshRate : (mon.refreshRate || 60)).toFixed(3); + let s = `${mon.name},${mon.width}x${mon.height}@${rr},${mon.x}x${mon.y},${scale}`; + if (transform !== 0) + s += `,transform,${transform}`; + return s; + } + + // Use batchMessage (hyprctl keyword), NOT dispatch (hyprctl dispatch) + // "keyword" is a config command, not a dispatcher action. + function sendKeyword(monStr: string): void { + Hypr.extras.batchMessage([`keyword monitor ${monStr}`]); + Hyprctl.update(); + } + + function arrange(monitorName: string, pos: string, relativeToId: int): void { + const target = findMonitorById(relativeToId); + const moving = findMonitorByName(monitorName); + if (!target || !moving) return; + + let x = target.x; + let y = target.y; + + const targetW = Math.round(target.width / (target.scale || 1)); + const targetH = Math.round(target.height / (target.scale || 1)); + const movingW = Math.round(moving.width / (moving.scale || 1)); + const movingH = Math.round(moving.height / (moving.scale || 1)); + + if (pos === "left") x -= movingW; + else if (pos === "right") x += targetW; + else if (pos === "top") y -= movingH; + else if (pos === "bottom") y += targetH; + + sendKeyword(monitorStr(moving, moving.scale || 1, moving.transform || 0, moving.refreshRate || 60) + .replace(`${moving.x}x${moving.y}`, `${Math.round(x)}x${Math.round(y)}`)); + } + + function rotate(monitorName: string, angle: int): void { + const mon = findMonitorByName(monitorName); + if (!mon) return; + + let transform = 0; + if (angle === 90) transform = 1; + else if (angle === 180) transform = 2; + else if (angle === 270) transform = 3; + + sendKeyword(monitorStr(mon, mon.scale || 1, transform, mon.refreshRate || 60)); + } + + function setScale(monitorName: string, scale: real): void { + const mon = findMonitorByName(monitorName); + if (!mon) return; + const s = Math.max(0.5, Math.min(3.0, scale)); + sendKeyword(monitorStr(mon, s, mon.transform || 0, mon.refreshRate || 60)); + } + + function setRefreshRate(monitorName: string, refreshRate: real): void { + const mon = findMonitorByName(monitorName); + if (!mon) return; + sendKeyword(monitorStr(mon, mon.scale || 1, mon.transform || 0, Math.max(1, refreshRate))); + } +} diff --git a/services/Network.qml b/services/Network.qml index f3dfc3ea1..4e0b809ca 100644 --- a/services/Network.qml +++ b/services/Network.qml @@ -1,44 +1,28 @@ pragma Singleton +import QtQuick import Quickshell import Quickshell.Io -import QtQuick import qs.services Singleton { id: root - Component.onCompleted: { - // Trigger ethernet device detection after initialization - Qt.callLater(() => { - getEthernetDevices(); - }); - // Load saved connections on startup - Nmcli.loadSavedConnections(() => { - root.savedConnections = Nmcli.savedConnections; - root.savedConnectionSsids = Nmcli.savedConnectionSsids; - }); - // Get initial WiFi status - Nmcli.getWifiStatus(enabled => { - root.wifiEnabled = enabled; - }); - // Sync networks from Nmcli on startup - Qt.callLater(() => { - syncNetworksFromNmcli(); - }, 100); - } - readonly property list networks: [] readonly property AccessPoint active: networks.find(n => n.active) ?? null property bool wifiEnabled: true readonly property bool scanning: Nmcli.scanning - property list ethernetDevices: [] readonly property var activeEthernet: ethernetDevices.find(d => d.connected) ?? null property int ethernetDeviceCount: 0 property bool ethernetProcessRunning: false property var ethernetDeviceDetails: null property var wirelessDeviceDetails: null + property var pendingConnection: null + property list savedConnections: [] + property list savedConnectionSsids: [] + + signal connectionFailed(string ssid) function enableWifi(enabled: bool): void { Nmcli.enableWifi(enabled, result => { @@ -66,9 +50,6 @@ Singleton { Nmcli.rescanWifi(); } - property var pendingConnection: null - signal connectionFailed(string ssid) - function connectToNetwork(ssid: string, password: string, bssid: string, callback: var): void { // Set up pending connection tracking if callback provided if (callback) { @@ -159,20 +140,6 @@ Singleton { }); } - property list savedConnections: [] - property list savedConnectionSsids: [] - - // Sync saved connections from Nmcli when they're updated - Connections { - target: Nmcli - function onSavedConnectionsChanged() { - root.savedConnections = Nmcli.savedConnections; - } - function onSavedConnectionSsidsChanged() { - root.savedConnectionSsids = Nmcli.savedConnectionSsids; - } - } - function syncNetworksFromNmcli(): void { const rNetworks = root.networks; const nNetworks = Nmcli.networks; @@ -217,22 +184,6 @@ Singleton { } } - component AccessPoint: QtObject { - required property var lastIpcObject - readonly property string ssid: lastIpcObject.ssid - readonly property string bssid: lastIpcObject.bssid - readonly property int strength: lastIpcObject.strength - readonly property int frequency: lastIpcObject.frequency - readonly property bool active: lastIpcObject.active - readonly property string security: lastIpcObject.security - readonly property bool isSecure: security.length > 0 - } - - Component { - id: apComp - AccessPoint {} - } - function hasSavedProfile(ssid: string): bool { // Use Nmcli's hasSavedProfile which has the same logic return Nmcli.hasSavedProfile(ssid); @@ -309,16 +260,73 @@ Singleton { return octets.join("."); } + Component.onCompleted: { + // Trigger ethernet device detection after initialization + Qt.callLater(() => { + getEthernetDevices(); + }); + // Load saved connections on startup + Nmcli.loadSavedConnections(() => { + root.savedConnections = Nmcli.savedConnections; + root.savedConnectionSsids = Nmcli.savedConnectionSsids; + }); + // Get initial WiFi status + Nmcli.getWifiStatus(enabled => { + root.wifiEnabled = enabled; + }); + // Sync networks from Nmcli on startup + Qt.callLater(() => { + syncNetworksFromNmcli(); + }, 100); + } + + // Sync saved connections from Nmcli when they're updated + Connections { + function onSavedConnectionsChanged() { + root.savedConnections = Nmcli.savedConnections; + } + + function onSavedConnectionSsidsChanged() { + root.savedConnectionSsids = Nmcli.savedConnectionSsids; + } + + target: Nmcli + } + + Timer { + id: monitorDebounce + + interval: 200 + onTriggered: { + Nmcli.getNetworks(() => { + syncNetworksFromNmcli(); + }); + getEthernetDevices(); + } + } + Process { running: true command: ["nmcli", "m"] stdout: SplitParser { - onRead: { - Nmcli.getNetworks(() => { - syncNetworksFromNmcli(); - }); - getEthernetDevices(); - } + onRead: monitorDebounce.start() } } + + Component { + id: apComp + + AccessPoint {} + } + + component AccessPoint: QtObject { + required property var lastIpcObject + readonly property string ssid: lastIpcObject.ssid + readonly property string bssid: lastIpcObject.bssid + readonly property int strength: lastIpcObject.strength + readonly property int frequency: lastIpcObject.frequency + readonly property bool active: lastIpcObject.active + readonly property string security: lastIpcObject.security + readonly property bool isSecure: security.length > 0 + } } diff --git a/services/NetworkUsage.qml b/services/NetworkUsage.qml new file mode 100644 index 000000000..6c4dc8d25 --- /dev/null +++ b/services/NetworkUsage.qml @@ -0,0 +1,229 @@ +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Io +import Caelestia.Config +import Caelestia.Internal + +Singleton { + id: root + + property int refCount: 0 + + // Current speeds in bytes per second + readonly property real downloadSpeed: _downloadSpeed + readonly property real uploadSpeed: _uploadSpeed + + // Total bytes transferred since tracking started + readonly property real downloadTotal: _downloadTotal + readonly property real uploadTotal: _uploadTotal + + // History buffers for sparkline + readonly property CircularBuffer downloadBuffer: _downloadBuffer + readonly property CircularBuffer uploadBuffer: _uploadBuffer + readonly property int historyLength: 30 + + // Private properties + property real _downloadSpeed: 0 + property real _uploadSpeed: 0 + property real _downloadTotal: 0 + property real _uploadTotal: 0 + + // Previous readings for calculating speed + property real _prevRxBytes: 0 + property real _prevTxBytes: 0 + property real _prevTimestamp: 0 + + // Initial readings for calculating totals + property real _initialRxBytes: 0 + property real _initialTxBytes: 0 + property bool _initialized: false + + function formatBytes(bytes: real): var { + // Handle negative or invalid values + if (bytes < 0 || isNaN(bytes) || !isFinite(bytes)) { + return { + value: 0, + unit: "B/s" + }; + } + + if (bytes < 1024) { + return { + value: bytes, + unit: "B/s" + }; + } else if (bytes < 1024 * 1024) { + return { + value: bytes / 1024, + unit: "KB/s" + }; + } else if (bytes < 1024 * 1024 * 1024) { + return { + value: bytes / (1024 * 1024), + unit: "MB/s" + }; + } else { + return { + value: bytes / (1024 * 1024 * 1024), + unit: "GB/s" + }; + } + } + + function formatBytesTotal(bytes: real): var { + // Handle negative or invalid values + if (bytes < 0 || isNaN(bytes) || !isFinite(bytes)) { + return { + value: 0, + unit: "B" + }; + } + + if (bytes < 1024) { + return { + value: bytes, + unit: "B" + }; + } else if (bytes < 1024 * 1024) { + return { + value: bytes / 1024, + unit: "KB" + }; + } else if (bytes < 1024 * 1024 * 1024) { + return { + value: bytes / (1024 * 1024), + unit: "MB" + }; + } else { + return { + value: bytes / (1024 * 1024 * 1024), + unit: "GB" + }; + } + } + + function parseNetDev(content: string): var { + const lines = content.split("\n"); + let totalRx = 0; + let totalTx = 0; + + for (let i = 2; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line) + continue; + + const parts = line.split(/\s+/); + if (parts.length < 10) + continue; + + const iface = parts[0].replace(":", ""); + // Skip loopback interface + if (iface === "lo") + continue; + + const rxBytes = parseFloat(parts[1]) || 0; + const txBytes = parseFloat(parts[9]) || 0; + + totalRx += rxBytes; + totalTx += txBytes; + } + + return { + rx: totalRx, + tx: totalTx + }; + } + + CircularBuffer { + id: _downloadBuffer + + capacity: root.historyLength + 1 + } + + CircularBuffer { + id: _uploadBuffer + + capacity: root.historyLength + 1 + } + + FileView { + id: netDevFile + + path: "/proc/net/dev" + } + + Timer { + interval: GlobalConfig.dashboard.resourceUpdateInterval + running: root.refCount > 0 + repeat: true + triggeredOnStart: true + + onTriggered: { + netDevFile.reload(); + const content = netDevFile.text(); + if (!content) + return; + + const data = root.parseNetDev(content); + const now = Date.now(); + + if (!root._initialized) { + root._initialRxBytes = data.rx; + root._initialTxBytes = data.tx; + root._prevRxBytes = data.rx; + root._prevTxBytes = data.tx; + root._prevTimestamp = now; + root._initialized = true; + return; + } + + const timeDelta = (now - root._prevTimestamp) / 1000; // seconds + if (timeDelta > 0) { + // Calculate byte deltas + let rxDelta = data.rx - root._prevRxBytes; + let txDelta = data.tx - root._prevTxBytes; + + // Handle counter overflow (when counters wrap around from max to 0) + // This happens when counters exceed 32-bit or 64-bit limits + if (rxDelta < 0) { + // Counter wrapped around - assume 64-bit counter + rxDelta += Math.pow(2, 64); + } + if (txDelta < 0) { + txDelta += Math.pow(2, 64); + } + + // Calculate speeds + root._downloadSpeed = rxDelta / timeDelta; + root._uploadSpeed = txDelta / timeDelta; + + if (root._downloadSpeed >= 0 && isFinite(root._downloadSpeed)) + _downloadBuffer.push(root._downloadSpeed); + + if (root._uploadSpeed >= 0 && isFinite(root._uploadSpeed)) + _uploadBuffer.push(root._uploadSpeed); + } + + // Calculate totals with overflow handling + let downTotal = data.rx - root._initialRxBytes; + let upTotal = data.tx - root._initialTxBytes; + + // Handle counter overflow for totals + if (downTotal < 0) { + downTotal += Math.pow(2, 64); + } + if (upTotal < 0) { + upTotal += Math.pow(2, 64); + } + + root._downloadTotal = downTotal; + root._uploadTotal = upTotal; + + root._prevRxBytes = data.rx; + root._prevTxBytes = data.tx; + root._prevTimestamp = now; + } + } +} diff --git a/services/Nmcli.qml b/services/Nmcli.qml index 36bd3e6df..ea5d7eb31 100644 --- a/services/Nmcli.qml +++ b/services/Nmcli.qml @@ -1,9 +1,9 @@ pragma Singleton pragma ComponentBehavior: Bound +import QtQuick import Quickshell import Quickshell.Io -import QtQuick Singleton { id: root @@ -24,14 +24,15 @@ Singleton { property var wifiConnectionQueue: [] property int currentSsidQueryIndex: 0 property var pendingConnection: null - signal connectionFailed(string ssid) property var wirelessDeviceDetails: null property var ethernetDeviceDetails: null property list ethernetDevices: [] readonly property var activeEthernet: ethernetDevices.find(d => d.connected) ?? null - property list activeProcesses: [] + readonly property alias connectionCheckTimer: connectionCheckTimer + readonly property alias immediateCheckTimer: immediateCheckTimer + // Constants readonly property string deviceTypeWifi: "wifi" readonly property string deviceTypeEthernet: "ethernet" @@ -55,6 +56,8 @@ Singleton { readonly property string connectionParamPassword: "password" readonly property string connectionParamBssid: "802-11-wireless.bssid" + signal connectionFailed(string ssid) + function detectPasswordRequired(error: string): bool { if (!error || error.length === 0) { return false; @@ -165,7 +168,7 @@ Singleton { function executeCommand(args: list, callback: var): void { const proc = commandProc.createObject(root); - proc.command = ["nmcli", ...args]; + proc.cmdArgs = ["nmcli", ...args]; proc.callback = callback; activeProcesses.push(proc); @@ -178,7 +181,7 @@ Singleton { }); Qt.callLater(() => { - proc.exec(proc.command); + proc.exec(proc.cmdArgs); }); } @@ -388,7 +391,7 @@ Singleton { } if (!result.success && root.pendingConnection && retries < maxRetries) { - console.warn("[NMCLI] Connection failed, retrying... (attempt " + (retries + 1) + "/" + maxRetries + ")"); + console.warn(lc, "Connection failed, retrying... (attempt " + (retries + 1) + "/" + maxRetries + ")"); Qt.callLater(() => { connectWireless(ssid, password, bssid, callback, retries + 1); }, 1000); @@ -414,7 +417,7 @@ Singleton { loadSavedConnections(() => {}); activateConnection(ssid, callback); } else { - console.warn("[NMCLI] Connection profile creation failed, trying fallback..."); + console.warn(lc, "Connection profile creation failed, trying fallback..."); let fallbackCmd = [root.nmcliCommandDevice, root.nmcliCommandWifi, "connect", ssid, root.connectionParamPassword, password]; executeCommand(fallbackCmd, fallbackResult => { if (callback) @@ -751,17 +754,25 @@ Singleton { const networks = deduplicateNetworks(allNetworks); const rNetworks = root.networks; - const destroyed = rNetworks.filter(rn => !networks.find(n => n.frequency === rn.frequency && n.ssid === rn.ssid && n.bssid === rn.bssid)); - for (const network of destroyed) { - const index = rNetworks.indexOf(network); - if (index >= 0) { - rNetworks.splice(index, 1); - network.destroy(); + const newMap = new Map(); + for (const n of networks) + newMap.set(`${n.frequency}:${n.ssid}:${n.bssid}`, n); + + for (let i = rNetworks.length - 1; i >= 0; i--) { + const rn = rNetworks[i]; + const key = `${rn.frequency}:${rn.ssid}:${rn.bssid}`; + if (!newMap.has(key)) { + rNetworks.splice(i, 1); + rn.destroy(); } } - for (const network of networks) { - const match = rNetworks.find(n => n.frequency === network.frequency && n.ssid === network.ssid && n.bssid === network.bssid); + const existingMap = new Map(); + for (const rn of rNetworks) + existingMap.set(`${rn.frequency}:${rn.ssid}:${rn.bssid}`, rn); + + for (const [key, network] of newMap) { + const match = existingMap.get(key); if (match) { match.lastIpcObject = network; } else { @@ -829,7 +840,7 @@ Singleton { return false; } - if (!isConnectionCommand(proc.command) || !root.pendingConnection || !root.pendingConnection.callback) { + if (!isConnectionCommand(proc.cmdArgs) || !root.pendingConnection || !root.pendingConnection.callback) { return false; } @@ -861,247 +872,6 @@ Singleton { return false; } - component CommandProcess: Process { - id: proc - - property var callback: null - property list command: [] - property bool callbackCalled: false - property int exitCode: 0 - - signal processFinished - - environment: ({ - LANG: "C.UTF-8", - LC_ALL: "C.UTF-8" - }) - - stdout: StdioCollector { - id: stdoutCollector - } - - stderr: StdioCollector { - id: stderrCollector - - onStreamFinished: { - const error = text.trim(); - if (error && error.length > 0) { - const output = (stdoutCollector && stdoutCollector.text) ? stdoutCollector.text : ""; - root.handlePasswordRequired(proc, error, output, -1); - } - } - } - - onExited: code => { - exitCode = code; - - Qt.callLater(() => { - if (callbackCalled) { - processFinished(); - return; - } - - if (proc.callback) { - const output = (stdoutCollector && stdoutCollector.text) ? stdoutCollector.text : ""; - const error = (stderrCollector && stderrCollector.text) ? stderrCollector.text : ""; - const success = exitCode === 0; - const cmdIsConnection = isConnectionCommand(proc.command); - - if (root.handlePasswordRequired(proc, error, output, exitCode)) { - processFinished(); - return; - } - - const needsPassword = cmdIsConnection && root.detectPasswordRequired(error); - - if (!success && cmdIsConnection && root.pendingConnection) { - const failedSsid = root.pendingConnection.ssid; - root.connectionFailed(failedSsid); - } - - callbackCalled = true; - callback({ - success: success, - output: output, - error: error, - exitCode: proc.exitCode, - needsPassword: needsPassword || false - }); - processFinished(); - } else { - processFinished(); - } - }); - } - } - - Component { - id: commandProc - - CommandProcess {} - } - - component AccessPoint: QtObject { - required property var lastIpcObject - readonly property string ssid: lastIpcObject.ssid - readonly property string bssid: lastIpcObject.bssid - readonly property int strength: lastIpcObject.strength - readonly property int frequency: lastIpcObject.frequency - readonly property bool active: lastIpcObject.active - readonly property string security: lastIpcObject.security - readonly property bool isSecure: security.length > 0 - } - - Component { - id: apComp - - AccessPoint {} - } - - Timer { - id: connectionCheckTimer - - interval: 4000 - onTriggered: { - if (root.pendingConnection) { - const connected = root.active && root.active.ssid === root.pendingConnection.ssid; - - if (!connected && root.pendingConnection.callback) { - let foundPasswordError = false; - for (let i = 0; i < root.activeProcesses.length; i++) { - const proc = root.activeProcesses[i]; - if (proc && proc.stderr && proc.stderr.text) { - const error = proc.stderr.text.trim(); - if (error && error.length > 0) { - if (root.isConnectionCommand(proc.command)) { - const needsPassword = root.detectPasswordRequired(error); - - if (needsPassword && !proc.callbackCalled && root.pendingConnection) { - const pending = root.pendingConnection; - root.pendingConnection = null; - immediateCheckTimer.stop(); - immediateCheckTimer.checkCount = 0; - proc.callbackCalled = true; - const result = { - success: false, - output: (proc.stdout && proc.stdout.text) ? proc.stdout.text : "", - error: error, - exitCode: -1, - needsPassword: true - }; - if (pending.callback) { - pending.callback(result); - } - if (proc.callback && proc.callback !== pending.callback) { - proc.callback(result); - } - foundPasswordError = true; - break; - } - } - } - } - } - - if (!foundPasswordError) { - const pending = root.pendingConnection; - const failedSsid = pending.ssid; - root.pendingConnection = null; - immediateCheckTimer.stop(); - immediateCheckTimer.checkCount = 0; - root.connectionFailed(failedSsid); - pending.callback({ - success: false, - output: "", - error: "Connection timeout", - exitCode: -1, - needsPassword: false - }); - } - } else if (connected) { - root.pendingConnection = null; - immediateCheckTimer.stop(); - immediateCheckTimer.checkCount = 0; - } - } - } - } - - Timer { - id: immediateCheckTimer - - property int checkCount: 0 - - interval: 500 - repeat: true - triggeredOnStart: false - - onTriggered: { - if (root.pendingConnection) { - checkCount++; - const connected = root.active && root.active.ssid === root.pendingConnection.ssid; - - if (connected) { - connectionCheckTimer.stop(); - immediateCheckTimer.stop(); - immediateCheckTimer.checkCount = 0; - if (root.pendingConnection.callback) { - root.pendingConnection.callback({ - success: true, - output: "Connected", - error: "", - exitCode: 0 - }); - } - root.pendingConnection = null; - } else { - for (let i = 0; i < root.activeProcesses.length; i++) { - const proc = root.activeProcesses[i]; - if (proc && proc.stderr && proc.stderr.text) { - const error = proc.stderr.text.trim(); - if (error && error.length > 0) { - if (root.isConnectionCommand(proc.command)) { - const needsPassword = root.detectPasswordRequired(error); - - if (needsPassword && !proc.callbackCalled && root.pendingConnection && root.pendingConnection.callback) { - connectionCheckTimer.stop(); - immediateCheckTimer.stop(); - immediateCheckTimer.checkCount = 0; - const pending = root.pendingConnection; - root.pendingConnection = null; - proc.callbackCalled = true; - const result = { - success: false, - output: (proc.stdout && proc.stdout.text) ? proc.stdout.text : "", - error: error, - exitCode: -1, - needsPassword: true - }; - if (pending.callback) { - pending.callback(result); - } - if (proc.callback && proc.callback !== pending.callback) { - proc.callback(result); - } - return; - } - } - } - } - } - - if (checkCount >= 6) { - immediateCheckTimer.stop(); - immediateCheckTimer.checkCount = 0; - } - } - } else { - immediateCheckTimer.stop(); - immediateCheckTimer.checkCount = 0; - } - } - } - function checkPendingConnection(): void { if (root.pendingConnection) { Qt.callLater(() => { @@ -1253,39 +1023,9 @@ Singleton { return details; } - Process { - id: rescanProc - - command: ["nmcli", "dev", root.nmcliCommandWifi, "list", "--rescan", "yes"] - onExited: root.getNetworks() - } - - Process { - id: monitorProc - - running: true - command: ["nmcli", "monitor"] - environment: ({ - LANG: "C.UTF-8", - LC_ALL: "C.UTF-8" - }) - stdout: SplitParser { - onRead: root.refreshOnConnectionChange() - } - onExited: monitorRestartTimer.start() - } - - Timer { - id: monitorRestartTimer - interval: 2000 - onTriggered: { - monitorProc.running = true; - } - } - - function refreshOnConnectionChange(): void { - getNetworks(networks => { - const newActive = root.active; + function refreshOnConnectionChange(): void { + getNetworks(networks => { + const newActive = root.active; if (newActive && newActive.active) { Qt.callLater(() => { @@ -1349,4 +1089,283 @@ Singleton { } }, 2000); } + + Component { + id: commandProc + + CommandProcess {} + } + + Component { + id: apComp + + AccessPoint {} + } + + Timer { + id: connectionCheckTimer + + interval: 4000 + onTriggered: { + if (root.pendingConnection) { + const connected = root.active && root.active.ssid === root.pendingConnection.ssid; + + if (!connected && root.pendingConnection.callback) { + let foundPasswordError = false; + for (let i = 0; i < root.activeProcesses.length; i++) { + const proc = root.activeProcesses[i]; + if (proc && proc.stderr && proc.stderr.text) { + const error = proc.stderr.text.trim(); + if (error && error.length > 0) { + if (root.isConnectionCommand(proc.cmdArgs)) { + const needsPassword = root.detectPasswordRequired(error); + + if (needsPassword && !proc.callbackCalled && root.pendingConnection) { + const pending = root.pendingConnection; + root.pendingConnection = null; + immediateCheckTimer.stop(); + immediateCheckTimer.checkCount = 0; + proc.callbackCalled = true; + const result = { + success: false, + output: (proc.stdout && proc.stdout.text) ? proc.stdout.text : "", + error: error, + exitCode: -1, + needsPassword: true + }; + if (pending.callback) { + pending.callback(result); + } + if (proc.callback && proc.callback !== pending.callback) { + proc.callback(result); + } + foundPasswordError = true; + break; + } + } + } + } + } + + if (!foundPasswordError) { + const pending = root.pendingConnection; + const failedSsid = pending.ssid; + root.pendingConnection = null; + immediateCheckTimer.stop(); + immediateCheckTimer.checkCount = 0; + root.connectionFailed(failedSsid); + pending.callback({ + success: false, + output: "", + error: "Connection timeout", + exitCode: -1, + needsPassword: false + }); + } + } else if (connected) { + root.pendingConnection = null; + immediateCheckTimer.stop(); + immediateCheckTimer.checkCount = 0; + } + } + } + } + + Timer { + id: immediateCheckTimer + + property int checkCount: 0 + + interval: 500 + repeat: true + triggeredOnStart: false + + onTriggered: { + if (root.pendingConnection) { + checkCount++; + const connected = root.active && root.active.ssid === root.pendingConnection.ssid; + + if (connected) { + connectionCheckTimer.stop(); + immediateCheckTimer.stop(); + immediateCheckTimer.checkCount = 0; + if (root.pendingConnection.callback) { + root.pendingConnection.callback({ + success: true, + output: "Connected", + error: "", + exitCode: 0 + }); + } + root.pendingConnection = null; + } else { + for (let i = 0; i < root.activeProcesses.length; i++) { + const proc = root.activeProcesses[i]; + if (proc && proc.stderr && proc.stderr.text) { + const error = proc.stderr.text.trim(); + if (error && error.length > 0) { + if (root.isConnectionCommand(proc.cmdArgs)) { + const needsPassword = root.detectPasswordRequired(error); + + if (needsPassword && !proc.callbackCalled && root.pendingConnection && root.pendingConnection.callback) { + connectionCheckTimer.stop(); + immediateCheckTimer.stop(); + immediateCheckTimer.checkCount = 0; + const pending = root.pendingConnection; + root.pendingConnection = null; + proc.callbackCalled = true; + const result = { + success: false, + output: (proc.stdout && proc.stdout.text) ? proc.stdout.text : "", + error: error, + exitCode: -1, + needsPassword: true + }; + if (pending.callback) { + pending.callback(result); + } + if (proc.callback && proc.callback !== pending.callback) { + proc.callback(result); + } + return; + } + } + } + } + } + + if (checkCount >= 6) { + immediateCheckTimer.stop(); + immediateCheckTimer.checkCount = 0; + } + } + } else { + immediateCheckTimer.stop(); + immediateCheckTimer.checkCount = 0; + } + } + } + + Process { + id: rescanProc + + command: ["nmcli", "dev", root.nmcliCommandWifi, "list", "--rescan", "yes"] + onExited: root.getNetworks() // qmllint disable signal-handler-parameters + } + + Process { + id: monitorProc + + running: true + command: ["nmcli", "monitor"] + environment: ({ + LANG: "C.UTF-8", + LC_ALL: "C.UTF-8" + }) + stdout: SplitParser { + onRead: root.refreshOnConnectionChange() + } + onExited: monitorRestartTimer.start() // qmllint disable signal-handler-parameters + } + + Timer { + id: monitorRestartTimer + + interval: 2000 + onTriggered: { + monitorProc.running = true; + } + } + + LoggingCategory { + id: lc + + name: "caelestia.qml.services.nmcli" + defaultLogLevel: LoggingCategory.Info + } + + component CommandProcess: Process { + id: proc + + property var callback: null + property list cmdArgs: [] + property bool callbackCalled: false + property int exitCode: 0 + + signal processFinished + + environment: ({ + LANG: "C.UTF-8", + LC_ALL: "C.UTF-8" + }) + + stdout: StdioCollector { + id: stdoutCollector + } + + stderr: StdioCollector { + id: stderrCollector + + onStreamFinished: { + const error = text.trim(); + if (error && error.length > 0) { + const output = (stdoutCollector && stdoutCollector.text) ? stdoutCollector.text : ""; + root.handlePasswordRequired(proc, error, output, -1); + } + } + } + + onExited: code => { // qmllint disable signal-handler-parameters + exitCode = code; + + Qt.callLater(() => { + if (callbackCalled) { + processFinished(); + return; + } + + if (proc.callback) { + const output = (stdoutCollector && stdoutCollector.text) ? stdoutCollector.text : ""; + const error = (stderrCollector && stderrCollector.text) ? stderrCollector.text : ""; + const success = exitCode === 0; + const cmdIsConnection = isConnectionCommand(proc.cmdArgs); + + if (root.handlePasswordRequired(proc, error, output, exitCode)) { + processFinished(); + return; + } + + const needsPassword = cmdIsConnection && root.detectPasswordRequired(error); + + if (!success && cmdIsConnection && root.pendingConnection) { + const failedSsid = root.pendingConnection.ssid; + root.connectionFailed(failedSsid); + } + + callbackCalled = true; + callback({ + success: success, + output: output, + error: error, + exitCode: proc.exitCode, + needsPassword: needsPassword || false + }); + processFinished(); + } else { + processFinished(); + } + }); + } + } + + component AccessPoint: QtObject { + required property var lastIpcObject + readonly property string ssid: lastIpcObject.ssid + readonly property string bssid: lastIpcObject.bssid + readonly property int strength: lastIpcObject.strength + readonly property int frequency: lastIpcObject.frequency + readonly property bool active: lastIpcObject.active + readonly property string security: lastIpcObject.security + readonly property bool isSecure: security.length > 0 + } } diff --git a/services/NotifData.qml b/services/NotifData.qml new file mode 100644 index 000000000..3c6ae23b6 --- /dev/null +++ b/services/NotifData.qml @@ -0,0 +1,243 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Services.Notifications +import Caelestia +import Caelestia.Config +import qs.services +import qs.utils + +QtObject { + id: notif + + property bool popup + property bool closed + property var locks: new Set() + + property date time: new Date() + property string timeStr: qsTr("now") + + readonly property Timer timeStrTimer: Timer { + running: !notif.closed + repeat: true + interval: 5000 + onTriggered: notif.updateTimeStr() + } + + property Notification notification + property string id + property string summary + property string body + property string appIcon + property string appName + property string image + property var hints // Hints are not persisted across restarts + property real expireTimeout: GlobalConfig.notifs.defaultExpireTimeout + property int urgency: NotificationUrgency.Normal + property bool resident + property bool hasActionIcons + property list actions + + readonly property bool hasFullscreen: { + const monitor = Hypr.focusedMonitor; + const specialName = monitor?.lastIpcObject.specialWorkspace?.name; + if (specialName) { + const specialWs = Hypr.workspaces.values.find(ws => ws.name === specialName); + return specialWs?.toplevels.values.some(t => t.lastIpcObject.fullscreen > 1) ?? false; + } + return monitor?.activeWorkspace?.toplevels.values.some(t => t.lastIpcObject.fullscreen > 1) ?? false; + } + + readonly property Timer timer: Timer { + running: true + interval: notif.expireTimeout > 0 ? notif.expireTimeout : notif.hasFullscreen ? GlobalConfig.notifs.fullscreenExpireTimeout : GlobalConfig.notifs.defaultExpireTimeout + onTriggered: { + // Always expire if the active workspace has a fullscreen window + if (GlobalConfig.notifs.expire || notif.hasFullscreen) + notif.popup = false; + } + } + + readonly property LazyLoader dummyImageLoader: LazyLoader { + active: false + + // qmllint disable uncreatable-type + PanelWindow { + // qmllint enable uncreatable-type + implicitWidth: TokenConfig.sizes.notifs.image + implicitHeight: TokenConfig.sizes.notifs.image + color: "transparent" + mask: Region {} + + Image { + function tryCache(): void { + if (status !== Image.Ready || width != TokenConfig.sizes.notifs.image || height != TokenConfig.sizes.notifs.image) + return; + + const cacheKey = notif.appName + notif.summary + notif.id + notif.image; + let h1 = 0xdeadbeef, h2 = 0x41c6ce57, ch; + for (let i = 0; i < cacheKey.length; i++) { + ch = cacheKey.charCodeAt(i); + h1 = Math.imul(h1 ^ ch, 2654435761); + h2 = Math.imul(h2 ^ ch, 1597334677); + } + h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507); + h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909); + h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507); + h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909); + const hash = (h2 >>> 0).toString(16).padStart(8, 0) + (h1 >>> 0).toString(16).padStart(8, 0); + + const cache = `${Paths.notifimagecache}/${hash}.png`; + CUtils.saveItem(this, Qt.resolvedUrl(cache), () => { + notif.image = cache; + notif.dummyImageLoader.active = false; + }); + } + + anchors.fill: parent + source: Qt.resolvedUrl(notif.image) + fillMode: Image.PreserveAspectCrop + cache: false + asynchronous: true + opacity: 0 + + onStatusChanged: tryCache() + onWidthChanged: tryCache() + onHeightChanged: tryCache() + } + } + } + + readonly property Connections conn: Connections { + function onClosed(): void { + notif.close(); + } + + function onSummaryChanged(): void { + notif.summary = notif.notification.summary; + } + + function onBodyChanged(): void { + notif.body = notif.notification.body; + } + + function onAppIconChanged(): void { + notif.appIcon = notif.notification.appIcon; + } + + function onAppNameChanged(): void { + notif.appName = notif.notification.appName; + } + + function onImageChanged(): void { + notif.image = notif.notification.image; + notif.maybeTriggerDummyImageLoader(); + } + + function onExpireTimeoutChanged(): void { + notif.expireTimeout = notif.notification.expireTimeout; + } + + function onUrgencyChanged(): void { + notif.urgency = notif.notification.urgency; + } + + function onResidentChanged(): void { + notif.resident = notif.notification.resident; + } + + function onHasActionIconsChanged(): void { + notif.hasActionIcons = notif.notification.hasActionIcons; + } + + function onActionsChanged(): void { + // qmllint disable unresolved-type + notif.actions = notif.notification.actions.map(a => ({ + // qmllint enable unresolved-type + identifier: a.identifier, + text: a.text, + invoke: () => a.invoke() + })); + } + + function onHintsChanged(): void { + notif.hints = notif.notification.hints; + } + + target: notif.notification + } + + function updateTimeStr(): void { + const diff = Date.now() - time.getTime(); + const m = Math.floor(diff / 60000); + + if (m < 1) { + timeStr = qsTr("now"); + timeStrTimer.interval = 5000; + } else { + const h = Math.floor(m / 60); + const d = Math.floor(h / 24); + + if (d > 0) { + timeStr = `${d}d`; + timeStrTimer.interval = 3600000; + } else if (h > 0) { + timeStr = `${h}h`; + timeStrTimer.interval = 300000; + } else { + timeStr = `${m}m`; + timeStrTimer.interval = m < 10 ? 30000 : 60000; + } + } + } + + function maybeTriggerDummyImageLoader(): void { + if (image && !image.startsWith("image://icon/") && !image.startsWith(Paths.notifimagecache)) + dummyImageLoader.active = true; + } + + function lock(item: Item): void { + locks.add(item); + } + + function unlock(item: Item): void { + locks.delete(item); + if (closed) + close(); + } + + function close(): void { + closed = true; + if (locks.size === 0 && Notifs.list.includes(this)) { + Notifs.list = Notifs.list.filter(n => n !== this); + notification?.dismiss(); + destroy(); + } + } + + Component.onCompleted: { + if (!notification) + return; + + id = notification.id; + summary = notification.summary; + body = notification.body; + appIcon = notification.appIcon; + appName = notification.appName; + image = notification.image; + maybeTriggerDummyImageLoader(); + expireTimeout = notification.expireTimeout; + hints = notification.hints; + urgency = notification.urgency; + resident = notification.resident; + hasActionIcons = notification.hasActionIcons; + // qmllint disable unresolved-type + actions = notification.actions.map(a => ({ + // qmllint enable unresolved-type + identifier: a.identifier, + text: a.text, + invoke: () => a.invoke() + })); + } +} diff --git a/services/Notifs.qml b/services/Notifs.qml index 2ebc32db1..d539d0a28 100644 --- a/services/Notifs.qml +++ b/services/Notifs.qml @@ -1,27 +1,44 @@ pragma Singleton pragma ComponentBehavior: Bound -import qs.components.misc -import qs.config -import qs.utils -import Caelestia +import QtQuick import Quickshell import Quickshell.Io import Quickshell.Services.Notifications -import QtQuick +import Caelestia +import Caelestia.Config +import qs.components.misc +import qs.services +import qs.utils Singleton { id: root - property list list: [] - readonly property list notClosed: list.filter(n => !n.closed) - readonly property list popups: list.filter(n => n.popup) + property list list: [] + readonly property list notClosed: list.filter(n => !n.closed) + readonly property list popups: list.filter(n => n.popup) property alias dnd: props.dnd property bool loaded + function hasFullscreen(): bool { + for (const monitor of Hypr.monitors.values) { + if (monitor?.activeWorkspace?.toplevels.values.some(t => t.lastIpcObject.fullscreen > 1)) + return true; + } + return false; + } + + function shouldShowPopup(): bool { + if (props.dnd || [...Visibilities.screens.values()].some(v => v.sidebar)) + return false; + if (GlobalConfig.notifs.fullscreen === "off" && hasFullscreen()) + return false; + return true; + } + onDndChanged: { - if (!Config.utilities.toasts.dndChanged) + if (!GlobalConfig.utilities.toasts.dndChanged) return; if (dnd) @@ -78,7 +95,7 @@ Singleton { notif.tracked = true; const comp = notifComp.createObject(root, { - popup: !props.dnd && ![...Visibilities.screens.values()].some(v => v.sidebar), + popup: root.shouldShowPopup(), notification: notif }); root.list = [comp, ...root.list]; @@ -88,6 +105,7 @@ Singleton { FileView { id: storage + printErrors: false path: `${Paths.state}/notifs.json` onLoaded: { const data = JSON.parse(text()); @@ -99,12 +117,14 @@ Singleton { onLoadFailed: err => { if (err === FileViewError.FileNotFound) { root.loaded = true; - setText("[]"); + Qt.callLater(() => setText("[]")); } } } + // qmllint disable unresolved-type CustomShortcut { + // qmllint enable unresolved-type name: "clearNotifs" description: "Clear all notifications" onPressed: { @@ -114,8 +134,6 @@ Singleton { } IpcHandler { - target: "notifs" - function clear(): void { for (const notif of root.list.slice()) notif.close(); @@ -136,203 +154,13 @@ Singleton { function disableDnd(): void { props.dnd = false; } - } - - component Notif: QtObject { - id: notif - - property bool popup - property bool closed - property var locks: new Set() - - property date time: new Date() - readonly property string timeStr: { - const diff = Time.date.getTime() - time.getTime(); - const m = Math.floor(diff / 60000); - - if (m < 1) - return qsTr("now"); - - const h = Math.floor(m / 60); - const d = Math.floor(h / 24); - - if (d > 0) - return `${d}d`; - if (h > 0) - return `${h}h`; - return `${m}m`; - } - - property Notification notification - property string id - property string summary - property string body - property string appIcon - property string appName - property string image - property real expireTimeout: Config.notifs.defaultExpireTimeout - property int urgency: NotificationUrgency.Normal - property bool resident - property bool hasActionIcons - property list actions - - readonly property Timer timer: Timer { - running: true - interval: notif.expireTimeout > 0 ? notif.expireTimeout : Config.notifs.defaultExpireTimeout - onTriggered: { - if (Config.notifs.expire) - notif.popup = false; - } - } - - readonly property LazyLoader dummyImageLoader: LazyLoader { - active: false - - PanelWindow { - implicitWidth: Config.notifs.sizes.image - implicitHeight: Config.notifs.sizes.image - color: "transparent" - mask: Region {} - - Image { - function tryCache(): void { - if (status !== Image.Ready || width != Config.notifs.sizes.image || height != Config.notifs.sizes.image) - return; - - const cacheKey = notif.appName + notif.summary + notif.id; - let h1 = 0xdeadbeef, h2 = 0x41c6ce57, ch; - for (let i = 0; i < cacheKey.length; i++) { - ch = cacheKey.charCodeAt(i); - h1 = Math.imul(h1 ^ ch, 2654435761); - h2 = Math.imul(h2 ^ ch, 1597334677); - } - h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507); - h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909); - h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507); - h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909); - const hash = (h2 >>> 0).toString(16).padStart(8, 0) + (h1 >>> 0).toString(16).padStart(8, 0); - - const cache = `${Paths.notifimagecache}/${hash}.png`; - CUtils.saveItem(this, Qt.resolvedUrl(cache), () => { - notif.image = cache; - notif.dummyImageLoader.active = false; - }); - } - - anchors.fill: parent - source: Qt.resolvedUrl(notif.image) - fillMode: Image.PreserveAspectCrop - cache: false - asynchronous: true - opacity: 0 - - onStatusChanged: tryCache() - onWidthChanged: tryCache() - onHeightChanged: tryCache() - } - } - } - readonly property Connections conn: Connections { - target: notif.notification - - function onClosed(): void { - notif.close(); - } - - function onSummaryChanged(): void { - notif.summary = notif.notification.summary; - } - - function onBodyChanged(): void { - notif.body = notif.notification.body; - } - - function onAppIconChanged(): void { - notif.appIcon = notif.notification.appIcon; - } - - function onAppNameChanged(): void { - notif.appName = notif.notification.appName; - } - - function onImageChanged(): void { - notif.image = notif.notification.image; - if (notif.notification?.image) - notif.dummyImageLoader.active = true; - } - - function onExpireTimeoutChanged(): void { - notif.expireTimeout = notif.notification.expireTimeout; - } - - function onUrgencyChanged(): void { - notif.urgency = notif.notification.urgency; - } - - function onResidentChanged(): void { - notif.resident = notif.notification.resident; - } - - function onHasActionIconsChanged(): void { - notif.hasActionIcons = notif.notification.hasActionIcons; - } - - function onActionsChanged(): void { - notif.actions = notif.notification.actions.map(a => ({ - identifier: a.identifier, - text: a.text, - invoke: () => a.invoke() - })); - } - } - - function lock(item: Item): void { - locks.add(item); - } - - function unlock(item: Item): void { - locks.delete(item); - if (closed) - close(); - } - - function close(): void { - closed = true; - if (locks.size === 0 && root.list.includes(this)) { - root.list = root.list.filter(n => n !== this); - notification?.dismiss(); - destroy(); - } - } - - Component.onCompleted: { - if (!notification) - return; - - id = notification.id; - summary = notification.summary; - body = notification.body; - appIcon = notification.appIcon; - appName = notification.appName; - image = notification.image; - if (notification?.image) - dummyImageLoader.active = true; - expireTimeout = notification.expireTimeout; - urgency = notification.urgency; - resident = notification.resident; - hasActionIcons = notification.hasActionIcons; - actions = notification.actions.map(a => ({ - identifier: a.identifier, - text: a.text, - invoke: () => a.invoke() - })); - } + target: "notifs" } Component { id: notifComp - Notif {} + NotifData {} } } diff --git a/services/Players.qml b/services/Players.qml index 1191696ae..41507839d 100644 --- a/services/Players.qml +++ b/services/Players.qml @@ -1,36 +1,51 @@ pragma Singleton -import qs.components.misc -import qs.config +import QtQml import Quickshell import Quickshell.Io import Quickshell.Services.Mpris -import QtQml import Caelestia +import Caelestia.Config +import qs.components.misc Singleton { id: root readonly property list list: Mpris.players.values - readonly property MprisPlayer active: props.manualActive ?? list.find(p => getIdentity(p) === Config.services.defaultPlayer) ?? list[0] ?? null + readonly property MprisPlayer active: props.manualActive ?? list.find(p => getIdentity(p) === GlobalConfig.services.defaultPlayer) ?? list[0] ?? null property alias manualActive: props.manualActive function getIdentity(player: MprisPlayer): string { - const alias = Config.services.playerAliases.find(a => a.from === player.identity); + const alias = GlobalConfig.services.playerAliases.find(a => a.from === player.identity); return alias?.to ?? player.identity; } - Connections { - target: active + function getArtUrl(player: MprisPlayer): string { + if (!player) + return ""; + if (player.trackArtUrl) + return player.trackArtUrl; + + const url = player.metadata["xesam:url"] ?? ""; + if (url.startsWith("https://www.youtube.com/watch")) { + // Fallback for youtube + const id = url.match(/[?&]v=([\w-]{11})/)?.[1]; + return id ? `https://img.youtube.com/vi/${id}/hqdefault.jpg` : ""; + } + return ""; + } + Connections { function onPostTrackChanged() { - if (!Config.utilities.toasts.nowPlaying) { + if (!GlobalConfig.utilities.toasts.nowPlaying) { return; } - if (active.trackArtist != "" && active.trackTitle != "") { - Toaster.toast(qsTr("Now Playing"), qsTr("%1 - %2").arg(active.trackArtist).arg(active.trackTitle), "music_note"); + if (root.active.trackArtist != "" && root.active.trackTitle != "") { + Toaster.toast(qsTr("Now Playing"), qsTr("%1 - %2").arg(root.active.trackArtist).arg(root.active.trackTitle), "music_note"); } } + + target: root.active } PersistentProperties { @@ -41,7 +56,9 @@ Singleton { reloadableId: "players" } + // qmllint disable unresolved-type CustomShortcut { + // qmllint enable unresolved-type name: "mediaToggle" description: "Toggle media playback" onPressed: { @@ -51,7 +68,9 @@ Singleton { } } + // qmllint disable unresolved-type CustomShortcut { + // qmllint enable unresolved-type name: "mediaPrev" description: "Previous track" onPressed: { @@ -61,7 +80,9 @@ Singleton { } } + // qmllint disable unresolved-type CustomShortcut { + // qmllint enable unresolved-type name: "mediaNext" description: "Next track" onPressed: { @@ -71,15 +92,15 @@ Singleton { } } + // qmllint disable unresolved-type CustomShortcut { + // qmllint enable unresolved-type name: "mediaStop" description: "Stop media playback" onPressed: root.active?.stop() } IpcHandler { - target: "mpris" - function getActive(prop: string): string { const active = root.active; return active ? active[prop] ?? "Invalid property" : "No active player"; @@ -122,5 +143,7 @@ Singleton { function stop(): void { root.active?.stop(); } + + target: "mpris" } } diff --git a/services/Recorder.qml b/services/Recorder.qml index 6eddce949..253bac17c 100644 --- a/services/Recorder.qml +++ b/services/Recorder.qml @@ -1,82 +1,310 @@ pragma Singleton +import QtQuick import Quickshell import Quickshell.Io -import QtQuick Singleton { id: root readonly property alias running: props.running + readonly property alias starting: props.starting readonly property alias paused: props.paused readonly property alias elapsed: props.elapsed - property bool needsStart - property list startArgs - property bool needsStop - property bool needsPause - - function start(extraArgs = []): void { - needsStart = true; - startArgs = extraArgs; - checkProc.running = true; + readonly property alias videoMode: props.videoMode + readonly property alias audioMode: props.audioMode + property int startChecks: 0 + readonly property int maxStartChecks: 30 + + signal errorOccurred(string errorMsg) + signal recordingStarted() + signal recordingStopped() + + function start(videoMode: string, audioMode: string): bool { + if (props.running || props.starting) { + console.warn("Recording already running"); + errorOccurred("Recording already in progress"); + return false; + } + + const requestedVideoMode = videoMode || "fullscreen"; + const requestedAudioMode = audioMode || "none"; + + // Build command array + const args = ["caelestia", "record", "--mode", requestedVideoMode]; + + if (requestedAudioMode) { + args.push("--audio", requestedAudioMode); + } + + console.log("Executing:", args.join(" ")); + + try { + Quickshell.execDetached(args); + props.starting = true; + props.running = false; + props.paused = false; + props.elapsed = 0; + props.videoMode = requestedVideoMode; + props.audioMode = requestedAudioMode; + root.startChecks = 0; + verifyTimer.restart(); + return true; + } catch (error) { + console.error("Failed to start recording:", error); + errorOccurred("Failed to execute recording command: " + error); + props.starting = false; + props.running = false; + return false; + } } function stop(): void { - needsStop = true; - checkProc.running = true; + if (!props.running && !props.starting) { + console.warn("No recording to stop"); + return; + } + + console.log("Stopping recording"); + + try { + Quickshell.execDetached(["caelestia", "record", "--stop"]); + if (props.starting) { + props.starting = false; + props.running = false; + props.paused = false; + props.elapsed = 0; + recordingStopped(); + return; + } + // Don't immediately set running to false - wait for process to confirm + stopVerifyTimer.restart(); + } catch (error) { + console.error("Failed to stop recording:", error); + errorOccurred("Failed to stop recording: " + error); + // Force state reset on error + props.starting = false; + props.running = false; + props.paused = false; + props.elapsed = 0; + recordingStopped(); + } } function togglePause(): void { - needsPause = true; - checkProc.running = true; + if (!props.running || props.starting) { + console.warn("No recording to pause"); + return; + } + + console.log("Toggling pause"); + + try { + Quickshell.execDetached(["caelestia", "record", "--pause"]); + props.paused = !props.paused; + } catch (error) { + console.error("Failed to toggle pause:", error); + errorOccurred("Failed to pause/resume recording: " + error); + } + } + + function verifyRunning(): bool { + statusProc.running = true; + return props.running; } PersistentProperties { id: props property bool running: false + property bool starting: false property bool paused: false - property real elapsed: 0 // Might get too large for int + property real elapsed: 0 + property string videoMode: "fullscreen" + property string audioMode: "none" reloadableId: "recorder" } + // Main process checker - runs periodically when recording Process { id: checkProc - running: true + running: false command: ["pidof", "gpu-screen-recorder"] + onExited: code => { - props.running = code === 0; + const wasRunning = props.running; + const isRunning = code === 0; - if (code === 0) { - if (root.needsStop) { - Quickshell.execDetached(["caelestia", "record"]); - props.running = false; - props.paused = false; - } else if (root.needsPause) { - Quickshell.execDetached(["caelestia", "record", "-p"]); - props.paused = !props.paused; - } - } else if (root.needsStart) { - Quickshell.execDetached(["caelestia", "record", ...root.startArgs]); + // Detect unexpected stop + if (wasRunning && !isRunning) { + console.warn("Recording process stopped unexpectedly"); + props.starting = false; + props.running = false; + props.paused = false; + props.elapsed = 0; + recordingStopped(); + } + + // Schedule next check if still recording + if (props.running) { + statusCheckTimer.restart(); + } + } + } + + // Verification timer after start + Timer { + id: verifyTimer + interval: 1000 + repeat: false + onTriggered: { + console.log("Verifying recording started"); + statusProc.running = true; + } + } + + // Verification timer after stop + Timer { + id: stopVerifyTimer + interval: 500 + repeat: false + onTriggered: { + console.log("Verifying recording stopped"); + stopStatusProc.running = true; + } + } + + // Status check process for start verification + Process { + id: statusProc + + running: false + command: ["pidof", "gpu-screen-recorder"] + + onExited: code => { + const isRunning = code === 0; + + if (isRunning && props.starting) { + console.log("Recording verified running"); + props.starting = false; props.running = true; props.paused = false; + recordingStarted(); + statusCheckTimer.restart(); + return; + } + + if (!isRunning && props.starting) { + root.startChecks++; + if (root.startChecks < root.maxStartChecks) { + verifyTimer.restart(); + return; + } + + console.error("Recording process failed to start"); + errorOccurred("Recording did not start"); + props.starting = false; + props.running = false; + props.paused = false; props.elapsed = 0; + } else if (isRunning && props.running) { + console.log("Recording verified running"); + statusCheckTimer.restart(); } + } + } - root.needsStart = false; - root.needsStop = false; - root.needsPause = false; + // Status check process for stop verification + Process { + id: stopStatusProc + + running: false + command: ["pidof", "gpu-screen-recorder"] + + onExited: code => { + const isRunning = code === 0; + + if (!isRunning) { + console.log("Recording stopped successfully"); + props.starting = false; + props.running = false; + props.paused = false; + props.elapsed = 0; + recordingStopped(); + } else { + // Process still running, try again + console.warn("Process still running, checking again"); + stopVerifyTimer.restart(); + } } } - Connections { - target: Time - // enabled: props.running && !props.paused + // Elapsed time tracker + Timer { + id: elapsedTimer + interval: 1000 + repeat: true + running: props.running && !props.paused - function onSecondsChanged(): void { + onTriggered: { props.elapsed++; } } + + // Periodic status check while recording + Timer { + id: statusCheckTimer + interval: 3000 + repeat: false + + onTriggered: { + if (props.running) { + checkProc.running = true; + } + } + } + + // Initialize on component completion + Component.onCompleted: { + console.log("Recorder service initialized"); + // Check initial state + initialStatusProc.running = true; + } + + // Initial status check + Process { + id: initialStatusProc + + running: false + command: ["pidof", "gpu-screen-recorder"] + + onExited: code => { + if (code === 0) { + console.log("Found existing recording process"); + props.starting = false; + props.running = true; + statusCheckTimer.restart(); + } else { + console.log("No existing recording process"); + props.starting = false; + props.running = false; + props.paused = false; + props.elapsed = 0; + } + } + } + + // Cleanup on destruction + Component.onDestruction: { + if (props.running) { + console.log("Service destroyed while recording - stopping recording"); + try { + Quickshell.execDetached(["caelestia", "record", "--stop"]); + } catch (error) { + console.error("Failed to stop recording on cleanup:", error); + } + } + } } diff --git a/services/Screens.qml b/services/Screens.qml new file mode 100644 index 000000000..ac26d27d8 --- /dev/null +++ b/services/Screens.qml @@ -0,0 +1,14 @@ +pragma Singleton + +import Quickshell +import Caelestia.Config + +Singleton { + id: root + + readonly property list screens: Quickshell.screens.filter(s => GlobalConfig.forScreen(s.name).enabled) + + function isExcluded(screen: ShellScreen): bool { + return !GlobalConfig.forScreen(screen.name).enabled; + } +} diff --git a/services/SystemUsage.qml b/services/SystemUsage.qml index bd02da362..15dda6111 100644 --- a/services/SystemUsage.qml +++ b/services/SystemUsage.qml @@ -1,31 +1,57 @@ pragma Singleton -import qs.config +import QtQuick import Quickshell import Quickshell.Io -import QtQuick +import Caelestia.Config Singleton { id: root + // CPU properties + property string cpuName: "" property real cpuPerc property real cpuTemp - readonly property string gpuType: Config.services.gpuType.toUpperCase() || autoGpuType + + // GPU properties + readonly property string gpuType: GlobalConfig.services.gpuType.toUpperCase() || autoGpuType property string autoGpuType: "NONE" + property string gpuName: "" property real gpuPerc property real gpuTemp + + // Memory properties property real memUsed property real memTotal readonly property real memPerc: memTotal > 0 ? memUsed / memTotal : 0 - property real storageUsed - property real storageTotal - property real storagePerc: storageTotal > 0 ? storageUsed / storageTotal : 0 + + // Storage properties (aggregated) + readonly property real storagePerc: { + let totalUsed = 0; + let totalSize = 0; + for (const disk of disks) { + totalUsed += disk.used; + totalSize += disk.total; + } + return totalSize > 0 ? totalUsed / totalSize : 0; + } + + // Individual disks: Array of { mount, used, total, free, perc } + property var disks: [] property real lastCpuIdle property real lastCpuTotal property int refCount + function cleanCpuName(name: string): string { + return name.replace(/\(R\)|\(TM\)|CPU|\d+(?:th|nd|rd|st) Gen |Core |Processor/gi, "").replace(/\s+/g, " ").trim(); + } + + function cleanGpuName(name: string): string { + return name.replace(/\(R\)|\(TM\)|Graphics/gi, "").replace(/\s+/g, " ").trim(); + } + function formatKib(kib: real): var { const mib = 1024; const gib = 1024 ** 2; @@ -54,7 +80,7 @@ Singleton { Timer { running: root.refCount > 0 - interval: 3000 + interval: GlobalConfig.dashboard.resourceUpdateInterval repeat: true triggeredOnStart: true onTriggered: { @@ -66,6 +92,18 @@ Singleton { } } + // One-time CPU info detection (name) + FileView { + id: cpuinfoInit + + path: "/proc/cpuinfo" + onLoaded: { + const nameMatch = text().match(/model name\s*:\s*(.+)/); + if (nameMatch) + root.cpuName = root.cleanCpuName(nameMatch[1]); + } + } + FileView { id: stat @@ -101,41 +139,111 @@ Singleton { Process { id: storage - command: ["sh", "-c", "df | grep '^/dev/' | awk '{print $1, $3, $4}'"] + // Get physical disks with aggregated usage from their partitions + // -J triggers JSON output. -b triggers bytes. + command: ["lsblk", "-J", "-b", "-o", "NAME,SIZE,TYPE,FSUSED,FSSIZE,MOUNTPOINT"] + stdout: StdioCollector { onStreamFinished: { - const deviceMap = new Map(); + const data = JSON.parse(text); + const diskList = []; + const seenDevices = new Set(); + + // Helper to recursively sum usage from children (partitions, crypt, lvm) + const aggregateUsage = dev => { + let used = 0; + let size = 0; + let isRoot = dev.mountpoint === "/" || (dev.mountpoints && dev.mountpoints.includes("/")); + + if (!seenDevices.has(dev.name)) { + // lsblk returns null for empty/unformatted partitions, which parses to 0 here + used = parseInt(dev.fsused) || 0; + size = parseInt(dev.fssize) || 0; + seenDevices.add(dev.name); + } - for (const line of text.trim().split("\n")) { - if (line.trim() === "") - continue; - - const parts = line.trim().split(/\s+/); - if (parts.length >= 3) { - const device = parts[0]; - const used = parseInt(parts[1], 10) || 0; - const avail = parseInt(parts[2], 10) || 0; - - // Only keep the entry with the largest total space for each device - if (!deviceMap.has(device) || (used + avail) > (deviceMap.get(device).used + deviceMap.get(device).avail)) { - deviceMap.set(device, { - used: used, - avail: avail - }); + if (dev.children) { + for (const child of dev.children) { + const stats = aggregateUsage(child); + used += stats.used; + size += stats.size; + if (stats.isRoot) + isRoot = true; } } + return { + used, + size, + isRoot + }; + }; + + for (const dev of data.blockdevices) { + // Only process physical disks at the top level + if (dev.type === "disk" && !dev.name.startsWith("zram")) { + const stats = aggregateUsage(dev); + + if (stats.size === 0) { + continue; + } + + const total = stats.size; + const used = stats.used; + + diskList.push({ + mount: dev.name, + used: used / 1024 // KiB + , + total: total / 1024 // KiB + , + free: (total - used) / 1024, + perc: total > 0 ? used / total : 0, + hasRoot: stats.isRoot + }); + } } - let totalUsed = 0; - let totalAvail = 0; + // Sort by putting the disk with root first, then sort the rest alphabetically + root.disks = diskList.sort((a, b) => { + if (a.hasRoot && !b.hasRoot) + return -1; + if (!a.hasRoot && b.hasRoot) + return 1; + return a.mount.localeCompare(b.mount); + }); + } + } + } + + // GPU name detection (one-time) + Process { + id: gpuNameDetect - for (const [device, stats] of deviceMap) { - totalUsed += stats.used; - totalAvail += stats.avail; - } + running: true + command: ["sh", "-c", "nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null || glxinfo -B 2>/dev/null | grep 'Device:' | cut -d':' -f2 | cut -d'(' -f1 || lspci 2>/dev/null | grep -i 'vga\\|3d controller\\|display' | head -1"] + stdout: StdioCollector { + onStreamFinished: { + const output = text.trim(); + if (!output) + return; - root.storageUsed = totalUsed; - root.storageTotal = totalUsed + totalAvail; + // Check if it's from nvidia-smi (clean GPU name) + if (output.toLowerCase().includes("nvidia") || output.toLowerCase().includes("geforce") || output.toLowerCase().includes("rtx") || output.toLowerCase().includes("gtx")) { + root.gpuName = root.cleanGpuName(output); + } else if (output.toLowerCase().includes("rx")) { + root.gpuName = root.cleanGpuName(output); + } else { + // Parse lspci output: extract name from brackets or after colon + // Handles cases like [AMD/ATI] Navi 21 [Radeon RX 6800/6800 XT / 6900 XT] (rev c0) + const bracketMatch = output.match(/\[([^\]]+)\][^\[]*$/); + if (bracketMatch) { + root.gpuName = root.cleanGpuName(bracketMatch[1]); + } else { + const colonMatch = output.match(/:\s*(.+)/); + if (colonMatch) + root.gpuName = root.cleanGpuName(colonMatch[1]); + } + } } } } @@ -143,7 +251,7 @@ Singleton { Process { id: gpuTypeCheck - running: !Config.services.gpuType + running: !GlobalConfig.services.gpuType command: ["sh", "-c", "if command -v nvidia-smi &>/dev/null && nvidia-smi -L &>/dev/null; then echo NVIDIA; elif ls /sys/class/drm/card*/device/gpu_busy_percent 2>/dev/null | grep -q .; then echo GENERIC; else echo NONE; fi"] stdout: StdioCollector { onStreamFinished: root.autoGpuType = text.trim() diff --git a/services/Time.qml b/services/Time.qml index a07d9ef8e..0db520d09 100644 --- a/services/Time.qml +++ b/services/Time.qml @@ -1,8 +1,8 @@ pragma Singleton -import qs.config -import Quickshell import QtQuick +import Quickshell +import Caelestia.Config Singleton { property alias enabled: clock.enabled @@ -11,7 +11,7 @@ Singleton { readonly property int minutes: clock.minutes readonly property int seconds: clock.seconds - readonly property string timeStr: format(Config.services.useTwelveHourClock ? "hh:mm:A" : "hh:mm") + readonly property string timeStr: format(GlobalConfig.services.useTwelveHourClock ? "hh:mm:A" : "hh:mm") readonly property list timeComponents: timeStr.split(":") readonly property string hourStr: timeComponents[0] ?? "" readonly property string minuteStr: timeComponents[1] ?? "" @@ -23,6 +23,7 @@ Singleton { SystemClock { id: clock + precision: SystemClock.Seconds } } diff --git a/services/VPN.qml b/services/VPN.qml index 2d08631a1..61e91d0b2 100644 --- a/services/VPN.qml +++ b/services/VPN.qml @@ -1,20 +1,26 @@ pragma Singleton +import QtQuick import Quickshell import Quickshell.Io -import QtQuick -import qs.config import Caelestia +import Caelestia.Config Singleton { id: root property bool connected: false + property var status: ({ + connected: false, + state: "disconnected", + reason: "", + authUrl: "" + }) readonly property bool connecting: connectProc.running || disconnectProc.running - readonly property bool enabled: Config.utilities.vpn.provider.some(p => typeof p === "object" ? (p.enabled === true) : false) + readonly property bool enabled: GlobalConfig.utilities.vpn.provider.some(p => typeof p === "object" ? (p.enabled === true) : false) readonly property var providerInput: { - const enabledProvider = Config.utilities.vpn.provider.find(p => typeof p === "object" ? (p.enabled === true) : false); + const enabledProvider = GlobalConfig.utilities.vpn.provider.find(p => typeof p === "object" ? (p.enabled === true) : false); return enabledProvider || "wireguard"; } readonly property bool isCustomProvider: typeof providerInput === "object" @@ -53,7 +59,7 @@ Singleton { displayName: "Warp" }, "netbird": { - connectCmd: ["netbird", "up"], + connectCmd: ["netbird", "up", "--no-browser"], disconnectCmd: ["netbird", "down"], interface: "wt0", displayName: "NetBird" @@ -75,6 +81,10 @@ Singleton { } function connect(): void { + if (status.state === "needs-auth" && status.authUrl) { + emitStatusToast(status); + return; + } if (!connected && !connecting && root.currentConfig && root.currentConfig.connectCmd) { connectProc.exec(root.currentConfig.connectCmd); } @@ -87,11 +97,7 @@ Singleton { } function toggle(): void { - if (connected) { - disconnect(); - } else { - connect(); - } + connected ? disconnect() : connect(); } function checkStatus(): void { @@ -100,18 +106,223 @@ Singleton { } } - onConnectedChanged: { - if (!Config.utilities.toasts.vpnChanged) + function getStatusCommand(): var { + switch (providerName) { + case "tailscale": + return ["tailscale", "status", "--json"]; + case "netbird": + return ["netbird", "status", "--json"]; + case "warp": + return ["warp-cli", "status"]; + case "wireguard": + return ["ip", "link", "show"]; + default: + return ["ip", "link", "show"]; + } + } + + function parseTailscaleStatus(output: string): var { + const status = { + connected: false, + state: "disconnected", + reason: "", + authUrl: "" + }; + + // Handle empty or whitespace-only output + if (!output || output.trim().length === 0) { + return status; + } + + // Check for common non-JSON states first + if (output.includes("Logged out") || output.includes("Stopped") || output.includes("not running") || output.includes("Tailscale is not running")) { + status.state = "disconnected"; + return status; + } + + // Try to parse as JSON + try { + const data = JSON.parse(output); + const backendState = data.BackendState || ""; + + if (backendState === "Running") { + status.connected = true; + status.state = "connected"; + } else if (backendState === "Starting") { + status.state = "connecting"; + } else if (backendState === "NeedsLogin" || backendState === "NeedsMachineAuth") { + status.state = "needs-auth"; + status.reason = backendState === "NeedsLogin" ? "Login required" : "Machine authorization required"; + status.authUrl = data.AuthURL || ""; + } + } catch (e) { + // JSON parsing failed - treat as disconnected unless it looks like an error + if (output.includes("error") || output.includes("Error") || output.includes("failed")) { + status.state = "disconnected"; + status.reason = "Tailscale may not be running"; + } else { + status.state = "disconnected"; + } + } + return status; + } + + function parseNetBirdStatus(output: string): var { + const status = { + connected: false, + state: "disconnected", + reason: "", + authUrl: "" + }; + try { + const data = JSON.parse(output); + const mgmtConnected = data.management?.connected; + const signalConnected = data.signal?.connected; + + if (mgmtConnected && signalConnected) { + status.connected = true; + status.state = "connected"; + } else if (data.management?.error) { + const error = data.management.error; + if (error.includes("auth") || error.includes("login")) { + status.state = "needs-auth"; + status.reason = "Authentication required"; + } else { + status.reason = error; + } + } + } catch (e) { + status.state = "error"; + status.reason = "Failed to parse status"; + } + return status; + } + + function parseWarpStatus(output: string): var { + const status = { + connected: false, + state: "disconnected", + reason: "", + authUrl: "" + }; + + if (output.includes("Connected")) { + status.connected = true; + status.state = "connected"; + } else if (output.includes("Connecting")) { + status.state = "connecting"; + } else if (output.includes("Unable") || output.includes("Registration Missing") || output.includes("registration") || output.includes("register")) { + status.state = "needs-auth"; + status.reason = "WARP registration required"; + } else if (!output.includes("Disconnected")) { + status.state = "error"; + status.reason = "Unknown WARP status"; + } + return status; + } + + function parseWireGuardStatus(output: string): var { + const status = { + connected: false, + state: "disconnected", + reason: "", + authUrl: "" + }; + const iface = root.currentConfig?.interface || ""; + + if (iface && output.includes(iface + ":")) { + status.connected = true; + status.state = "connected"; + } + return status; + } + + function parseStatusOutput(output: string): var { + switch (providerName) { + case "tailscale": + return parseTailscaleStatus(output); + case "netbird": + return parseNetBirdStatus(output); + case "warp": + return parseWarpStatus(output); + case "wireguard": + default: + return parseWireGuardStatus(output); + } + } + + function extractAuthUrl(text: string): string { + const urlMatch = text.match(/(https?:\/\/[^\s]+)/); + return urlMatch ? urlMatch[1] : ""; + } + + function createAuthStatus(authUrl: string): var { + return { + connected: false, + state: "needs-auth", + reason: "Authentication required", + authUrl: authUrl + }; + } + + function updateStatus(newStatus: var): void { + const oldState = status.state; + if (newStatus.state === "needs-auth" && !newStatus.authUrl && status.authUrl) { + newStatus.authUrl = status.authUrl; + } + status = newStatus; + root.connected = newStatus.connected; + + if (oldState !== newStatus.state) { + emitStatusToast(newStatus); + } + } + + function emitStatusToast(statusObj: var): void { + if (!GlobalConfig.utilities.toasts.vpnChanged) return; const displayName = root.currentConfig ? (root.currentConfig.displayName || "VPN") : "VPN"; - if (connected) { + + switch (statusObj.state) { + case "connected": Toaster.toast(qsTr("VPN connected"), qsTr("Connected to %1").arg(displayName), "vpn_key"); - } else { - Toaster.toast(qsTr("VPN disconnected"), qsTr("Disconnected from %1").arg(displayName), "vpn_key_off"); + break; + case "disconnected": + if (status.connected) { + Toaster.toast(qsTr("VPN disconnected"), qsTr("Disconnected from %1").arg(displayName), "vpn_key_off"); + } + break; + case "needs-auth": + const authMsg = statusObj.reason || "Authentication required"; + Toaster.toast(qsTr("VPN authentication required"), qsTr("%1: %2").arg(displayName).arg(authMsg), "vpn_lock"); + break; + case "error": + if (status.state === "connected" || status.state === "connecting" || status.state === "needs-auth") { + const errMsg = statusObj.reason || "Unknown error"; + Toaster.toast(qsTr("VPN error"), qsTr("%1: %2").arg(displayName).arg(errMsg), "error"); + } + break; + } + } + + onStatusChanged: { + if (providerName === "warp" && status.state === "needs-auth" && status.reason.includes("registration")) { + warpRegisterProc.exec(["warp-cli", "registration", "new"]); } } + onProviderNameChanged: { + status = { + connected: false, + state: "disconnected", + reason: "", + authUrl: "" + }; + root.connected = false; + statusCheckTimer.start(); + } + Component.onCompleted: root.enabled && statusCheckTimer.start() Process { @@ -127,15 +338,47 @@ Singleton { Process { id: statusProc - command: ["ip", "link", "show"] + command: root.getStatusCommand() + // qmllint disable incompatible-type environment: ({ + // qmllint enable incompatible-type LANG: "C.UTF-8", LC_ALL: "C.UTF-8" }) stdout: StdioCollector { onStreamFinished: { - const iface = root.currentConfig ? root.currentConfig.interface : ""; - root.connected = iface && text.includes(iface + ":"); + const newStatus = root.parseStatusOutput(text); + root.updateStatus(newStatus); + } + } + stderr: StdioCollector { + onStreamFinished: { + if (text.trim().length > 0) { + if (text.includes("doesn't appear to be running") || text.includes("failed to connect to local tailscaled") || text.includes("daemon is not running") || text.includes("not running") && (text.includes("netbird") || text.includes("warp"))) { + let cmd = "sudo systemctl start "; + switch (root.providerName) { + case "tailscale": + cmd += "tailscaled"; + break; + case "netbird": + cmd += "netbird"; + break; + case "warp": + cmd += "warp-svc"; + break; + default: + cmd += root.providerName + "d"; + break; + } + const errorStatus = { + connected: false, + state: "disconnected", + reason: `Service not running (run: ${cmd})`, + authUrl: "" + }; + root.updateStatus(errorStatus); + } + } } } } @@ -143,12 +386,59 @@ Singleton { Process { id: connectProc - onExited: statusCheckTimer.start() + onExited: exitCode => { // qmllint disable signal-handler-parameters + if (exitCode !== 0) { + return; + } + + if (root.providerName === "tailscale") { + Qt.callLater(() => { + if (root.status.state !== "needs-auth") { + statusCheckTimer.start(); + } + }); + } else if (root.status.state !== "needs-auth") { + statusCheckTimer.start(); + } + } + stdout: SplitParser { + onRead: data => { + const authUrl = root.extractAuthUrl(data); + if (authUrl) { + root.updateStatus(root.createAuthStatus(authUrl)); + } + } + } stderr: StdioCollector { onStreamFinished: { const error = text.trim(); - if (error && !error.includes("[#]") && !error.includes("already exists")) { - console.warn("VPN connection error:", error); + + if (error.includes("Access denied") || error.includes("checkprefs access denied")) { + const errorStatus = { + connected: false, + state: "disconnected", + reason: "Permission denied. Run in terminal: sudo tailscale set --operator=$USER", + authUrl: "" + }; + root.updateStatus(errorStatus); + return; + } + + if (error.includes("Unknown device type") || error.includes("Protocol not supported")) { + const errorStatus = { + connected: false, + state: "disconnected", + reason: "WireGuard module not loaded. Run: sudo modprobe wireguard", + authUrl: "" + }; + root.updateStatus(errorStatus); + return; + } + + const authUrl = root.extractAuthUrl(error); + + if (authUrl) { + root.updateStatus(root.createAuthStatus(authUrl)); } else if (error.includes("already exists")) { root.connected = true; } @@ -159,21 +449,38 @@ Singleton { Process { id: disconnectProc - onExited: statusCheckTimer.start() + onExited: statusCheckTimer.start() // qmllint disable signal-handler-parameters stderr: StdioCollector { onStreamFinished: { const error = text.trim(); if (error && !error.includes("[#]")) { - console.warn("VPN disconnection error:", error); + console.warn(lc, "Disconnection error:", error); } } } } + Process { + id: warpRegisterProc + + onExited: exitCode => { // qmllint disable signal-handler-parameters + if (exitCode === 0) { + statusCheckTimer.start(); + } + } + } + Timer { id: statusCheckTimer interval: 500 onTriggered: root.checkStatus() } + + LoggingCategory { + id: lc + + name: "caelestia.qml.services.vpn" + defaultLogLevel: LoggingCategory.Info + } } diff --git a/services/Visibilities.qml b/services/Visibilities.qml index 5ddde0c95..391870502 100644 --- a/services/Visibilities.qml +++ b/services/Visibilities.qml @@ -1,16 +1,18 @@ pragma Singleton import Quickshell +import qs.components +import qs.services Singleton { property var screens: new Map() property var bars: new Map() - function load(screen: ShellScreen, visibilities: var): void { + function load(screen: ShellScreen, visibilities: DrawerVisibilities): void { screens.set(Hypr.monitorFor(screen), visibilities); } - function getForActive(): PersistentProperties { + function getForActive(): DrawerVisibilities { return screens.get(Hypr.focusedMonitor); } } diff --git a/services/Wallpapers.qml b/services/Wallpapers.qml index cb96bc565..15daf5c16 100644 --- a/services/Wallpapers.qml +++ b/services/Wallpapers.qml @@ -1,17 +1,18 @@ pragma Singleton -import qs.config -import qs.utils -import Caelestia.Models +import QtQuick import Quickshell import Quickshell.Io -import QtQuick +import Caelestia.Config +import Caelestia.Models +import qs.services +import qs.utils Searcher { id: root readonly property string currentNamePath: `${Paths.state}/wallpaper/path.txt` - readonly property list smartArg: Config.services.smartScheme ? [] : ["--no-smart"] + readonly property list smartArg: GlobalConfig.services.smartScheme ? [] : ["--no-smart"] property bool showPreview: false readonly property string current: showPreview ? previewPath : actualCurrent @@ -40,14 +41,12 @@ Searcher { list: wallpapers.entries key: "relativePath" - useFuzzy: Config.launcher.useFuzzy.wallpapers + useFuzzy: GlobalConfig.launcher.useFuzzy.wallpapers extraOpts: useFuzzy ? ({}) : ({ forward: false }) IpcHandler { - target: "wallpaper" - function get(): string { return root.actualCurrent; } @@ -59,6 +58,8 @@ Searcher { function list(): string { return root.list.map(w => w.path).join("\n"); } + + target: "wallpaper" } FileView { diff --git a/services/Weather.qml b/services/Weather.qml index a3095423b..9c30bf7a8 100644 --- a/services/Weather.qml +++ b/services/Weather.qml @@ -1,10 +1,10 @@ pragma Singleton -import qs.config -import qs.utils -import Caelestia -import Quickshell import QtQuick +import Quickshell +import Caelestia +import Caelestia.Config +import qs.utils Singleton { id: root @@ -17,17 +17,17 @@ Singleton { readonly property string icon: cc ? Icons.getWeatherIcon(cc.weatherCode) : "cloud_alert" readonly property string description: cc?.weatherDesc ?? qsTr("No weather") - readonly property string temp: Config.services.useFahrenheit ? `${cc?.tempF ?? 0}°F` : `${cc?.tempC ?? 0}°C` - readonly property string feelsLike: Config.services.useFahrenheit ? `${cc?.feelsLikeF ?? 0}°F` : `${cc?.feelsLikeC ?? 0}°C` + readonly property string temp: GlobalConfig.services.useFahrenheit ? `${cc?.tempF ?? 0}°F` : `${cc?.tempC ?? 0}°C` + readonly property string feelsLike: GlobalConfig.services.useFahrenheit ? `${cc?.feelsLikeF ?? 0}°F` : `${cc?.feelsLikeC ?? 0}°C` readonly property int humidity: cc?.humidity ?? 0 readonly property real windSpeed: cc?.windSpeed ?? 0 - readonly property string sunrise: cc ? Qt.formatDateTime(new Date(cc.sunrise), Config.services.useTwelveHourClock ? "h:mm A" : "h:mm") : "--:--" - readonly property string sunset: cc ? Qt.formatDateTime(new Date(cc.sunset), Config.services.useTwelveHourClock ? "h:mm A" : "h:mm") : "--:--" + readonly property string sunrise: cc ? Qt.formatDateTime(new Date(cc.sunrise), GlobalConfig.services.useTwelveHourClock ? "h:mm A" : "h:mm") : "--:--" + readonly property string sunset: cc ? Qt.formatDateTime(new Date(cc.sunset), GlobalConfig.services.useTwelveHourClock ? "h:mm A" : "h:mm") : "--:--" readonly property var cachedCities: new Map() function reload(): void { - const configLocation = Config.services.weatherLocation; + const configLocation = GlobalConfig.services.weatherLocation; if (configLocation) { if (configLocation.indexOf(",") !== -1 && !isNaN(parseFloat(configLocation.split(",")[0]))) { @@ -54,18 +54,35 @@ Singleton { return; } - const [lat, lon] = coords.split(","); - const url = `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=geocodejson`; - Requests.get(url, text => { + const [lat, lon] = coords.split(",").map(s => s.trim()); + + const fallbackToBigDataCloud = () => { + const fallbackUrl = `https://api.bigdatacloud.net/data/reverse-geocode-client?latitude=${lat}&longitude=${lon}&localityLanguage=en`; + Requests.get(fallbackUrl, text => { + const geo = JSON.parse(text); + const geoCity = geo.city || geo.locality; + if (geoCity) { + city = geoCity; + cachedCities.set(coords, geoCity); + } else { + city = "Unknown City"; + } + }); + }; + + const nominatimUrl = `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=geocodejson`; + Requests.get(nominatimUrl, text => { const geo = JSON.parse(text).features?.[0]?.properties.geocoding; if (geo) { const geoCity = geo.type === "city" ? geo.name : geo.city; - city = geoCity; - cachedCities.set(coords, geoCity); - } else { - city = "Unknown City"; + if (geoCity) { + city = geoCity; + cachedCities.set(coords, geoCity); + return; + } } - }); + fallbackToBigDataCloud(); + }, fallbackToBigDataCloud); } function fetchCoordsFromCity(cityName: string): void { @@ -104,14 +121,14 @@ Singleton { humidity: json.current.relative_humidity_2m, windSpeed: json.current.wind_speed_10m, isDay: json.current.is_day, - sunrise: json.daily.sunrise[0], - sunset: json.daily.sunset[0] + sunrise: json.daily.sunrise[0].replace("T", " "), + sunset: json.daily.sunset[0].replace("T", " ") }; const forecastList = []; for (let i = 0; i < json.daily.time.length; i++) forecastList.push({ - date: json.daily.time[i], + date: json.daily.time[i].replace(/-/g, "/"), maxTempC: Math.round(json.daily.temperature_2m_max[i]), maxTempF: Math.round(toFahrenheit(json.daily.temperature_2m_max[i])), minTempC: Math.round(json.daily.temperature_2m_min[i]), @@ -124,7 +141,8 @@ Singleton { const hourlyList = []; const now = new Date(); for (let i = 0; i < json.hourly.time.length; i++) { - const time = new Date(json.hourly.time[i]); + const time = new Date(json.hourly.time[i].replace("T", " ")); + if (time < now) continue; @@ -149,7 +167,7 @@ Singleton { if (!loc || loc.indexOf(",") === -1) return ""; - const [lat, lon] = loc.split(","); + const [lat, lon] = loc.split(",").map(s => s.trim()); const baseUrl = "https://api.open-meteo.com/v1/forecast"; const params = ["latitude=" + lat, "longitude=" + lon, "hourly=weather_code,temperature_2m", "daily=weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset", "current=temperature_2m,relative_humidity_2m,apparent_temperature,is_day,weather_code,wind_speed_10m", "timezone=auto", "forecast_days=7"]; @@ -192,7 +210,14 @@ Singleton { onLocChanged: fetchWeatherData() - // Refresh current location hourly + Connections { + function onWeatherLocationChanged(): void { + root.reload(); + } + + target: GlobalConfig.services + } + Timer { interval: 3600000 // 1 hour running: true diff --git a/shell b/shell new file mode 160000 index 000000000..63bb82762 --- /dev/null +++ b/shell @@ -0,0 +1 @@ +Subproject commit 63bb82762bb29ac9b7fcd5b97839abae721ce860 diff --git a/shell.qml b/shell.qml index 3ce777699..7cb423541 100644 --- a/shell.qml +++ b/shell.qml @@ -1,6 +1,8 @@ -//@ pragma Env QS_NO_RELOAD_POPUP=1 -//@ pragma Env QSG_RENDER_LOOP=threaded -//@ pragma Env QT_QUICK_FLICKABLE_WHEEL_DECELERATION=10000 +//@ pragma Env QS_CRASHREPORT_URL=https://github.com/caelestia-dots/shell/issues/new?template=crash.yml +//@ pragma DefaultEnv QS_NO_RELOAD_POPUP=1 +//@ pragma DefaultEnv QS_DROP_EXPENSIVE_FONTS=1 +//@ pragma DefaultEnv QSG_RENDER_LOOP=threaded +//@ pragma DefaultEnv QT_QUICK_FLICKABLE_WHEEL_DECELERATION=10000 import "modules" import "modules/drawers" @@ -10,6 +12,8 @@ import "modules/lock" import Quickshell ShellRoot { + settings.watchFiles: true + Background {} Drawers {} AreaPicker {} @@ -17,9 +21,13 @@ ShellRoot { id: lock } + ConfigToasts {} Shortcuts {} BatteryMonitor {} IdleMonitors { lock: lock } + MonitorIdentifier { + id: monitorIdentifier + } } diff --git a/utils/Icons.qml b/utils/Icons.qml index c06cbf809..c864d5530 100644 --- a/utils/Icons.qml +++ b/utils/Icons.qml @@ -1,9 +1,9 @@ pragma Singleton -import qs.config +import QtQuick import Quickshell import Quickshell.Services.Notifications -import QtQuick +import Caelestia.Config Singleton { id: root @@ -79,6 +79,26 @@ Singleton { Office: "content_paste" }) + // Checks if a name matches an icon config. Icon configs can have the following keys: + // - name: The exact name of the icon + // - regex: A regex to match against the name (takes priority over name) + // - flags: The regex flags (only used if regex is set) + // - icon: The icon to use + function matchIconConfig(name: string, iconConfig: var): bool { + if (!iconConfig.icon) + return false; + + if (iconConfig.regex) { + const re = new RegExp(iconConfig.regex, iconConfig.flags ?? ""); + if (re.test(name)) + return true; + } else if (iconConfig.name === name) { + return true; + } + + return false; + } + function getAppIcon(name: string, fallback: string): string { const icon = DesktopEntries.heuristicLookup(name)?.icon; if (fallback !== "undefined") @@ -87,6 +107,10 @@ Singleton { } function getAppCategoryIcon(name: string, fallback: string): string { + for (const iconConfig of GlobalConfig.bar.workspaces.windowIcons) + if (matchIconConfig(name, iconConfig)) + return iconConfig.icon; + const categories = DesktopEntries.heuristicLookup(name)?.categories; if (categories) @@ -188,11 +212,9 @@ Singleton { function getSpecialWsIcon(name: string): string { name = name.toLowerCase().slice("special:".length); - for (const iconConfig of Config.bar.workspaces.specialWorkspaceIcons) { - if (iconConfig.name === name) { + for (const iconConfig of GlobalConfig.bar.workspaces.specialWorkspaceIcons) + if (matchIconConfig(name, iconConfig)) return iconConfig.icon; - } - } if (name === "special") return "star"; @@ -208,7 +230,7 @@ Singleton { } function getTrayIcon(id: string, icon: string): string { - for (const sub of Config.bar.tray.iconSubs) + for (const sub of GlobalConfig.bar.tray.iconSubs) if (sub.id === id) return sub.image ? Qt.resolvedUrl(sub.image) : Quickshell.iconPath(sub.icon); @@ -218,4 +240,24 @@ Singleton { } return icon; } + + function getBatteryIcon(charge: int): string { + if (charge > 0 && charge < 5) + return "battery_0_bar"; + if (charge >= 5 && charge < 20) + return "battery_1_bar"; + if (charge >= 20 && charge < 35) + return "battery_2_bar"; + if (charge >= 35 && charge < 50) + return "battery_3_bar"; + if (charge >= 50 && charge < 65) + return "battery_4_bar"; + if (charge >= 65 && charge < 80) + return "battery_5_bar"; + if (charge >= 80 && charge < 95) + return "battery_6_bar"; + if (charge >= 95) + return "battery_full"; + return "battery_alert"; + } } diff --git a/utils/NetworkConnection.qml b/utils/NetworkConnection.qml index e55b87bc4..8331813d9 100644 --- a/utils/NetworkConnection.qml +++ b/utils/NetworkConnection.qml @@ -1,7 +1,7 @@ pragma Singleton -import qs.services import QtQuick +import qs.services /** * NetworkConnection diff --git a/utils/Paths.qml b/utils/Paths.qml index bc89770ab..97f6448a5 100644 --- a/utils/Paths.qml +++ b/utils/Paths.qml @@ -1,8 +1,9 @@ pragma Singleton -import qs.config -import Caelestia +import QtQuick import Quickshell +import Caelestia +import Caelestia.Config Singleton { id: root @@ -18,7 +19,7 @@ Singleton { readonly property string imagecache: `${cache}/imagecache` readonly property string notifimagecache: `${imagecache}/notifs` - readonly property string wallsdir: Quickshell.env("CAELESTIA_WALLPAPERS_DIR") || absolutePath(Config.paths.wallpaperDir) + readonly property string wallsdir: Quickshell.env("CAELESTIA_WALLPAPERS_DIR") || absolutePath(GlobalConfig.paths.wallpaperDir) readonly property string recsdir: Quickshell.env("CAELESTIA_RECORDINGS_DIR") || `${videos}/Recordings` readonly property string libdir: Quickshell.env("CAELESTIA_LIB_DIR") || "/usr/lib/caelestia" diff --git a/utils/Searcher.qml b/utils/Searcher.qml index 053b73bba..102c9e766 100644 --- a/utils/Searcher.qml +++ b/utils/Searcher.qml @@ -1,8 +1,7 @@ -import Quickshell - import "scripts/fzf.js" as Fzf import "scripts/fuzzysort.js" as Fuzzy import QtQuick +import Quickshell Singleton { required property list list diff --git a/utils/Strings.qml b/utils/Strings.qml new file mode 100644 index 000000000..a91a0c082 --- /dev/null +++ b/utils/Strings.qml @@ -0,0 +1,26 @@ +pragma Singleton + +import Quickshell + +Singleton { + property var _regexCache: ({}) + + function testRegexList(filterList: list, target: string): bool { + const regexChecker = /^\^.*\$$/; + for (const filter of filterList) { + if (regexChecker.test(filter)) { + let re = _regexCache[filter]; + if (!re) { + re = new RegExp(filter); + _regexCache[filter] = re; + } + if (re.test(target)) + return true; + } else { + if (filter === target) + return true; + } + } + return false; + } +} diff --git a/utils/SysInfo.qml b/utils/SysInfo.qml index 19aa4a7a7..c715b8d28 100644 --- a/utils/SysInfo.qml +++ b/utils/SysInfo.qml @@ -1,10 +1,10 @@ pragma Singleton -import qs.config -import qs.utils +import QtQuick import Quickshell import Quickshell.Io -import QtQuick +import Caelestia.Config +import qs.utils Singleton { id: root @@ -36,11 +36,11 @@ Singleton { root.osIdLike = fd("ID_LIKE").split(" "); const logo = Quickshell.iconPath(fd("LOGO"), true); - if (Config.general.logo === "caelestia") { + if (GlobalConfig.general.logo === "caelestia") { root.osLogo = Qt.resolvedUrl(`${Quickshell.shellDir}/assets/logo.svg`); root.isDefaultLogo = true; - } else if (Config.general.logo) { - root.osLogo = Quickshell.iconPath(Config.general.logo, true) || "file://" + Paths.absolutePath(Config.general.logo); + } else if (GlobalConfig.general.logo) { + root.osLogo = Quickshell.iconPath(GlobalConfig.general.logo, true) || "file://" + Paths.absolutePath(GlobalConfig.general.logo); root.isDefaultLogo = false; } else if (logo) { root.osLogo = logo; @@ -50,11 +50,11 @@ Singleton { } Connections { - target: Config.general - function onLogoChanged(): void { osRelease.reload(); } + + target: GlobalConfig.general } Timer { diff --git a/utils/scripts/lrcparser.js b/utils/scripts/lrcparser.js new file mode 100644 index 000000000..847779eda --- /dev/null +++ b/utils/scripts/lrcparser.js @@ -0,0 +1,62 @@ +function parseLrc(text) { + if (!text) return []; + let lines = text.split("\n"); + let result = []; + + let timeRegex = /\[(\d+):(\d+\.\d+|\d+)\]/g; + + // Blacklist for credits/metadata often found in NetEase lyrics + const creditKeywords = [ + "作词", "作曲", "编曲", "制作", "收录", "演奏", "词:", "曲:", "Lyricist", "Composer", "Arranger", "Producer", "Mixing", "Mastering" + ]; + + for (let line of lines) { + + timeRegex.lastIndex = 0; + let matches = []; + let match; + + while ((match = timeRegex.exec(line)) !== null) { + matches.push(match); + } + + if (matches.length === 0) continue; + + let lyric = line.replace(timeRegex, "").trim(); + + let min = parseInt(matches[0][1]); + let sec = parseFloat(matches[0][2]); + let totalTime = min * 60 + sec; + + // Only filter credits if they appear in the first 20 seconds + if (totalTime < 20) { + let isCreditFormat = creditKeywords.some(k => lyric.includes(k)); + if (isCreditFormat && (lyric.includes(":") || lyric.includes(":") || lyric.length < 25)) { + continue; + } + } + + for (let match of matches) { + let min = parseInt(match[1]); + let sec = parseFloat(match[2]); + + result.push({ + time: min * 60 + sec, + text: lyric + }); + } + } + + result.sort((a, b) => a.time - b.time); + return result; +} + +function getCurrentLine(lyrics, position) { + const epsilon = 0.1; // 100ms tolerance + for (let i = lyrics.length - 1; i >= 0; i--) { + if ((position + epsilon) >= lyrics[i].time) { + return i; + } + } + return -1; +}