diff --git a/.config/btop/themes/caelestia.theme b/.config/btop/themes/caelestia.theme new file mode 100644 index 0000000..190368f --- /dev/null +++ b/.config/btop/themes/caelestia.theme @@ -0,0 +1,83 @@ +# Main background, empty for terminal default, need to be empty if you want transparent background +theme[main_bg]=#18120b + +# Main text color +theme[main_fg]=#ede0d4 + +# Title color for boxes +theme[title]=#ede0d4 + +# Highlight color for keyboard shortcuts +theme[hi_fg]=#f3bd6e + +# Background color of selected item in processes box +theme[selected_bg]=#251f17 + +# Foreground color of selected item in processes box +theme[selected_fg]=#f3bd6e + +# Color of inactive/disabled text +theme[inactive_fg]=#9b8f80 + +# Color of text appearing on top of graphs, i.e uptime and current network graph scaling +theme[graph_text]=#b7cea2 + +# Background color of the percentage meters +theme[meter_bg]=#9b8f80 + +# Misc colors for processes box including mini cpu graphs, details memory graph and details status text +theme[proc_misc]=#b7cea2 + +# CPU, Memory, Network, Proc box outline colors +theme[cpu_box]=#ffad7f +theme[mem_box]=#ffdc85 +theme[net_box]=#f0b279 +theme[proc_box]=#ffa2bd + +# Box divider line and small boxes line color +theme[div_line]=#4f4539 + +# Temperature graph color (Green -> Yellow -> Red) +theme[temp_start]=#ffdc85 +theme[temp_mid]=#ffefdb +theme[temp_end]=#f6a14d + +# CPU graph colors (Teal -> Sapphire -> Lavender) +theme[cpu_start]=#f5df89 +theme[cpu_mid]=#b3d27e +theme[cpu_end]=#ffbcbb + +# Mem/Disk free meter (Mauve -> Lavender -> Blue) +theme[free_start]=#ffad7f +theme[free_mid]=#ffbcbb +theme[free_end]=#ffa2bd + +# Mem/Disk cached meter (Sapphire -> Blue -> Lavender) +theme[cached_start]=#b3d27e +theme[cached_mid]=#ffa2bd +theme[cached_end]=#ffbcbb + +# Mem/Disk available meter (Peach -> Maroon -> Red) +theme[available_start]=#fac482 +theme[available_mid]=#f0b279 +theme[available_end]=#f6a14d + +# Mem/Disk used meter (Green -> Teal -> Sky) +theme[used_start]=#ffdc85 +theme[used_mid]=#f5df89 +theme[used_end]=#e1df87 + +# Download graph colors (Peach -> Maroon -> Red) +theme[download_start]=#fac482 +theme[download_mid]=#f0b279 +theme[download_end]=#f6a14d + +# Upload graph colors (Green -> Teal -> Sky) +theme[upload_start]=#ffdc85 +theme[upload_mid]=#f5df89 +theme[upload_end]=#e1df87 + +# Process box color gradient for threads, mem and cpu usage (Sapphire -> Lavender -> Mauve) +theme[process_start]=#b3d27e +theme[process_mid]=#ffbcbb +theme[process_end]=#ffad7f diff --git a/.config/hypr/hyprland.conf b/.config/hypr/hyprland.conf index 7ad7556..b504bc1 100755 --- a/.config/hypr/hyprland.conf +++ b/.config/hypr/hyprland.conf @@ -23,9 +23,18 @@ env = XDG_SESSION_TYPE,wayland env = XDG_SESSION_DESKTOP,Hyprland env = XDG_SCREENSHOTS_DIR,$HOME/Pictures/Screenshots -env = LIBVA_DRIVER_NAME,nvidia -env = GBM_BACKEND,nvidia-drm -env = __GLX_VENDOR_LIBRARY_NAME,nvidia +# env = LIBVA_DRIVER_NAME,nvidia +# env = GBM_BACKEND,nvidia-drm +# env = __GLX_VENDOR_LIBRARY_NAME,nvidia + +env = AQ_DRM_DEVICES,/dev/dri/card2:/dev/dri/card1 + +# Toolkit Backend Variables +env = GDK_BACKEND,wayland,x11,* +env = QT_QPA_PLATFORM,wayland;xcb +env = CLUTTER_BACKEND,wayland + +env = GDK_BACKEND,wayland,x11,* env = QT_AUTO_SCREEN_SCALE_FACTOR,1 env = QT_QPA_PLATFORM,wayland;xcb @@ -33,6 +42,7 @@ env = QT_WAYLAND_DISABLE_WINDOWDECORATION,1 env = QT_QPA_PLATFORMTHEME,qt5ct env = XCURSOR_SIZE,24 +env = HYPRCURSOR_SIZE,24 ################ ### MONITORS ### ################ @@ -63,14 +73,14 @@ exec-once = ~/.config/hypr/start.sh # https://wiki.hyprland.org/Configuring/Variables/#general general { - gaps_in = 5 - gaps_out = 20 + gaps_in = 3 + gaps_out = 5 border_size = 2 - col.active_border = rgb(44475a) rgb(bd93f9) 90deg - col.inactive_border = rgba(44475aaa) - col.nogroup_border = rgba(282a36dd) - col.nogroup_border_active = rgb(bd93f9) rgb(44475a) 90deg + col.active_border = 0xffd3869b + col.inactive_border = 0xff45475a + # col.nogroup_border = rgba(282a36dd) + # col.nogroup_border_active = rgb(bd93f9) rgb(44475a) 90deg # Set to true enable resizing windows by clicking and dragging on borders and gaps resize_on_border = false @@ -89,19 +99,20 @@ decoration { rounding = 10 # Change transparency of focused and unfocused windows - active_opacity = 1.0 - inactive_opacity = 1.0 + active_opacity = 0.9 + inactive_opacity = 0.9 shadow { enabled = true - range = 4 - render_power = 3 - color = rgba(1E202966) -} + range = 4 + render_power = 3 + color = 0x33000000 + color_inactive = 0x22000000 + } # https://wiki.hyprland.org/Configuring/Variables/#blur blur { enabled = true - size = 3 - passes = 1 + size = 2 + passes = 4 vibrancy = 0.1696 } } @@ -141,6 +152,7 @@ misc { animate_mouse_windowdragging = true enable_swallow = true disable_hyprland_logo = true + vfr = true } diff --git a/.config/hypr/hyprpaper.conf b/.config/hypr/hyprpaper.conf index d137d0a..e34ef1f 100755 --- a/.config/hypr/hyprpaper.conf +++ b/.config/hypr/hyprpaper.conf @@ -1,4 +1,4 @@ -$path = ~/Pictures/Wallpapers/kdh.jpg +$path = ~/Pictures/Wallpapers/gruvroad.png wallpaper { monitor = eDP-1 path = $path diff --git a/.config/hypr/scheme/current.conf b/.config/hypr/scheme/current.conf new file mode 100644 index 0000000..96ec23e --- /dev/null +++ b/.config/hypr/scheme/current.conf @@ -0,0 +1,110 @@ +$primary_paletteKeyColor = 9b6f28 +$secondary_paletteKeyColor = 8a7457 +$tertiary_paletteKeyColor = 697d57 +$neutral_paletteKeyColor = 7f766c +$neutral_variant_paletteKeyColor = 817567 +$background = 18120b +$onBackground = ede0d4 +$surface = 18120b +$surfaceDim = 18120b +$surfaceBright = 3f382f +$surfaceContainerLowest = 120d07 +$surfaceContainerLow = 201b13 +$surfaceContainer = 251f17 +$surfaceContainerHigh = 2f2921 +$surfaceContainerHighest = 3b342b +$onSurface = ede0d4 +$surfaceVariant = 4f4539 +$onSurfaceVariant = d3c4b4 +$inverseSurface = ede0d4 +$inverseOnSurface = 362f27 +$outline = 9b8f80 +$outlineVariant = 4f4539 +$shadow = 000000 +$scrim = 000000 +$surfaceTint = f3bd6e +$primary = f3bd6e +$onPrimary = 442b00 +$primaryContainer = 624000 +$onPrimaryContainer = ffddb2 +$inversePrimary = 7f5610 +$secondary = ddc2a1 +$onSecondary = 3e2e16 +$secondaryContainer = 56442a +$onSecondaryContainer = fadebc +$tertiary = b7cea2 +$onTertiary = 243516 +$tertiaryContainer = 82976f +$onTertiaryContainer = 000000 +$error = ffb4ab +$onError = 690005 +$errorContainer = 93000a +$onErrorContainer = ffdad6 +$primaryFixed = ffddb2 +$primaryFixedDim = f3bd6e +$onPrimaryFixed = 291800 +$onPrimaryFixedVariant = 624000 +$secondaryFixed = fadebc +$secondaryFixedDim = ddc2a1 +$onSecondaryFixed = 271904 +$onSecondaryFixedVariant = 56442a +$tertiaryFixed = d3eabc +$tertiaryFixedDim = b7cea2 +$onTertiaryFixed = 0f2004 +$onTertiaryFixedVariant = 3a4c2a +$term0 = 353433 +$term1 = d07d00 +$term2 = ffc243 +$term3 = ffe1bb +$term4 = b9ab66 +$term5 = e79953 +$term6 = e8c66d +$term7 = e8d5bf +$term8 = afa090 +$term9 = f19300 +$term10 = ffd891 +$term11 = fff2e4 +$term12 = cfc092 +$term13 = f6b072 +$term14 = ffd673 +$term15 = ffffff +$rosewater = ffefe2 +$flamingo = fbdcc3 +$pink = ffd5b8 +$mauve = ffad7f +$red = f6a14d +$maroon = f0b279 +$peach = fac482 +$yellow = ffefdb +$green = ffdc85 +$teal = f5df89 +$sky = e1df87 +$sapphire = b3d27e +$blue = ffa2bd +$lavender = ffbcbb +$klink = 559652 +$klinkSelection = 559652 +$kvisited = c66716 +$kvisitedSelection = c66716 +$knegative = c67400 +$knegativeSelection = c67400 +$kneutral = ed9800 +$kneutralSelection = ed9800 +$kpositive = cea400 +$kpositiveSelection = cea400 +$text = ede0d4 +$subtext1 = d3c4b4 +$subtext0 = 9b8f80 +$overlay2 = 887c6e +$overlay1 = 73695c +$overlay0 = 61584c +$surface2 = 50473c +$surface1 = 3e362d +$surface0 = 2b241c +$base = 18120b +$mantle = 18120b +$crust = 17110a +$success = B5CCBA +$onSuccess = 213528 +$successContainer = 374B3E +$onSuccessContainer = D1E9D6 diff --git a/.config/hypr/start.sh b/.config/hypr/start.sh index c467732..5a163c1 100755 --- a/.config/hypr/start.sh +++ b/.config/hypr/start.sh @@ -20,10 +20,11 @@ run_and_time() { echo "--- Hyprland Startup: $(date) ---" -run_and_time qs -c nucleus-shell +run_and_time qs -c caelestia # run_and_time systemctl --user start hyprpaper # run_and_time systemctl --user start hypridle # run_and_time systemctl --user start hyprpolkitagent +run_and_time systemctl --user start xdg-desktop-portal-hyprland run_and_time ssh-agent run_and_time copyq run_and_time kdeconnect-indicator diff --git a/.config/quickshell/caelestia/.clang-format b/.config/quickshell/caelestia/.clang-format new file mode 100644 index 0000000..75eab98 --- /dev/null +++ b/.config/quickshell/caelestia/.clang-format @@ -0,0 +1,25 @@ +--- +BasedOnStyle: LLVM +IndentWidth: 4 +ColumnLimit: 120 +--- +Language: Cpp +DerivePointerAlignment: false +PointerAlignment: Left +AccessModifierOffset: -4 +AlignAfterOpenBracket: DontAlign +AllowShortEnumsOnASingleLine: false +AllowShortFunctionsOnASingleLine: Inline +AllowShortLambdasOnASingleLine: None +BinPackArguments: true +BreakBeforeBraces: Attach +BreakConstructorInitializers: BeforeComma +Cpp11BracedListStyle: false +EmptyLineAfterAccessModifier: Never +EmptyLineBeforeAccessModifier: Always +IndentAccessModifiers: false +IndentCaseLabels: false +InsertNewlineAtEOF: true +SeparateDefinitionBlocks: Always +WrapNamespaceBodyWithEmptyLines: Always +... diff --git a/.config/quickshell/caelestia/.envrc b/.config/quickshell/caelestia/.envrc new file mode 100644 index 0000000..c90b500 --- /dev/null +++ b/.config/quickshell/caelestia/.envrc @@ -0,0 +1,15 @@ +if has nix; then + use flake +fi + +shopt -s globstar +watch_file assets/cpp/**/*.cpp +watch_file assets/cpp/**/*.hpp +watch_file plugin/**/*.cpp +watch_file plugin/**/*.hpp +watch_file **/CMakeLists.txt + +cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo -DCMAKE_CXX_COMPILER=clazy -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -DDISTRIBUTOR=direnv +cmake --build build +export CAELESTIA_LIB_DIR="$PWD/build/lib" +export QML2_IMPORT_PATH="$PWD/build/qml:${QML2_IMPORT_PATH:-}" diff --git a/.config/quickshell/caelestia/.github/CONTRIBUTING.md b/.config/quickshell/caelestia/.github/CONTRIBUTING.md new file mode 100644 index 0000000..d0239c0 --- /dev/null +++ b/.config/quickshell/caelestia/.github/CONTRIBUTING.md @@ -0,0 +1,21 @@ +# Contributing + +There are only a few rules: +- Follow the commit convention as follows: + - The name of the commit should be `module: change` + - Try to be consistent with the module names; you can look at existing commits for the module names I use + - If there is more than one change, the change in the commit name should be the most impactful change + - Put other changes in the description +- Format your code + - I use the vscode qml extension with default arguments to format the code, however you do not have to use it + - Just try to follow the code style of the rest of the code and ensure that there is: + - no trailing whitespace on any lines + - a single space between operators +- No AI slop allowed + - AI readme/docs slop = instant block +- PLEASE TEST YOUR PRS + - I can't believe I have to put this here, but please test your PRs before submitting them + - Your PR must not break anything currently existing, or specify in the description if it does +- PR descriptions should be descriptive + - Please explain what the PR does and how to use it in your PR description + - Also include any breaking changes and/or side effects of the PR diff --git a/.config/quickshell/caelestia/.github/FUNDING.yml b/.config/quickshell/caelestia/.github/FUNDING.yml new file mode 100644 index 0000000..30f44f7 --- /dev/null +++ b/.config/quickshell/caelestia/.github/FUNDING.yml @@ -0,0 +1,15 @@ +# These are supported funding model platforms + +github: soramanew +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: soramane +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +polar: # Replace with a single Polar username +buy_me_a_coffee: soramane +thanks_dev: # Replace with a single thanks.dev username +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.config/quickshell/caelestia/.github/ISSUE_TEMPLATE/config.yml b/.config/quickshell/caelestia/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.config/quickshell/caelestia/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.config/quickshell/caelestia/.github/ISSUE_TEMPLATE/feature.yml b/.config/quickshell/caelestia/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 0000000..c5caffa --- /dev/null +++ b/.config/quickshell/caelestia/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,24 @@ +name: Feature request +description: Suggest a new feature +labels: ["enhancement"] +type: "Feature" +title: "[FEATURE] " +body: + - type: markdown + attributes: + value: "NOTE: Please write in **English**." + + - type: textarea + attributes: + label: "What would you like to be added?" + description: "Can be a suggestion for an existing feature. You can suggest a widget, minor user interaction changes.. whatever." + + - type: textarea + attributes: + label: "How will it help?" + description: "It's helpful to include examples (like in your use case)." + + - type: textarea + attributes: + label: "Extra info" + description: "If you want a new widget, a pic of the inspiration (if available) would be awesome." diff --git a/.config/quickshell/caelestia/.github/ISSUE_TEMPLATE/issue.yml b/.config/quickshell/caelestia/.github/ISSUE_TEMPLATE/issue.yml new file mode 100644 index 0000000..b767104 --- /dev/null +++ b/.config/quickshell/caelestia/.github/ISSUE_TEMPLATE/issue.yml @@ -0,0 +1,56 @@ +name: Issue +description: Report an issue with the dots +labels: ["bug"] +type: "Bug" +title: "[BUG] " +body: + - type: markdown + attributes: + value: "**Welcome to submit a new issue!**\n- It takes only 3 steps, so please be patient :)\n- Tip: If your issue is not a feature request and is not an issue with the dots (e.g. \"how do I use X feature\"), please use [Discussions](https://github.com/caelestia-dots/shell/discussions) instead." + - type: checkboxes + attributes: + label: "Step 1. Before you submit" + description: "Hint: The 2nd and 3rd checkbox is **not** forcely required as you may have failed to do so." + options: + - label: I have read the above instructions and am sure that this is supposed to be posted here. + required: true + - 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 # Not required cuz user may have failed to do so + - label: I've successfully updated the system packages to the latest. + required: false # Not required cuz user may have failed to do so + - label: I've ticked the checkboxes without reading their contents + required: false # Obviously + + - type: textarea + attributes: + label: "Step 2. Version info" + description: "Run `caelestia -v` and paste the result below." + value: "
Version info\n\n```\n\n```\n\n
" + validations: + required: true + + - type: markdown + attributes: + value: | + **Tips for the following Step 3** + 1. Use `LANG=C LC_ALL=C` to get the output of a command in English, eg. `LANG=C LC_ALL=C date` displays time in English. + 2. If it throws errors, **PLEASE**, attach logs and describe in detail if possible. + - Something happened to the shell (bar, dashboard, etc)? Run `caelestia shell -l` WITHOUT exiting the shell for logs. + - Installation failed? Run installation again for logs. + - You may use more code blocks when needed. + 3. In case you are confused, the `
`, ``, ``, `
` are HTML tags for folding the logs (typically very long) inside. Please do not touch them (unless you know what you are doing). + 4. If the logs are suuuuuuper long, consider using an online pastebin service instead. + + - type: textarea + attributes: + label: "Step 3. Describe the issue" + value: "\n\n\n
Logs\n\n```\n\n```\n\n
" + validations: + required: true + + - type: checkboxes + attributes: + label: Reminder + options: + - label: I agree that it's usually impossible for others to help me without my logs. + required: true diff --git a/.config/quickshell/caelestia/.github/workflows/release.yml b/.config/quickshell/caelestia/.github/workflows/release.yml new file mode 100644 index 0000000..ac43772 --- /dev/null +++ b/.config/quickshell/caelestia/.github/workflows/release.yml @@ -0,0 +1,37 @@ +name: Create release + +on: + push: + tags: + - 'v*' + +jobs: + build-and-release: + runs-on: ubuntu-latest + + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + + - name: Create packages + run: | + mkdir -p release + rsync -av \ + --exclude='release' \ + --exclude='.*' \ + --exclude='nix' \ + --exclude='flake.lock' \ + --exclude='flake.nix' \ + . release + tar -czf caelestia-shell-${{ github.ref_name }}.tar.gz release + cp caelestia-shell-${{ github.ref_name }}.tar.gz caelestia-shell-latest.tar.gz + + - name: Create release + uses: softprops/action-gh-release@v2 + with: + files: | + caelestia-shell-${{ github.ref_name }}.tar.gz + caelestia-shell-latest.tar.gz + generate_release_notes: true diff --git a/.config/quickshell/caelestia/.github/workflows/update-flake-inputs.yml b/.config/quickshell/caelestia/.github/workflows/update-flake-inputs.yml new file mode 100644 index 0000000..1a8bd07 --- /dev/null +++ b/.config/quickshell/caelestia/.github/workflows/update-flake-inputs.yml @@ -0,0 +1,85 @@ +name: Update flake inputs + +on: + workflow_dispatch: + schedule: + - cron: '0 0 * * 0' + +jobs: + update-flake: + runs-on: ubuntu-latest + + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + + - name: Install Nix + uses: nixbuild/nix-quick-install-action@v31 + with: + nix_conf: | + keep-env-derivations = true + keep-outputs = true + + - name: Restore and save Nix store + uses: nix-community/cache-nix-action@v6 + with: + # restore and save a cache using this key + primary-key: nix-${{ hashFiles('**/*.nix', '**/flake.lock') }} + # if there's no cache hit, restore a cache by this prefix + restore-prefixes-first-match: nix- + # collect garbage until the Nix store size (in bytes) is at most this number + # before trying to save a new cache + # 1G = 1073741824 + gc-max-store-size-linux: 1G + # do purge caches + purge: true + # purge all versions of the cache + purge-prefixes: nix- + # created more than this number of seconds ago + purge-created: 0 + # or, last accessed more than this number of seconds ago + # relative to the start of the `Post Restore and save Nix store` phase + purge-last-accessed: 0 + # except any version with the key that is the same as the `primary-key` + purge-primary-key: never + + - name: Update flake inputs + run: nix flake update + + - name: Attempt to build flake + run: nix build + + - name: Test on Sway + env: + XDG_RUNTIME_DIR: /home/runner/runtime + WLR_BACKENDS: headless + WLR_LIBINPUT_NO_DEVICES: 1 + WAYLAND_DISPLAY: wayland-1 + run: | + mkdir $XDG_RUNTIME_DIR + chown $USER $XDG_RUNTIME_DIR + chmod 0700 $XDG_RUNTIME_DIR + + nix profile install 'nixpkgs#sway' + sway & + sleep 3 # Give Sway some time to start + result/bin/caelestia-shell -d + sleep 3 # Give the shell some time to start (and die) + pgrep .quickshell-wra # Fail job if shell died + + result/bin/caelestia-shell kill + killall sway # Screw using IPC + + - name: Check for changes + id: check + run: echo modified=$(git diff --exit-code flake.lock &>/dev/null && echo 'false' || echo 'true') >> $GITHUB_OUTPUT + + - name: Commit and push changes + if: steps.check.outputs.modified == 'true' + uses: EndBug/add-and-commit@v9 + with: + add: flake.lock + default_author: github_actions + message: "[CI] chore: update flake" diff --git a/.config/quickshell/caelestia/.gitignore b/.config/quickshell/caelestia/.gitignore new file mode 100644 index 0000000..c30a6d9 --- /dev/null +++ b/.config/quickshell/caelestia/.gitignore @@ -0,0 +1,6 @@ +.direnv +/result +/.qmlls.ini +build/ +.cache/ +logs \ No newline at end of file diff --git a/.config/quickshell/caelestia/.vscode/settings.json b/.config/quickshell/caelestia/.vscode/settings.json new file mode 100644 index 0000000..13763e4 --- /dev/null +++ b/.config/quickshell/caelestia/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "editor.defaultFormatter": "theqtcompany.qt-qml", + "[cpp]": { + "editor.defaultFormatter": "llvm-vs-code-extensions.vscode-clangd" + } +} diff --git a/.config/quickshell/caelestia/CMakeLists.txt b/.config/quickshell/caelestia/CMakeLists.txt new file mode 100644 index 0000000..7b95855 --- /dev/null +++ b/.config/quickshell/caelestia/CMakeLists.txt @@ -0,0 +1,67 @@ +cmake_minimum_required(VERSION 3.19) + +if(NOT DEFINED VERSION) + execute_process(COMMAND git describe --tags --abbrev=0 + WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}" + OUTPUT_VARIABLE VERSION + OUTPUT_STRIP_TRAILING_WHITESPACE + ) + + if("${VERSION}" STREQUAL "") + message(FATAL_ERROR "VERSION is not set and failed to get from git") + endif() +endif() + +if(NOT DEFINED GIT_REVISION) + execute_process(COMMAND git rev-parse HEAD + WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}" + OUTPUT_VARIABLE GIT_REVISION + OUTPUT_STRIP_TRAILING_WHITESPACE + ) + + if("${GIT_REVISION}" STREQUAL "") + message(FATAL_ERROR "GIT_REVISION is not set and failed to get from git") + endif() +endif() + +string(REGEX REPLACE "^v" "" VERSION "${VERSION}") + +project(caelestia-shell VERSION ${VERSION} LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib") + +set(DISTRIBUTOR "Unset" CACHE STRING "Distributor") +set(ENABLE_MODULES "extras;plugin;shell" CACHE STRING "Modules to build/install") +set(INSTALL_LIBDIR "usr/lib/caelestia" CACHE STRING "Library install dir") +set(INSTALL_QMLDIR "usr/lib/qt6/qml" CACHE STRING "QML install dir") +set(INSTALL_QSCONFDIR "etc/xdg/quickshell/caelestia" CACHE STRING "Quickshell config install dir") + +add_compile_options( + -Wall -Wextra -Wpedantic -Wshadow -Wconversion + -Wold-style-cast -Wnull-dereference -Wdouble-promotion + -Wformat=2 -Wfloat-equal -Woverloaded-virtual + -Wsign-conversion -Wredundant-decls -Wswitch + -Wunreachable-code +) + +if(CMAKE_CXX_COMPILER_ID MATCHES "Clang") + add_compile_options(-Wunused-lambda-capture) +endif() + +if("extras" IN_LIST ENABLE_MODULES) + add_subdirectory(extras) +endif() + +if("plugin" IN_LIST ENABLE_MODULES) + add_subdirectory(plugin) +endif() + +if("shell" IN_LIST ENABLE_MODULES) + foreach(dir assets components config modules services utils) + install(DIRECTORY ${dir} DESTINATION "${INSTALL_QSCONFDIR}") + endforeach() + install(FILES shell.qml LICENSE DESTINATION "${INSTALL_QSCONFDIR}") +endif() diff --git a/.config/quickshell/caelestia/LICENSE b/.config/quickshell/caelestia/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/.config/quickshell/caelestia/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/.config/quickshell/caelestia/README.md b/.config/quickshell/caelestia/README.md new file mode 100644 index 0000000..886f061 --- /dev/null +++ b/.config/quickshell/caelestia/README.md @@ -0,0 +1,763 @@ +

caelestia-shell

+ +
+ +![GitHub last commit](https://img.shields.io/github/last-commit/caelestia-dots/shell?style=for-the-badge&labelColor=101418&color=9ccbfb) +![GitHub Repo stars](https://img.shields.io/github/stars/caelestia-dots/shell?style=for-the-badge&labelColor=101418&color=b9c8da) +![GitHub repo size](https://img.shields.io/github/repo-size/caelestia-dots/shell?style=for-the-badge&labelColor=101418&color=d3bfe6) +[![Ko-Fi donate](https://img.shields.io/badge/donate-kofi?style=for-the-badge&logo=ko-fi&logoColor=ffffff&label=ko-fi&labelColor=101418&color=f16061&link=https%3A%2F%2Fko-fi.com%2Fsoramane)](https://ko-fi.com/soramane) +[![Discord invite](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fdiscordapp.com%2Fapi%2Finvites%2FBGDCFCmMBk%3Fwith_counts%3Dtrue&query=approximate_member_count&style=for-the-badge&logo=discord&logoColor=ffffff&label=discord&labelColor=101418&color=96f1f1&link=https%3A%2F%2Fdiscord.gg%2FBGDCFCmMBk)](https://discord.gg/BGDCFCmMBk) + +
+ +https://github.com/user-attachments/assets/0840f496-575c-4ca6-83a8-87bb01a85c5f + +## Components + +- Widgets: [`Quickshell`](https://quickshell.outfoxxed.me) +- Window manager: [`Hyprland`](https://hyprland.org) +- Dots: [`caelestia`](https://github.com/caelestia-dots) + +## Installation + +> [!NOTE] +> This repo is for the desktop shell of the caelestia dots. If you want installation instructions +> for the entire dots, head to [the main repo](https://github.com/caelestia-dots/caelestia) instead. + +### Arch linux + +> [!NOTE] +> If you want to make your own changes/tweaks to the shell do NOT edit the files installed by the AUR +> package. Instead, follow the instructions in the [manual installation section](#manual-installation). + +The shell is available from the AUR as `caelestia-shell`. You can install it with an AUR helper +like [`yay`](https://github.com/Jguer/yay) or manually downloading the PKGBUILD and running `makepkg -si`. + +A package following the latest commit also exists as `caelestia-shell-git`. This is bleeding edge +and likely to be unstable/have bugs. Regular users are recommended to use the stable package +(`caelestia-shell`). + +### Nix + +You can run the shell directly via `nix run`: + +```sh +nix run github:caelestia-dots/shell +``` + +Or add it to your system configuration: + +```nix +{ + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; + + caelestia-shell = { + url = "github:caelestia-dots/shell"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; +} +``` + +The package is available as `caelestia-shell.packages..default`, which can be added to your +`environment.systemPackages`, `users.users..packages`, `home.packages` if using home-manager, +or a devshell. The shell can then be run via `caelestia-shell`. + +> [!TIP] +> The default package does not have the CLI enabled by default, which is required for full funcionality. +> To enable the CLI, use the `with-cli` package. + +For home-manager, you can also use the Caelestia's home manager module (explained in [configuring](https://github.com/caelestia-dots/shell?tab=readme-ov-file#home-manager-module)) that installs and configures the shell and the CLI. + +### Manual installation + +Dependencies: + +- [`caelestia-cli`](https://github.com/caelestia-dots/cli) +- [`quickshell-git`](https://quickshell.outfoxxed.me) - this has to be the git version, not the latest tagged version +- [`ddcutil`](https://github.com/rockowitz/ddcutil) +- [`brightnessctl`](https://github.com/Hummer12007/brightnessctl) +- [`app2unit`](https://github.com/Vladimir-csp/app2unit) +- [`libcava`](https://github.com/LukashonakV/cava) +- [`networkmanager`](https://networkmanager.dev) +- [`lm-sensors`](https://github.com/lm-sensors/lm-sensors) +- [`fish`](https://github.com/fish-shell/fish-shell) +- [`aubio`](https://github.com/aubio/aubio) +- [`libpipewire`](https://pipewire.org) +- `glibc` +- `qt6-declarative` +- `gcc-libs` +- [`material-symbols`](https://fonts.google.com/icons) +- [`caskaydia-cove-nerd`](https://www.nerdfonts.com/font-downloads) +- [`swappy`](https://github.com/jtheoof/swappy) +- [`libqalculate`](https://github.com/Qalculate/libqalculate) +- [`bash`](https://www.gnu.org/software/bash) +- `qt6-base` +- `qt6-declarative` + +Build dependencies: + +- [`cmake`](https://cmake.org) +- [`ninja`](https://github.com/ninja-build/ninja) + +To install the shell manually, install all dependencies and clone this repo to `$XDG_CONFIG_HOME/quickshell/caelestia`. +Then simply build and install using `cmake`. + +```sh +cd $XDG_CONFIG_HOME/quickshell +git clone https://github.com/caelestia-dots/shell.git caelestia + +cd caelestia +cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/ +cmake --build build +sudo cmake --install build +``` + +> [!TIP] +> You can customise the installation location via the `cmake` flags `INSTALL_LIBDIR`, `INSTALL_QMLDIR` and +> `INSTALL_QSCONFDIR` for the libraries (the beat detector), QML plugin and Quickshell config directories +> respectively. If changing the library directory, remember to set the `CAELESTIA_LIB_DIR` environment +> variable to the custom directory when launching the shell. +> +> e.g. installing to `~/.config/quickshell/caelestia` for easy local changes: +> +> ```sh +> mkdir -p ~/.config/quickshell/caelestia +> cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/ -DINSTALL_QSCONFDIR=~/.config/quickshell/caelestia +> cmake --build build +> sudo cmake --install build +> sudo chown -R $USER ~/.config/quickshell/caelestia +> ``` + +## Usage + +The shell can be started via the `caelestia shell -d` command or `qs -c caelestia`. +If the entire caelestia dots are installed, the shell will be autostarted on login +via an `exec-once` in the hyprland config. + +### Shortcuts/IPC + +All keybinds are accessible via Hyprland [global shortcuts](https://wiki.hyprland.org/Configuring/Binds/#dbus-global-shortcuts). +If using the entire caelestia dots, the keybinds are already configured for you. +Otherwise, [this file](https://github.com/caelestia-dots/caelestia/blob/main/hypr/hyprland/keybinds.conf#L1-L39) +contains an example on how to use global shortcuts. + +All IPC commands can be accessed via `caelestia shell ...`. For example + +```sh +caelestia shell mpris getActive trackTitle +``` + +The list of IPC commands can be shown via `caelestia shell -s`: + +``` +$ caelestia shell -s +target drawers + function toggle(drawer: string): void + function list(): string +target notifs + function clear(): void +target lock + function lock(): void + function unlock(): void + function isLocked(): bool +target mpris + function playPause(): void + function getActive(prop: string): string + function next(): void + function stop(): void + function play(): void + function list(): string + function pause(): void + function previous(): void +target picker + function openFreeze(): void + function open(): void +target wallpaper + function set(path: string): void + function get(): string + function list(): string +``` + +### PFP/Wallpapers + +The profile picture for the dashboard is read from the file `~/.face`, so to set +it you can copy your image to there or set it via the dashboard. + +The wallpapers for the wallpaper switcher are read from `~/Pictures/Wallpapers` +by default. To change it, change the wallpapers path in `~/.config/caelestia/shell.json`. + +To set the wallpaper, you can use the command `caelestia wallpaper`. Use `caelestia wallpaper -h` for more info about +the command. + +## Updating + +If installed via the AUR package, simply update your system (e.g. using `yay`). + +If installed manually, you can update by running `git pull` in `$XDG_CONFIG_HOME/quickshell/caelestia`. + +```sh +cd $XDG_CONFIG_HOME/quickshell/caelestia +git pull +``` + +## Configuring + +All configuration options should be put in `~/.config/caelestia/shell.json`. This file is _not_ created by +default, you must create it manually. + +### Example configuration + +> [!NOTE] +> The example configuration only includes recommended configuration options. For more advanced customisation +> such as modifying the size of individual items or changing constants in the code, there are some other +> options which can be found in the source files in the `config` directory. + +
Example + +```json +{ + "appearance": { + "mediaGifSpeedAdjustment": 300, + "sessionGifSpeed": 0.7, + "anim": { + "durations": { + "scale": 1 + } + }, + "font": { + "family": { + "clock": "Rubik", + "material": "Material Symbols Rounded", + "mono": "CaskaydiaCove NF", + "sans": "Rubik" + }, + "size": { + "scale": 1 + } + }, + "padding": { + "scale": 1 + }, + "rounding": { + "scale": 1 + }, + "spacing": { + "scale": 1 + }, + "transparency": { + "enabled": false, + "base": 0.85, + "layers": 0.4 + } + }, + "general": { + "logo": "caelestia", + "apps": { + "terminal": ["foot"], + "audio": ["pavucontrol"], + "playback": ["mpv"], + "explorer": ["thunar"] + }, + "battery": { + "warnLevels": [ + { + "level": 20, + "title": "Low battery", + "message": "You might want to plug in a charger", + "icon": "battery_android_frame_2" + }, + { + "level": 10, + "title": "Did you see the previous message?", + "message": "You should probably plug in a charger now", + "icon": "battery_android_frame_1" + }, + { + "level": 5, + "title": "Critical battery level", + "message": "PLUG THE CHARGER RIGHT NOW!!", + "icon": "battery_android_alert", + "critical": true + } + ], + "criticalLevel": 3 + }, + "idle": { + "lockBeforeSleep": true, + "inhibitWhenAudio": true, + "timeouts": [ + { + "timeout": 180, + "idleAction": "lock" + }, + { + "timeout": 300, + "idleAction": "dpms off", + "returnAction": "dpms on" + }, + { + "timeout": 600, + "idleAction": ["systemctl", "suspend-then-hibernate"] + } + ] + } + }, + "background": { + "desktopClock": { + "enabled": false, + "scale": 1.0, + "position": "bottom-right", + "shadow": { + "enabled": true, + "opacity": 0.7, + "blur": 0.4 + }, + "background": { + "enabled": false, + "opacity": 0.7, + "blur": true + }, + "invertColors": false + }, + "enabled": true, + "visualiser": { + "blur": false, + "enabled": false, + "autoHide": true, + "rounding": 1, + "spacing": 1 + } + }, + "bar": { + "clock": { + "showIcon": true + }, + "dragThreshold": 20, + "entries": [ + { + "id": "logo", + "enabled": true + }, + { + "id": "workspaces", + "enabled": true + }, + { + "id": "spacer", + "enabled": true + }, + { + "id": "activeWindow", + "enabled": true + }, + { + "id": "spacer", + "enabled": true + }, + { + "id": "tray", + "enabled": true + }, + { + "id": "clock", + "enabled": true + }, + { + "id": "statusIcons", + "enabled": true + }, + { + "id": "power", + "enabled": true + } + ], + "persistent": true, + "popouts": { + "activeWindow": true, + "statusIcons": true, + "tray": true + }, + "scrollActions": { + "brightness": true, + "workspaces": true, + "volume": true + }, + "showOnHover": true, + "status": { + "showAudio": false, + "showBattery": true, + "showBluetooth": true, + "showKbLayout": false, + "showMicrophone": false, + "showNetwork": true, + "showWifi": true, + "showLockStatus": true + }, + "tray": { + "background": false, + "compact": false, + "iconSubs": [], + "recolour": false + }, + "workspaces": { + "activeIndicator": true, + "activeLabel": "󰮯", + "activeTrail": false, + "label": " ", + "occupiedBg": false, + "occupiedLabel": "󰮯", + "perMonitorWorkspaces": true, + "showWindows": true, + "shown": 5, + "specialWorkspaceIcons": [ + { + "name": "steam", + "icon": "sports_esports" + } + ] + }, + "excludedScreens": [""], + "activeWindow": { + "inverted": false + } + }, + "border": { + "rounding": 25, + "thickness": 10 + }, + "dashboard": { + "enabled": true, + "dragThreshold": 50, + "mediaUpdateInterval": 500, + "showOnHover": true + }, + "launcher": { + "actionPrefix": ">", + "actions": [ + { + "name": "Calculator", + "icon": "calculate", + "description": "Do simple math equations (powered by Qalc)", + "command": ["autocomplete", "calc"], + "enabled": true, + "dangerous": false + }, + { + "name": "Scheme", + "icon": "palette", + "description": "Change the current colour scheme", + "command": ["autocomplete", "scheme"], + "enabled": true, + "dangerous": false + }, + { + "name": "Wallpaper", + "icon": "image", + "description": "Change the current wallpaper", + "command": ["autocomplete", "wallpaper"], + "enabled": true, + "dangerous": false + }, + { + "name": "Variant", + "icon": "colors", + "description": "Change the current scheme variant", + "command": ["autocomplete", "variant"], + "enabled": true, + "dangerous": false + }, + { + "name": "Transparency", + "icon": "opacity", + "description": "Change shell transparency", + "command": ["autocomplete", "transparency"], + "enabled": false, + "dangerous": false + }, + { + "name": "Random", + "icon": "casino", + "description": "Switch to a random wallpaper", + "command": ["caelestia", "wallpaper", "-r"], + "enabled": true, + "dangerous": false + }, + { + "name": "Light", + "icon": "light_mode", + "description": "Change the scheme to light mode", + "command": ["setMode", "light"], + "enabled": true, + "dangerous": false + }, + { + "name": "Dark", + "icon": "dark_mode", + "description": "Change the scheme to dark mode", + "command": ["setMode", "dark"], + "enabled": true, + "dangerous": false + }, + { + "name": "Shutdown", + "icon": "power_settings_new", + "description": "Shutdown the system", + "command": ["systemctl", "poweroff"], + "enabled": true, + "dangerous": true + }, + { + "name": "Reboot", + "icon": "cached", + "description": "Reboot the system", + "command": ["systemctl", "reboot"], + "enabled": true, + "dangerous": true + }, + { + "name": "Logout", + "icon": "exit_to_app", + "description": "Log out of the current session", + "command": ["loginctl", "terminate-user", ""], + "enabled": true, + "dangerous": true + }, + { + "name": "Lock", + "icon": "lock", + "description": "Lock the current session", + "command": ["loginctl", "lock-session"], + "enabled": true, + "dangerous": false + }, + { + "name": "Sleep", + "icon": "bedtime", + "description": "Suspend then hibernate", + "command": ["systemctl", "suspend-then-hibernate"], + "enabled": true, + "dangerous": false + }, + { + "name": "Settings", + "icon": "settings", + "description": "Configure the shell", + "command": ["caelestia", "shell", "controlCenter", "open"], + "enabled": true, + "dangerous": false + } + ], + "dragThreshold": 50, + "vimKeybinds": false, + "enableDangerousActions": false, + "maxShown": 7, + "maxWallpapers": 9, + "specialPrefix": "@", + "useFuzzy": { + "apps": false, + "actions": false, + "schemes": false, + "variants": false, + "wallpapers": false + }, + "showOnHover": false, + "favouriteApps": [], + "hiddenApps": [] + }, + "lock": { + "recolourLogo": false + }, + "notifs": { + "actionOnClick": false, + "clearThreshold": 0.3, + "defaultExpireTimeout": 5000, + "expandThreshold": 20, + "openExpanded": false, + "expire": false + }, + "osd": { + "enabled": true, + "enableBrightness": true, + "enableMicrophone": false, + "hideDelay": 2000 + }, + "paths": { + "mediaGif": "root:/assets/bongocat.gif", + "sessionGif": "root:/assets/kurukuru.gif", + "wallpaperDir": "~/Pictures/Wallpapers" + }, + "services": { + "audioIncrement": 0.1, + "brightnessIncrement": 0.1, + "maxVolume": 1.0, + "defaultPlayer": "Spotify", + "gpuType": "", + "playerAliases": [{ "from": "com.github.th_ch.youtube_music", "to": "YT Music" }], + "weatherLocation": "", + "useFahrenheit": false, + "useFahrenheitPerformance": false, + "useTwelveHourClock": false, + "smartScheme": true, + "visualiserBars": 45 + }, + "session": { + "dragThreshold": 30, + "enabled": true, + "vimKeybinds": false, + "icons": { + "logout": "logout", + "shutdown": "power_settings_new", + "hibernate": "downloading", + "reboot": "cached" + }, + "commands": { + "logout": ["loginctl", "terminate-user", ""], + "shutdown": ["systemctl", "poweroff"], + "hibernate": ["systemctl", "hibernate"], + "reboot": ["systemctl", "reboot"] + } + }, + "sidebar": { + "dragThreshold": 80, + "enabled": true + }, + "utilities": { + "enabled": true, + "maxToasts": 4, + "toasts": { + "audioInputChanged": true, + "audioOutputChanged": true, + "capsLockChanged": true, + "chargingChanged": true, + "configLoaded": true, + "dndChanged": true, + "gameModeChanged": true, + "kbLayoutChanged": true, + "kbLimit": true, + "numLockChanged": true, + "vpnChanged": true, + "nowPlaying": false + }, + "vpn": { + "enabled": true, + "provider": [ + { + "name": "wireguard", + "interface": "your-connection-name", + "displayName": "Wireguard (Your VPN)", + "enabled": false + } + ] + } + } +} +``` + +
+ +### Home Manager Module + +For NixOS users, a home manager module is also available. + +
home.nix + +```nix +programs.caelestia = { + enable = true; + systemd = { + enable = false; # if you prefer starting from your compositor + target = "graphical-session.target"; + environment = []; + }; + settings = { + bar.status = { + showBattery = false; + }; + paths.wallpaperDir = "~/Images"; + }; + cli = { + enable = true; # Also add caelestia-cli to path + settings = { + theme.enableGtk = false; + }; + }; +}; +``` + +The module automatically adds Caelestia shell to the path with **full functionality**. The CLI is not required, however you have the option to enable and configure it. + +
+ +## FAQ + +### Need help or support? + +You can join the community Discord server for assistance and discussion: +https://discord.gg/BGDCFCmMBk + +### My screen is flickering, help pls! + +Try disabling VRR in the hyprland config. You can do this by adding the following to `~/.config/caelestia/hypr-user.conf`: + +```conf +misc { + vrr = 0 +} +``` + +### I want to make my own changes to the hyprland config! + +You can add your custom hyprland configs to `~/.config/caelestia/hypr-user.conf`. + +### I want to make my own changes to other stuff! + +See the [manual installation](https://github.com/caelestia-dots/shell?tab=readme-ov-file#manual-installation) section +for the corresponding repo. + +### I want to disable XXX feature! + +Please read the [configuring](https://github.com/caelestia-dots/shell?tab=readme-ov-file#configuring) section in the readme. +If there is no corresponding option, make feature request. + +### How do I make my colour scheme change with my wallpaper? + +Set a wallpaper via the launcher or `caelestia wallpaper` and set the scheme to the dynamic scheme via the launcher +or `caelestia scheme set`. e.g. + +```sh +caelestia wallpaper -f +caelestia scheme set -n dynamic +``` + +### My wallpapers aren't showing up in the launcher! + +The launcher pulls wallpapers from `~/Pictures/Wallpapers` by default. You can change this in the config. Additionally, +the launcher only shows an odd number of wallpapers at one time. If you only have 2 wallpapers, consider getting more +(or just putting one). + +## Credits + +Thanks to the Hyprland discord community (especially the homies in #rice-discussion) for all the help and suggestions +for improving these dots! + +A special thanks to [@outfoxxed](https://github.com/outfoxxed) for making Quickshell and the effort put into fixing issues +and implementing various feature requests. + +Another special thanks to [@end_4](https://github.com/end-4) for his [config](https://github.com/end-4/dots-hyprland) +which helped me a lot with learning how to use Quickshell. + +Finally another thank you to all the configs I took inspiration from (only one for now): + +- [Axenide/Ax-Shell](https://github.com/Axenide/Ax-Shell) + +## Stonks 📈 + + + + + + Star History Chart + + diff --git a/.config/quickshell/caelestia/assets/bongocat.gif b/.config/quickshell/caelestia/assets/bongocat.gif new file mode 100644 index 0000000..f960fec Binary files /dev/null and b/.config/quickshell/caelestia/assets/bongocat.gif differ diff --git a/.config/quickshell/caelestia/assets/dino.png b/.config/quickshell/caelestia/assets/dino.png new file mode 100644 index 0000000..b5bc7bb Binary files /dev/null and b/.config/quickshell/caelestia/assets/dino.png differ diff --git a/.config/quickshell/caelestia/assets/kurukuru.gif b/.config/quickshell/caelestia/assets/kurukuru.gif new file mode 100644 index 0000000..38d203d Binary files /dev/null and b/.config/quickshell/caelestia/assets/kurukuru.gif differ diff --git a/.config/quickshell/caelestia/assets/logo.svg b/.config/quickshell/caelestia/assets/logo.svg new file mode 100644 index 0000000..6879c92 --- /dev/null +++ b/.config/quickshell/caelestia/assets/logo.svg @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + diff --git a/.config/quickshell/caelestia/assets/pam.d/fprint b/.config/quickshell/caelestia/assets/pam.d/fprint new file mode 100644 index 0000000..d4814e9 --- /dev/null +++ b/.config/quickshell/caelestia/assets/pam.d/fprint @@ -0,0 +1,3 @@ +#%PAM-1.0 + +auth required pam_fprintd.so max-tries=1 diff --git a/.config/quickshell/caelestia/assets/pam.d/passwd b/.config/quickshell/caelestia/assets/pam.d/passwd new file mode 100644 index 0000000..4b14064 --- /dev/null +++ b/.config/quickshell/caelestia/assets/pam.d/passwd @@ -0,0 +1,6 @@ +#%PAM-1.0 + +auth required pam_faillock.so preauth +auth [success=1 default=bad] pam_unix.so nullok +auth [default=die] pam_faillock.so authfail +auth required pam_faillock.so authsucc diff --git a/.config/quickshell/caelestia/assets/shaders/opacitymask.frag b/.config/quickshell/caelestia/assets/shaders/opacitymask.frag new file mode 100644 index 0000000..94a80b8 --- /dev/null +++ b/.config/quickshell/caelestia/assets/shaders/opacitymask.frag @@ -0,0 +1,19 @@ +#version 440 + +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; + +layout(std140, binding = 0) uniform buf { + // qt_Matrix and qt_Opacity must always be both present + // if the built-in vertex shader is used. + mat4 qt_Matrix; + float qt_Opacity; +}; + +layout(binding = 1) uniform sampler2D source; +layout(binding = 2) uniform sampler2D maskSource; + +void main() +{ + fragColor = texture(source, qt_TexCoord0.st) * (texture(maskSource, qt_TexCoord0.st).a) * qt_Opacity; +} diff --git a/.config/quickshell/caelestia/assets/shaders/opacitymask.frag.qsb b/.config/quickshell/caelestia/assets/shaders/opacitymask.frag.qsb new file mode 100644 index 0000000..7bf97c2 Binary files /dev/null and b/.config/quickshell/caelestia/assets/shaders/opacitymask.frag.qsb differ diff --git a/.config/quickshell/caelestia/assets/wrap_term_launch.sh b/.config/quickshell/caelestia/assets/wrap_term_launch.sh new file mode 100755 index 0000000..caf60c7 --- /dev/null +++ b/.config/quickshell/caelestia/assets/wrap_term_launch.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env sh + +cat ~/.local/state/caelestia/sequences.txt 2>/dev/null + +exec "$@" diff --git a/.config/quickshell/caelestia/components/Anim.qml b/.config/quickshell/caelestia/components/Anim.qml new file mode 100644 index 0000000..6883a79 --- /dev/null +++ b/.config/quickshell/caelestia/components/Anim.qml @@ -0,0 +1,8 @@ +import qs.config +import QtQuick + +NumberAnimation { + duration: Appearance.anim.durations.normal + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.anim.curves.standard +} diff --git a/.config/quickshell/caelestia/components/CAnim.qml b/.config/quickshell/caelestia/components/CAnim.qml new file mode 100644 index 0000000..49484b7 --- /dev/null +++ b/.config/quickshell/caelestia/components/CAnim.qml @@ -0,0 +1,8 @@ +import qs.config +import QtQuick + +ColorAnimation { + duration: Appearance.anim.durations.normal + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.anim.curves.standard +} diff --git a/.config/quickshell/caelestia/components/ConnectionHeader.qml b/.config/quickshell/caelestia/components/ConnectionHeader.qml new file mode 100644 index 0000000..12b4276 --- /dev/null +++ b/.config/quickshell/caelestia/components/ConnectionHeader.qml @@ -0,0 +1,31 @@ +import qs.components +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +ColumnLayout { + id: root + + required property string icon + required property string title + + spacing: Appearance.spacing.normal + Layout.alignment: Qt.AlignHCenter + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + animate: true + text: root.icon + font.pointSize: Appearance.font.size.extraLarge * 3 + font.bold: true + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + animate: true + text: root.title + font.pointSize: Appearance.font.size.large + font.bold: true + } +} diff --git a/.config/quickshell/caelestia/components/ConnectionInfoSection.qml b/.config/quickshell/caelestia/components/ConnectionInfoSection.qml new file mode 100644 index 0000000..927ef28 --- /dev/null +++ b/.config/quickshell/caelestia/components/ConnectionInfoSection.qml @@ -0,0 +1,59 @@ +import qs.components +import qs.components.effects +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +ColumnLayout { + id: root + + required property var deviceDetails + + spacing: Appearance.spacing.small / 2 + + StyledText { + text: qsTr("IP Address") + } + + StyledText { + text: root.deviceDetails?.ipAddress || qsTr("Not available") + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.small + } + + StyledText { + Layout.topMargin: Appearance.spacing.normal + text: qsTr("Subnet Mask") + } + + StyledText { + text: root.deviceDetails?.subnet || qsTr("Not available") + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.small + } + + StyledText { + Layout.topMargin: Appearance.spacing.normal + text: qsTr("Gateway") + } + + StyledText { + text: root.deviceDetails?.gateway || qsTr("Not available") + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.small + } + + StyledText { + Layout.topMargin: Appearance.spacing.normal + text: qsTr("DNS Servers") + } + + StyledText { + text: (root.deviceDetails && root.deviceDetails.dns && root.deviceDetails.dns.length > 0) ? root.deviceDetails.dns.join(", ") : qsTr("Not available") + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.small + wrapMode: Text.Wrap + Layout.maximumWidth: parent.width + } +} diff --git a/.config/quickshell/caelestia/components/Logo.qml b/.config/quickshell/caelestia/components/Logo.qml new file mode 100644 index 0000000..3ab4f2b --- /dev/null +++ b/.config/quickshell/caelestia/components/Logo.qml @@ -0,0 +1,69 @@ +import QtQuick +import QtQuick.Shapes +import qs.services + +Item { + id: root + implicitWidth: designWidth + implicitHeight: designHeight + + readonly property real designWidth: 128 + readonly property real designHeight: 90.38 + + property color topColour: Colours.palette.m3primary + property color bottomColour: Colours.palette.m3onSurface + + Shape { + anchors.centerIn: parent + width: root.designWidth + height: root.designHeight + scale: Math.min(root.width / width, root.height / height) + transformOrigin: Item.Center + preferredRendererType: Shape.CurveRenderer + + ShapePath { + fillColor: root.topColour + strokeColor: "transparent" + + PathSvg { + path: "m42.56,42.96c-7.76,1.6-16.36,4.22-22.44,6.22-.49.16-.88-.44-.53-.82,5.37-5.85,9.66-13.3,9.66-13.3,8.66-14.67,22.97-23.51,39.85-21.14,6.47.91,12.33,3.38,17.26,6.98.99.72,1.14,2.14.31,3.04-.4.44-.95.67-1.51.67-.34,0-.69-.09-1-.26-3.21-1.84-6.82-2.69-10.71-3.24-13.1-1.84-25.41,4.75-31.06,15.83-.94,1.84-.61,3.81.45,5.21.22.3.07.72-.29.8Z" + } + } + + ShapePath { + fillColor: root.bottomColour + strokeColor: "transparent" + + PathSvg { + path: "m103.02,51.8c-.65.11-1.26-.37-1.28-1.03-.06-1.96.15-3.89-.2-5.78-.28-1.48-1.66-2.5-3.16-2.34h-.05c-6.53.73-24.63,3.1-48,9.32-6.89,1.83-9.83,10-5.67,15.79,4.62,6.44,11.84,10.93,20.41,12.13,11.82,1.66,22.99-3.36,29.21-12.65.54-.81,1.54-1.17,2.47-.86.91.3,1.47,1.15,1.47,2.04,0,.33-.08.66-.24.98-7.23,14.21-22.91,22.95-39.59,20.6-7.84-1.1-14.8-4.5-20.28-9.43,0,0,0,0-.02-.01-7.28-5.14-14.7-9.99-27.24-11.98-18.82-2.98-9.53-8.75.46-13.78,7.36-3.13,25.17-7.9,36.24-10.73.16-.03.31-.06.47-.1,1.52-.4,3.2-.83,5.02-1.29,1.06-.26,1.93-.48,2.58-.64.09-.02.18-.04.26-.06.31-.08.56-.14.73-.18.03,0,.06-.01.08-.02.03,0,.05-.01.07-.02.02,0,.04,0,.06-.01.01,0,.03,0,.04-.01,0,0,.02,0,.03,0,.01,0,.02,0,.02,0,10.62-2.58,24.63-5.62,37.74-7.34,1.02-.13,2.03-.26,3.03-.37,7.49-.87,14.58-1.26,20.42-.81,25.43,1.95-4.71,16.77-15.12,18.61Z" + } + } + + ShapePath { + fillColor: root.topColour + strokeColor: "transparent" + + PathSvg { + path: "m98.12.06c-.29,2.08-1.72,8.42-8.36,9.19-.09,0-.09.13,0,.14,6.64.78,8.07,7.11,8.36,9.19.01.08.13.08.14,0,.29-2.08,1.72-8.42,8.36-9.19.09,0,.09-.13,0-.14-6.64-.78-8.07-7.11-8.36-9.19-.01-.08-.13-.08-.14,0Z" + } + } + + ShapePath { + fillColor: root.topColour + strokeColor: "transparent" + + PathSvg { + path: "m113.36,15.5c-.22,1.29-1.08,4.35-4.38,4.87-.08.01-.08.13,0,.14,3.3.52,4.17,3.58,4.38,4.87.01.08.13.08.14,0,.22-1.29,1.08-4.35,4.38-4.87.08-.01.08-.13,0-.14-3.3-.52-4.17-3.58-4.38-4.87-.01-.08-.13-.08-.14,0Z" + } + } + + ShapePath { + fillColor: root.topColour + strokeColor: "transparent" + + PathSvg { + path: "m112.69,65.22c-.19,1.01-.86,3.15-3.2,3.57-.08.01-.08.13,0,.14,2.34.42,3.01,2.56,3.2,3.57.01.08.13.08.14,0,.19-1.01.86-3.15,3.2-3.57.08-.01.08-.13,0-.14-2.34-.42-3.01-2.56-3.2-3.57-.01-.08-.13-.08-.14,0Z" + } + } + } +} diff --git a/.config/quickshell/caelestia/components/MaterialIcon.qml b/.config/quickshell/caelestia/components/MaterialIcon.qml new file mode 100644 index 0000000..a1d19d3 --- /dev/null +++ b/.config/quickshell/caelestia/components/MaterialIcon.qml @@ -0,0 +1,16 @@ +import qs.services +import qs.config + +StyledText { + property real fill + property int grade: Colours.light ? 0 : -25 + + font.family: Appearance.font.family.material + font.pointSize: Appearance.font.size.larger + font.variableAxes: ({ + FILL: fill.toFixed(1), + GRAD: grade, + opsz: fontInfo.pixelSize, + wght: fontInfo.weight + }) +} diff --git a/.config/quickshell/caelestia/components/PropertyRow.qml b/.config/quickshell/caelestia/components/PropertyRow.qml new file mode 100644 index 0000000..640d5f7 --- /dev/null +++ b/.config/quickshell/caelestia/components/PropertyRow.qml @@ -0,0 +1,26 @@ +import qs.components +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +ColumnLayout { + id: root + + required property string label + required property string value + property bool showTopMargin: false + + spacing: Appearance.spacing.small / 2 + + StyledText { + Layout.topMargin: root.showTopMargin ? Appearance.spacing.normal : 0 + text: root.label + } + + StyledText { + text: root.value + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.small + } +} diff --git a/.config/quickshell/caelestia/components/SectionContainer.qml b/.config/quickshell/caelestia/components/SectionContainer.qml new file mode 100644 index 0000000..2b653a5 --- /dev/null +++ b/.config/quickshell/caelestia/components/SectionContainer.qml @@ -0,0 +1,32 @@ +import qs.components +import qs.components.effects +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +StyledRect { + id: root + + default property alias content: contentColumn.data + property real contentSpacing: Appearance.spacing.larger + property bool alignTop: false + + Layout.fillWidth: true + implicitHeight: contentColumn.implicitHeight + Appearance.padding.large * 2 + + radius: Appearance.rounding.normal + color: Colours.transparency.enabled ? Colours.layer(Colours.palette.m3surfaceContainer, 2) : Colours.palette.m3surfaceContainerHigh + + ColumnLayout { + id: contentColumn + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: root.alignTop ? parent.top : undefined + anchors.verticalCenter: root.alignTop ? undefined : parent.verticalCenter + anchors.margins: Appearance.padding.large + + spacing: root.contentSpacing + } +} diff --git a/.config/quickshell/caelestia/components/SectionHeader.qml b/.config/quickshell/caelestia/components/SectionHeader.qml new file mode 100644 index 0000000..502e918 --- /dev/null +++ b/.config/quickshell/caelestia/components/SectionHeader.qml @@ -0,0 +1,27 @@ +import qs.components +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +ColumnLayout { + id: root + + required property string title + property string description: "" + + spacing: 0 + + StyledText { + Layout.topMargin: Appearance.spacing.large + text: root.title + font.pointSize: Appearance.font.size.larger + font.weight: 500 + } + + StyledText { + visible: root.description !== "" + text: root.description + color: Colours.palette.m3outline + } +} diff --git a/.config/quickshell/caelestia/components/StateLayer.qml b/.config/quickshell/caelestia/components/StateLayer.qml new file mode 100644 index 0000000..a20e266 --- /dev/null +++ b/.config/quickshell/caelestia/components/StateLayer.qml @@ -0,0 +1,95 @@ +import qs.services +import qs.config +import QtQuick + +MouseArea { + id: root + + property bool disabled + property bool showHoverBackground: true + property color color: Colours.palette.m3onSurface + property real radius: parent?.radius ?? 0 + property alias rect: hoverLayer + + function onClicked(): void { + } + + anchors.fill: parent + + enabled: !disabled + cursorShape: disabled ? undefined : Qt.PointingHandCursor + hoverEnabled: true + + onPressed: event => { + if (disabled) + return; + + rippleAnim.x = event.x; + rippleAnim.y = event.y; + + const dist = (ox, oy) => ox * ox + oy * oy; + rippleAnim.radius = Math.sqrt(Math.max(dist(event.x, event.y), dist(event.x, height - event.y), dist(width - event.x, event.y), dist(width - event.x, height - event.y))); + + rippleAnim.restart(); + } + + onClicked: event => !disabled && onClicked(event) + + SequentialAnimation { + id: rippleAnim + + property real x + property real y + property real radius + + PropertyAction { + target: ripple + property: "x" + value: rippleAnim.x + } + PropertyAction { + target: ripple + property: "y" + value: rippleAnim.y + } + PropertyAction { + target: ripple + property: "opacity" + value: 0.08 + } + Anim { + target: ripple + properties: "implicitWidth,implicitHeight" + from: 0 + to: rippleAnim.radius * 2 + easing.bezierCurve: Appearance.anim.curves.standardDecel + } + Anim { + target: ripple + property: "opacity" + to: 0 + } + } + + StyledClippingRect { + id: hoverLayer + + anchors.fill: parent + + color: Qt.alpha(root.color, root.disabled ? 0 : root.pressed ? 0.12 : (root.showHoverBackground && root.containsMouse) ? 0.08 : 0) + radius: root.radius + + StyledRect { + id: ripple + + radius: Appearance.rounding.full + color: root.color + opacity: 0 + + transform: Translate { + x: -ripple.width / 2 + y: -ripple.height / 2 + } + } + } +} diff --git a/.config/quickshell/caelestia/components/StyledClippingRect.qml b/.config/quickshell/caelestia/components/StyledClippingRect.qml new file mode 100644 index 0000000..8f2630c --- /dev/null +++ b/.config/quickshell/caelestia/components/StyledClippingRect.qml @@ -0,0 +1,12 @@ +import Quickshell.Widgets +import QtQuick + +ClippingRectangle { + id: root + + color: "transparent" + + Behavior on color { + CAnim {} + } +} diff --git a/.config/quickshell/caelestia/components/StyledRect.qml b/.config/quickshell/caelestia/components/StyledRect.qml new file mode 100644 index 0000000..f5d5143 --- /dev/null +++ b/.config/quickshell/caelestia/components/StyledRect.qml @@ -0,0 +1,11 @@ +import QtQuick + +Rectangle { + id: root + + color: "transparent" + + Behavior on color { + CAnim {} + } +} diff --git a/.config/quickshell/caelestia/components/StyledText.qml b/.config/quickshell/caelestia/components/StyledText.qml new file mode 100644 index 0000000..ed961d2 --- /dev/null +++ b/.config/quickshell/caelestia/components/StyledText.qml @@ -0,0 +1,48 @@ +pragma ComponentBehavior: Bound + +import qs.services +import qs.config +import QtQuick + +Text { + id: root + + property bool animate: false + property string animateProp: "scale" + property real animateFrom: 0 + property real animateTo: 1 + property int animateDuration: Appearance.anim.durations.normal + + renderType: Text.NativeRendering + textFormat: Text.PlainText + color: Colours.palette.m3onSurface + font.family: Appearance.font.family.sans + font.pointSize: Appearance.font.size.smaller + + Behavior on color { + CAnim {} + } + + Behavior on text { + enabled: root.animate + + SequentialAnimation { + Anim { + to: root.animateFrom + easing.bezierCurve: Appearance.anim.curves.standardAccel + } + PropertyAction {} + Anim { + to: root.animateTo + easing.bezierCurve: Appearance.anim.curves.standardDecel + } + } + } + + component Anim: NumberAnimation { + target: root + property: root.animateProp + duration: root.animateDuration / 2 + easing.type: Easing.BezierSpline + } +} diff --git a/.config/quickshell/caelestia/components/containers/StyledFlickable.qml b/.config/quickshell/caelestia/components/containers/StyledFlickable.qml new file mode 100644 index 0000000..bc6ae0f --- /dev/null +++ b/.config/quickshell/caelestia/components/containers/StyledFlickable.qml @@ -0,0 +1,14 @@ +import ".." +import QtQuick + +Flickable { + id: root + + maximumFlickVelocity: 3000 + + rebound: Transition { + Anim { + properties: "x,y" + } + } +} diff --git a/.config/quickshell/caelestia/components/containers/StyledListView.qml b/.config/quickshell/caelestia/components/containers/StyledListView.qml new file mode 100644 index 0000000..626d206 --- /dev/null +++ b/.config/quickshell/caelestia/components/containers/StyledListView.qml @@ -0,0 +1,14 @@ +import ".." +import QtQuick + +ListView { + id: root + + maximumFlickVelocity: 3000 + + rebound: Transition { + Anim { + properties: "x,y" + } + } +} diff --git a/.config/quickshell/caelestia/components/containers/StyledWindow.qml b/.config/quickshell/caelestia/components/containers/StyledWindow.qml new file mode 100644 index 0000000..8c6e39f --- /dev/null +++ b/.config/quickshell/caelestia/components/containers/StyledWindow.qml @@ -0,0 +1,9 @@ +import Quickshell +import Quickshell.Wayland + +PanelWindow { + required property string name + + WlrLayershell.namespace: `caelestia-${name}` + color: "transparent" +} diff --git a/.config/quickshell/caelestia/components/controls/CircularIndicator.qml b/.config/quickshell/caelestia/components/controls/CircularIndicator.qml new file mode 100644 index 0000000..957899e --- /dev/null +++ b/.config/quickshell/caelestia/components/controls/CircularIndicator.qml @@ -0,0 +1,108 @@ +import ".." +import qs.services +import qs.config +import Caelestia.Internal +import QtQuick +import QtQuick.Templates + +BusyIndicator { + id: root + + enum AnimType { + Advance = 0, + Retreat + } + + enum AnimState { + Stopped, + Running, + Completing + } + + property real implicitSize: Appearance.font.size.normal * 3 + property real strokeWidth: Appearance.padding.small * 0.8 + property color fgColour: Colours.palette.m3primary + property color bgColour: Colours.palette.m3secondaryContainer + + property alias type: manager.indeterminateAnimationType + readonly property alias progress: manager.progress + + property real internalStrokeWidth: strokeWidth + property int animState + + padding: 0 + implicitWidth: implicitSize + implicitHeight: implicitSize + + Component.onCompleted: { + if (running) { + running = false; + running = true; + } + } + + onRunningChanged: { + if (running) { + manager.completeEndProgress = 0; + animState = CircularIndicator.Running; + } else { + if (animState == CircularIndicator.Running) + animState = CircularIndicator.Completing; + } + } + + states: State { + name: "stopped" + when: !root.running + + PropertyChanges { + root.opacity: 0 + root.internalStrokeWidth: root.strokeWidth / 3 + } + } + + transitions: Transition { + Anim { + properties: "opacity,internalStrokeWidth" + duration: manager.completeEndDuration * Appearance.anim.durations.scale + } + } + + contentItem: CircularProgress { + anchors.fill: parent + strokeWidth: root.internalStrokeWidth + fgColour: root.fgColour + bgColour: root.bgColour + padding: root.padding + rotation: manager.rotation + startAngle: manager.startFraction * 360 + value: manager.endFraction - manager.startFraction + } + + CircularIndicatorManager { + id: manager + } + + NumberAnimation { + running: root.animState !== CircularIndicator.Stopped + loops: Animation.Infinite + target: manager + property: "progress" + from: 0 + to: 1 + duration: manager.duration * Appearance.anim.durations.scale + } + + NumberAnimation { + running: root.animState === CircularIndicator.Completing + target: manager + property: "completeEndProgress" + from: 0 + to: 1 + duration: manager.completeEndDuration * Appearance.anim.durations.scale + onFinished: { + if (root.animState === CircularIndicator.Completing) + root.animState = CircularIndicator.Stopped; + } + } +} diff --git a/.config/quickshell/caelestia/components/controls/CircularProgress.qml b/.config/quickshell/caelestia/components/controls/CircularProgress.qml new file mode 100644 index 0000000..a15cd90 --- /dev/null +++ b/.config/quickshell/caelestia/components/controls/CircularProgress.qml @@ -0,0 +1,69 @@ +import ".." +import qs.services +import qs.config +import QtQuick +import QtQuick.Shapes + +Shape { + id: root + + property real value + property int startAngle: -90 + property int strokeWidth: Appearance.padding.smaller + property int padding: 0 + property int spacing: Appearance.spacing.small + property color fgColour: Colours.palette.m3primary + property color bgColour: Colours.palette.m3secondaryContainer + + readonly property real size: Math.min(width, height) + readonly property real arcRadius: (size - padding - strokeWidth) / 2 + readonly property real vValue: value || 1 / 360 + readonly property real gapAngle: ((spacing + strokeWidth) / (arcRadius || 1)) * (180 / Math.PI) + + preferredRendererType: Shape.CurveRenderer + asynchronous: true + + ShapePath { + fillColor: "transparent" + strokeColor: root.bgColour + strokeWidth: root.strokeWidth + capStyle: Appearance.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap + + PathAngleArc { + startAngle: root.startAngle + 360 * root.vValue + root.gapAngle + sweepAngle: Math.max(-root.gapAngle, 360 * (1 - root.vValue) - root.gapAngle * 2) + radiusX: root.arcRadius + radiusY: root.arcRadius + centerX: root.size / 2 + centerY: root.size / 2 + } + + Behavior on strokeColor { + CAnim { + duration: Appearance.anim.durations.large + } + } + } + + ShapePath { + fillColor: "transparent" + strokeColor: root.fgColour + strokeWidth: root.strokeWidth + capStyle: Appearance.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap + + PathAngleArc { + startAngle: root.startAngle + sweepAngle: 360 * root.vValue + radiusX: root.arcRadius + radiusY: root.arcRadius + centerX: root.size / 2 + centerY: root.size / 2 + } + + Behavior on strokeColor { + CAnim { + duration: Appearance.anim.durations.large + } + } + } +} diff --git a/.config/quickshell/caelestia/components/controls/CollapsibleSection.qml b/.config/quickshell/caelestia/components/controls/CollapsibleSection.qml new file mode 100644 index 0000000..e3d8eef --- /dev/null +++ b/.config/quickshell/caelestia/components/controls/CollapsibleSection.qml @@ -0,0 +1,132 @@ +import ".." +import qs.components +import qs.components.effects +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +ColumnLayout { + id: root + + required property string title + property string description: "" + property bool expanded: false + property bool showBackground: false + property bool nested: false + + signal toggleRequested + + spacing: Appearance.spacing.small + Layout.fillWidth: true + + Item { + id: sectionHeaderItem + Layout.fillWidth: true + Layout.preferredHeight: Math.max(titleRow.implicitHeight + Appearance.padding.normal * 2, 48) + + RowLayout { + id: titleRow + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Appearance.padding.normal + anchors.rightMargin: Appearance.padding.normal + spacing: Appearance.spacing.normal + + StyledText { + text: root.title + font.pointSize: Appearance.font.size.larger + font.weight: 500 + } + + Item { + Layout.fillWidth: true + } + + MaterialIcon { + text: "expand_more" + rotation: root.expanded ? 180 : 0 + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.normal + Behavior on rotation { + Anim { + duration: Appearance.anim.durations.small + easing.bezierCurve: Appearance.anim.curves.standard + } + } + } + } + + StateLayer { + anchors.fill: parent + color: Colours.palette.m3onSurface + radius: Appearance.rounding.normal + showHoverBackground: false + function onClicked(): void { + root.toggleRequested(); + root.expanded = !root.expanded; + } + } + } + + default property alias content: contentColumn.data + + Item { + id: contentWrapper + Layout.fillWidth: true + Layout.preferredHeight: root.expanded ? (contentColumn.implicitHeight + Appearance.spacing.small * 2) : 0 + clip: true + + Behavior on Layout.preferredHeight { + Anim { + easing.bezierCurve: Appearance.anim.curves.standard + } + } + + StyledRect { + id: backgroundRect + anchors.fill: parent + radius: Appearance.rounding.normal + color: Colours.transparency.enabled ? Colours.layer(Colours.palette.m3surfaceContainer, root.nested ? 3 : 2) : (root.nested ? Colours.palette.m3surfaceContainerHigh : Colours.palette.m3surfaceContainer) + opacity: root.showBackground && root.expanded ? 1.0 : 0.0 + visible: root.showBackground + + Behavior on opacity { + Anim { + easing.bezierCurve: Appearance.anim.curves.standard + } + } + } + + ColumnLayout { + id: contentColumn + anchors.left: parent.left + anchors.right: parent.right + y: Appearance.spacing.small + anchors.leftMargin: Appearance.padding.normal + anchors.rightMargin: Appearance.padding.normal + anchors.bottomMargin: Appearance.spacing.small + spacing: Appearance.spacing.small + opacity: root.expanded ? 1.0 : 0.0 + + Behavior on opacity { + Anim { + easing.bezierCurve: Appearance.anim.curves.standard + } + } + + StyledText { + id: descriptionText + Layout.fillWidth: true + Layout.topMargin: root.description !== "" ? Appearance.spacing.smaller : 0 + Layout.bottomMargin: root.description !== "" ? Appearance.spacing.small : 0 + visible: root.description !== "" + text: root.description + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small + wrapMode: Text.Wrap + } + } + } +} diff --git a/.config/quickshell/caelestia/components/controls/CustomMouseArea.qml b/.config/quickshell/caelestia/components/controls/CustomMouseArea.qml new file mode 100644 index 0000000..7c973c2 --- /dev/null +++ b/.config/quickshell/caelestia/components/controls/CustomMouseArea.qml @@ -0,0 +1,21 @@ +import QtQuick + +MouseArea { + property int scrollAccumulatedY: 0 + + function onWheel(event: WheelEvent): void { + } + + onWheel: event => { + // Update accumulated scroll + if (Math.sign(event.angleDelta.y) !== Math.sign(scrollAccumulatedY)) + scrollAccumulatedY = 0; + scrollAccumulatedY += event.angleDelta.y; + + // Trigger handler and reset if above threshold + if (Math.abs(scrollAccumulatedY) >= 120) { + onWheel(event); + scrollAccumulatedY = 0; + } + } +} diff --git a/.config/quickshell/caelestia/components/controls/CustomSpinBox.qml b/.config/quickshell/caelestia/components/controls/CustomSpinBox.qml new file mode 100644 index 0000000..438dc08 --- /dev/null +++ b/.config/quickshell/caelestia/components/controls/CustomSpinBox.qml @@ -0,0 +1,170 @@ +pragma ComponentBehavior: Bound + +import ".." +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +RowLayout { + id: root + + property real value + property real max: Infinity + property real min: -Infinity + property real step: 1 + property alias repeatRate: timer.interval + + signal valueModified(value: real) + + spacing: Appearance.spacing.small + + property bool isEditing: false + property string displayText: root.value.toString() + + onValueChanged: { + if (!root.isEditing) { + root.displayText = root.value.toString(); + } + } + + StyledTextField { + id: textField + + inputMethodHints: Qt.ImhFormattedNumbersOnly + text: root.isEditing ? text : root.displayText + validator: DoubleValidator { + bottom: root.min + top: root.max + decimals: root.step < 1 ? Math.max(1, Math.ceil(-Math.log10(root.step))) : 0 + } + onActiveFocusChanged: { + if (activeFocus) { + root.isEditing = true; + } else { + root.isEditing = false; + root.displayText = root.value.toString(); + } + } + onAccepted: { + const numValue = parseFloat(text); + if (!isNaN(numValue)) { + const clampedValue = Math.max(root.min, Math.min(root.max, numValue)); + root.value = clampedValue; + root.displayText = clampedValue.toString(); + root.valueModified(clampedValue); + } else { + text = root.displayText; + } + root.isEditing = false; + } + onEditingFinished: { + if (text !== root.displayText) { + const numValue = parseFloat(text); + if (!isNaN(numValue)) { + const clampedValue = Math.max(root.min, Math.min(root.max, numValue)); + root.value = clampedValue; + root.displayText = clampedValue.toString(); + root.valueModified(clampedValue); + } else { + text = root.displayText; + } + } + root.isEditing = false; + } + + padding: Appearance.padding.small + leftPadding: Appearance.padding.normal + rightPadding: Appearance.padding.normal + + background: StyledRect { + implicitWidth: 100 + radius: Appearance.rounding.small + color: Colours.tPalette.m3surfaceContainerHigh + } + } + + StyledRect { + radius: Appearance.rounding.small + color: Colours.palette.m3primary + + implicitWidth: implicitHeight + implicitHeight: upIcon.implicitHeight + Appearance.padding.small * 2 + + StateLayer { + id: upState + + color: Colours.palette.m3onPrimary + + onPressAndHold: timer.start() + onReleased: timer.stop() + + function onClicked(): void { + let newValue = Math.min(root.max, root.value + root.step); + // Round to avoid floating point precision errors + const decimals = root.step < 1 ? Math.max(1, Math.ceil(-Math.log10(root.step))) : 0; + newValue = Math.round(newValue * Math.pow(10, decimals)) / Math.pow(10, decimals); + root.value = newValue; + root.displayText = newValue.toString(); + root.valueModified(newValue); + } + } + + MaterialIcon { + id: upIcon + + anchors.centerIn: parent + text: "keyboard_arrow_up" + color: Colours.palette.m3onPrimary + } + } + + StyledRect { + radius: Appearance.rounding.small + color: Colours.palette.m3primary + + implicitWidth: implicitHeight + implicitHeight: downIcon.implicitHeight + Appearance.padding.small * 2 + + StateLayer { + id: downState + + color: Colours.palette.m3onPrimary + + onPressAndHold: timer.start() + onReleased: timer.stop() + + function onClicked(): void { + let newValue = Math.max(root.min, root.value - root.step); + // Round to avoid floating point precision errors + const decimals = root.step < 1 ? Math.max(1, Math.ceil(-Math.log10(root.step))) : 0; + newValue = Math.round(newValue * Math.pow(10, decimals)) / Math.pow(10, decimals); + root.value = newValue; + root.displayText = newValue.toString(); + root.valueModified(newValue); + } + } + + MaterialIcon { + id: downIcon + + anchors.centerIn: parent + text: "keyboard_arrow_down" + color: Colours.palette.m3onPrimary + } + } + + Timer { + id: timer + + interval: 100 + repeat: true + triggeredOnStart: true + onTriggered: { + if (upState.pressed) + upState.onClicked(); + else if (downState.pressed) + downState.onClicked(); + } + } +} diff --git a/.config/quickshell/caelestia/components/controls/FilledSlider.qml b/.config/quickshell/caelestia/components/controls/FilledSlider.qml new file mode 100644 index 0000000..80dd44c --- /dev/null +++ b/.config/quickshell/caelestia/components/controls/FilledSlider.qml @@ -0,0 +1,146 @@ +import ".." +import "../effects" +import qs.services +import qs.config +import QtQuick +import QtQuick.Templates + +Slider { + id: root + + required property string icon + property real oldValue + property bool initialized + + orientation: Qt.Vertical + + background: StyledRect { + color: Colours.layer(Colours.palette.m3surfaceContainer, 2) + radius: Appearance.rounding.full + + StyledRect { + anchors.left: parent.left + anchors.right: parent.right + + y: root.handle.y + implicitHeight: parent.height - y + + color: Colours.palette.m3secondary + radius: parent.radius + } + } + + handle: Item { + id: handle + + property alias moving: icon.moving + + y: root.visualPosition * (root.availableHeight - height) + implicitWidth: root.width + implicitHeight: root.width + + Elevation { + anchors.fill: parent + radius: rect.radius + level: handleInteraction.containsMouse ? 2 : 1 + } + + StyledRect { + id: rect + + anchors.fill: parent + + color: Colours.palette.m3inverseSurface + radius: Appearance.rounding.full + + MouseArea { + id: handleInteraction + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.NoButton + } + + MaterialIcon { + id: icon + + property bool moving + + function update(): void { + animate = !moving; + binding.when = moving; + font.pointSize = moving ? Appearance.font.size.small : Appearance.font.size.larger; + font.family = moving ? Appearance.font.family.sans : Appearance.font.family.material; + } + + text: root.icon + color: Colours.palette.m3inverseOnSurface + anchors.centerIn: parent + + onMovingChanged: anim.restart() + + Binding { + id: binding + + target: icon + property: "text" + value: Math.round(root.value * 100) + when: false + } + + SequentialAnimation { + id: anim + + Anim { + target: icon + property: "scale" + to: 0 + duration: Appearance.anim.durations.normal / 2 + easing.bezierCurve: Appearance.anim.curves.standardAccel + } + ScriptAction { + script: icon.update() + } + Anim { + target: icon + property: "scale" + to: 1 + duration: Appearance.anim.durations.normal / 2 + easing.bezierCurve: Appearance.anim.curves.standardDecel + } + } + } + } + } + + onPressedChanged: handle.moving = pressed + + onValueChanged: { + if (!initialized) { + initialized = true; + return; + } + if (Math.abs(value - oldValue) < 0.01) + return; + oldValue = value; + handle.moving = true; + stateChangeDelay.restart(); + } + + Timer { + id: stateChangeDelay + + interval: 500 + onTriggered: { + if (!root.pressed) + handle.moving = false; + } + } + + Behavior on value { + Anim { + duration: Appearance.anim.durations.large + } + } +} diff --git a/.config/quickshell/caelestia/components/controls/IconButton.qml b/.config/quickshell/caelestia/components/controls/IconButton.qml new file mode 100644 index 0000000..ffb1d06 --- /dev/null +++ b/.config/quickshell/caelestia/components/controls/IconButton.qml @@ -0,0 +1,83 @@ +import ".." +import qs.services +import qs.config +import QtQuick + +StyledRect { + id: root + + enum Type { + Filled, + Tonal, + Text + } + + property alias icon: label.text + property bool checked + property bool toggle + property real padding: type === IconButton.Text ? Appearance.padding.small / 2 : Appearance.padding.smaller + property alias font: label.font + property int type: IconButton.Filled + property bool disabled + + property alias stateLayer: stateLayer + property alias label: label + property alias radiusAnim: radiusAnim + + property bool internalChecked + property color activeColour: type === IconButton.Filled ? Colours.palette.m3primary : Colours.palette.m3secondary + property color inactiveColour: { + if (!toggle && type === IconButton.Filled) + return Colours.palette.m3primary; + return type === IconButton.Filled ? Colours.tPalette.m3surfaceContainer : Colours.palette.m3secondaryContainer; + } + property color activeOnColour: type === IconButton.Filled ? Colours.palette.m3onPrimary : type === IconButton.Tonal ? Colours.palette.m3onSecondary : Colours.palette.m3primary + property color inactiveOnColour: { + if (!toggle && type === IconButton.Filled) + return Colours.palette.m3onPrimary; + return type === IconButton.Tonal ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurfaceVariant; + } + property color disabledColour: Qt.alpha(Colours.palette.m3onSurface, 0.1) + property color disabledOnColour: Qt.alpha(Colours.palette.m3onSurface, 0.38) + + signal clicked + + onCheckedChanged: internalChecked = checked + + radius: internalChecked ? Appearance.rounding.small : implicitHeight / 2 * Math.min(1, Appearance.rounding.scale) + color: type === IconButton.Text ? "transparent" : disabled ? disabledColour : internalChecked ? activeColour : inactiveColour + + implicitWidth: implicitHeight + implicitHeight: label.implicitHeight + padding * 2 + + StateLayer { + id: stateLayer + + color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour + disabled: root.disabled + + function onClicked(): void { + if (root.toggle) + root.internalChecked = !root.internalChecked; + root.clicked(); + } + } + + MaterialIcon { + id: label + + anchors.centerIn: parent + color: root.disabled ? root.disabledOnColour : root.internalChecked ? root.activeOnColour : root.inactiveOnColour + fill: !root.toggle || root.internalChecked ? 1 : 0 + + Behavior on fill { + Anim {} + } + } + + Behavior on radius { + Anim { + id: radiusAnim + } + } +} diff --git a/.config/quickshell/caelestia/components/controls/IconTextButton.qml b/.config/quickshell/caelestia/components/controls/IconTextButton.qml new file mode 100644 index 0000000..b2bb96c --- /dev/null +++ b/.config/quickshell/caelestia/components/controls/IconTextButton.qml @@ -0,0 +1,88 @@ +import ".." +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +StyledRect { + id: root + + enum Type { + Filled, + Tonal, + Text + } + + property alias icon: iconLabel.text + property alias text: label.text + property bool checked + property bool toggle + property real horizontalPadding: Appearance.padding.normal + property real verticalPadding: Appearance.padding.smaller + property alias font: label.font + property int type: IconTextButton.Filled + + property alias stateLayer: stateLayer + property alias iconLabel: iconLabel + property alias label: label + + property bool internalChecked + property color activeColour: type === IconTextButton.Filled ? Colours.palette.m3primary : Colours.palette.m3secondary + property color inactiveColour: type === IconTextButton.Filled ? Colours.tPalette.m3surfaceContainer : Colours.palette.m3secondaryContainer + property color activeOnColour: type === IconTextButton.Filled ? Colours.palette.m3onPrimary : Colours.palette.m3onSecondary + property color inactiveOnColour: type === IconTextButton.Filled ? Colours.palette.m3onSurface : Colours.palette.m3onSecondaryContainer + + signal clicked + + onCheckedChanged: internalChecked = checked + + radius: internalChecked ? Appearance.rounding.small : implicitHeight / 2 * Math.min(1, Appearance.rounding.scale) + color: type === IconTextButton.Text ? "transparent" : internalChecked ? activeColour : inactiveColour + + implicitWidth: row.implicitWidth + horizontalPadding * 2 + implicitHeight: row.implicitHeight + verticalPadding * 2 + + StateLayer { + id: stateLayer + + color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour + + function onClicked(): void { + if (root.toggle) + root.internalChecked = !root.internalChecked; + root.clicked(); + } + } + + RowLayout { + id: row + + anchors.centerIn: parent + spacing: Appearance.spacing.small + + MaterialIcon { + id: iconLabel + + Layout.alignment: Qt.AlignVCenter + Layout.topMargin: Math.round(fontInfo.pointSize * 0.0575) + color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour + fill: root.internalChecked ? 1 : 0 + + Behavior on fill { + Anim {} + } + } + + StyledText { + id: label + + Layout.alignment: Qt.AlignVCenter + Layout.topMargin: -Math.round(iconLabel.fontInfo.pointSize * 0.0575) + color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour + } + } + + Behavior on radius { + Anim {} + } +} diff --git a/.config/quickshell/caelestia/components/controls/Menu.qml b/.config/quickshell/caelestia/components/controls/Menu.qml new file mode 100644 index 0000000..c763b54 --- /dev/null +++ b/.config/quickshell/caelestia/components/controls/Menu.qml @@ -0,0 +1,113 @@ +pragma ComponentBehavior: Bound + +import ".." +import "../effects" +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +Elevation { + id: root + + property list items + property MenuItem active: items[0] ?? null + property bool expanded + + signal itemSelected(item: MenuItem) + + radius: Appearance.rounding.small / 2 + level: 2 + + implicitWidth: Math.max(200, column.implicitWidth) + implicitHeight: root.expanded ? column.implicitHeight : 0 + opacity: root.expanded ? 1 : 0 + + StyledClippingRect { + anchors.fill: parent + radius: parent.radius + color: Colours.palette.m3surfaceContainer + + ColumnLayout { + id: column + + anchors.left: parent.left + anchors.right: parent.right + spacing: 0 + + Repeater { + model: root.items + + StyledRect { + id: item + + required property int index + required property MenuItem modelData + readonly property bool active: modelData === root.active + + Layout.fillWidth: true + implicitWidth: menuOptionRow.implicitWidth + Appearance.padding.normal * 2 + implicitHeight: menuOptionRow.implicitHeight + Appearance.padding.normal * 2 + + color: Qt.alpha(Colours.palette.m3secondaryContainer, active ? 1 : 0) + + StateLayer { + color: item.active ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface + disabled: !root.expanded + + function onClicked(): void { + root.itemSelected(item.modelData); + root.active = item.modelData; + root.expanded = false; + } + } + + RowLayout { + id: menuOptionRow + + anchors.fill: parent + anchors.margins: Appearance.padding.normal + spacing: Appearance.spacing.small + + MaterialIcon { + Layout.alignment: Qt.AlignVCenter + text: item.modelData.icon + color: item.active ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurfaceVariant + } + + StyledText { + Layout.alignment: Qt.AlignVCenter + Layout.fillWidth: true + text: item.modelData.text + color: item.active ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface + } + + Loader { + Layout.alignment: Qt.AlignVCenter + active: item.modelData.trailingIcon.length > 0 + visible: active + + sourceComponent: MaterialIcon { + text: item.modelData.trailingIcon + color: item.active ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface + } + } + } + } + } + } + } + + Behavior on opacity { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + } + } + + Behavior on implicitHeight { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } +} diff --git a/.config/quickshell/caelestia/components/controls/MenuItem.qml b/.config/quickshell/caelestia/components/controls/MenuItem.qml new file mode 100644 index 0000000..5348bbe --- /dev/null +++ b/.config/quickshell/caelestia/components/controls/MenuItem.qml @@ -0,0 +1,11 @@ +import QtQuick + +QtObject { + required property string text + property string icon + property string trailingIcon + property string activeIcon: icon + property string activeText: text + + signal clicked +} diff --git a/.config/quickshell/caelestia/components/controls/SpinBoxRow.qml b/.config/quickshell/caelestia/components/controls/SpinBoxRow.qml new file mode 100644 index 0000000..fe6a198 --- /dev/null +++ b/.config/quickshell/caelestia/components/controls/SpinBoxRow.qml @@ -0,0 +1,52 @@ +import ".." +import qs.components +import qs.components.effects +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +StyledRect { + id: root + + required property string label + required property real value + required property real min + required property real max + property real step: 1 + property var onValueModified: function (value) {} + + Layout.fillWidth: true + implicitHeight: row.implicitHeight + Appearance.padding.large * 2 + radius: Appearance.rounding.normal + color: Colours.layer(Colours.palette.m3surfaceContainer, 2) + + Behavior on implicitHeight { + Anim {} + } + + RowLayout { + id: row + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Appearance.padding.large + spacing: Appearance.spacing.normal + + StyledText { + Layout.fillWidth: true + text: root.label + } + + CustomSpinBox { + min: root.min + max: root.max + step: root.step + value: root.value + onValueModified: value => { + root.onValueModified(value); + } + } + } +} diff --git a/.config/quickshell/caelestia/components/controls/SplitButton.qml b/.config/quickshell/caelestia/components/controls/SplitButton.qml new file mode 100644 index 0000000..c91474e --- /dev/null +++ b/.config/quickshell/caelestia/components/controls/SplitButton.qml @@ -0,0 +1,164 @@ +import ".." +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +Row { + id: root + + enum Type { + Filled, + Tonal + } + + property real horizontalPadding: Appearance.padding.normal + property real verticalPadding: Appearance.padding.smaller + property int type: SplitButton.Filled + property bool disabled + property bool menuOnTop + property string fallbackIcon + property string fallbackText + + property alias menuItems: menu.items + property alias active: menu.active + property alias expanded: menu.expanded + property alias menu: menu + property alias iconLabel: iconLabel + property alias label: label + property alias stateLayer: stateLayer + + property color colour: type == SplitButton.Filled ? Colours.palette.m3primary : Colours.palette.m3secondaryContainer + property color textColour: type == SplitButton.Filled ? Colours.palette.m3onPrimary : Colours.palette.m3onSecondaryContainer + property color disabledColour: Qt.alpha(Colours.palette.m3onSurface, 0.1) + property color disabledTextColour: Qt.alpha(Colours.palette.m3onSurface, 0.38) + + spacing: Math.floor(Appearance.spacing.small / 2) + + StyledRect { + radius: implicitHeight / 2 * Math.min(1, Appearance.rounding.scale) + topRightRadius: Appearance.rounding.small / 2 + bottomRightRadius: Appearance.rounding.small / 2 + color: root.disabled ? root.disabledColour : root.colour + + implicitWidth: textRow.implicitWidth + root.horizontalPadding * 2 + implicitHeight: expandBtn.implicitHeight + + StateLayer { + id: stateLayer + + rect.topRightRadius: parent.topRightRadius + rect.bottomRightRadius: parent.bottomRightRadius + color: root.textColour + disabled: root.disabled + + function onClicked(): void { + root.active?.clicked(); + } + } + + RowLayout { + id: textRow + + anchors.centerIn: parent + anchors.horizontalCenterOffset: Math.floor(root.verticalPadding / 4) + spacing: Appearance.spacing.small + + MaterialIcon { + id: iconLabel + + Layout.alignment: Qt.AlignVCenter + animate: true + text: root.active?.activeIcon ?? root.fallbackIcon + color: root.disabled ? root.disabledTextColour : root.textColour + fill: 1 + } + + StyledText { + id: label + + Layout.alignment: Qt.AlignVCenter + Layout.preferredWidth: implicitWidth + animate: true + text: root.active?.activeText ?? root.fallbackText + color: root.disabled ? root.disabledTextColour : root.textColour + clip: true + + Behavior on Layout.preferredWidth { + Anim { + easing.bezierCurve: Appearance.anim.curves.emphasized + } + } + } + } + } + + StyledRect { + id: expandBtn + + property real rad: root.expanded ? implicitHeight / 2 * Math.min(1, Appearance.rounding.scale) : Appearance.rounding.small / 2 + + radius: implicitHeight / 2 * Math.min(1, Appearance.rounding.scale) + topLeftRadius: rad + bottomLeftRadius: rad + color: root.disabled ? root.disabledColour : root.colour + + implicitWidth: implicitHeight + implicitHeight: expandIcon.implicitHeight + root.verticalPadding * 2 + + StateLayer { + id: expandStateLayer + + rect.topLeftRadius: parent.topLeftRadius + rect.bottomLeftRadius: parent.bottomLeftRadius + color: root.textColour + disabled: root.disabled + + function onClicked(): void { + root.expanded = !root.expanded; + } + } + + MaterialIcon { + id: expandIcon + + anchors.centerIn: parent + anchors.horizontalCenterOffset: root.expanded ? 0 : -Math.floor(root.verticalPadding / 4) + + text: "expand_more" + color: root.disabled ? root.disabledTextColour : root.textColour + rotation: root.expanded ? 180 : 0 + + Behavior on anchors.horizontalCenterOffset { + Anim {} + } + + Behavior on rotation { + Anim {} + } + } + + Behavior on rad { + Anim {} + } + + Menu { + id: menu + + states: State { + when: root.menuOnTop + + AnchorChanges { + target: menu + anchors.top: undefined + anchors.bottom: expandBtn.top + } + } + + anchors.top: parent.bottom + anchors.right: parent.right + anchors.topMargin: Appearance.spacing.small + anchors.bottomMargin: Appearance.spacing.small + } + } +} diff --git a/.config/quickshell/caelestia/components/controls/SplitButtonRow.qml b/.config/quickshell/caelestia/components/controls/SplitButtonRow.qml new file mode 100644 index 0000000..db9925f --- /dev/null +++ b/.config/quickshell/caelestia/components/controls/SplitButtonRow.qml @@ -0,0 +1,62 @@ +pragma ComponentBehavior: Bound + +import ".." +import qs.components +import qs.components.effects +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +StyledRect { + id: root + + required property string label + property int expandedZ: 100 + property bool enabled: true + + property alias menuItems: splitButton.menuItems + property alias active: splitButton.active + property alias expanded: splitButton.expanded + property alias type: splitButton.type + + signal selected(item: MenuItem) + + Layout.fillWidth: true + implicitHeight: row.implicitHeight + Appearance.padding.large * 2 + radius: Appearance.rounding.normal + color: Colours.layer(Colours.palette.m3surfaceContainer, 2) + + clip: false + z: splitButton.menu.implicitHeight > 0 ? expandedZ : 1 + opacity: enabled ? 1.0 : 0.5 + + RowLayout { + id: row + anchors.fill: parent + anchors.margins: Appearance.padding.large + spacing: Appearance.spacing.normal + + StyledText { + Layout.fillWidth: true + text: root.label + color: root.enabled ? Colours.palette.m3onSurface : Colours.palette.m3onSurfaceVariant + } + + SplitButton { + id: splitButton + enabled: root.enabled + type: SplitButton.Filled + + menu.z: 1 + + stateLayer.onClicked: { + splitButton.expanded = !splitButton.expanded; + } + + menu.onItemSelected: item => { + root.selected(item); + } + } + } +} diff --git a/.config/quickshell/caelestia/components/controls/StyledInputField.qml b/.config/quickshell/caelestia/components/controls/StyledInputField.qml new file mode 100644 index 0000000..0d199c7 --- /dev/null +++ b/.config/quickshell/caelestia/components/controls/StyledInputField.qml @@ -0,0 +1,79 @@ +pragma ComponentBehavior: Bound + +import ".." +import qs.components +import qs.services +import qs.config +import QtQuick + +Item { + id: root + + property string text: "" + property var validator: null + property bool readOnly: false + property int horizontalAlignment: TextInput.AlignHCenter + property int implicitWidth: 70 + property bool enabled: true + + // Expose activeFocus through alias to avoid FINAL property override + readonly property alias hasFocus: inputField.activeFocus + + signal textEdited(string text) + signal editingFinished + + implicitHeight: inputField.implicitHeight + Appearance.padding.small * 2 + + StyledRect { + id: container + + anchors.fill: parent + color: inputHover.containsMouse || inputField.activeFocus ? Colours.layer(Colours.palette.m3surfaceContainer, 3) : Colours.layer(Colours.palette.m3surfaceContainer, 2) + radius: Appearance.rounding.small + border.width: 1 + border.color: inputField.activeFocus ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3outline, 0.3) + opacity: root.enabled ? 1 : 0.5 + + Behavior on color { + CAnim {} + } + Behavior on border.color { + CAnim {} + } + + MouseArea { + id: inputHover + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.IBeamCursor + acceptedButtons: Qt.NoButton + enabled: root.enabled + } + + StyledTextField { + id: inputField + anchors.centerIn: parent + width: parent.width - Appearance.padding.normal + horizontalAlignment: root.horizontalAlignment + validator: root.validator + readOnly: root.readOnly + enabled: root.enabled + + Binding { + target: inputField + property: "text" + value: root.text + when: !inputField.activeFocus + } + + onTextChanged: { + root.text = text; + root.textEdited(text); + } + + onEditingFinished: { + root.editingFinished(); + } + } + } +} diff --git a/.config/quickshell/caelestia/components/controls/StyledRadioButton.qml b/.config/quickshell/caelestia/components/controls/StyledRadioButton.qml new file mode 100644 index 0000000..b72fc77 --- /dev/null +++ b/.config/quickshell/caelestia/components/controls/StyledRadioButton.qml @@ -0,0 +1,57 @@ +import qs.components +import qs.services +import qs.config +import QtQuick +import QtQuick.Templates + +RadioButton { + id: root + + font.pointSize: Appearance.font.size.smaller + + implicitWidth: implicitIndicatorWidth + implicitContentWidth + contentItem.anchors.leftMargin + implicitHeight: Math.max(implicitIndicatorHeight, implicitContentHeight) + + indicator: Rectangle { + id: outerCircle + + implicitWidth: 20 + implicitHeight: 20 + radius: Appearance.rounding.full + color: "transparent" + border.color: root.checked ? Colours.palette.m3primary : Colours.palette.m3onSurfaceVariant + border.width: 2 + anchors.verticalCenter: parent.verticalCenter + + StateLayer { + anchors.margins: -Appearance.padding.smaller + color: root.checked ? Colours.palette.m3onSurface : Colours.palette.m3primary + z: -1 + + function onClicked(): void { + root.click(); + } + } + + StyledRect { + anchors.centerIn: parent + implicitWidth: 8 + implicitHeight: 8 + + radius: Appearance.rounding.full + color: Qt.alpha(Colours.palette.m3primary, root.checked ? 1 : 0) + } + + Behavior on border.color { + CAnim {} + } + } + + contentItem: StyledText { + text: root.text + font.pointSize: root.font.pointSize + anchors.verticalCenter: parent.verticalCenter + anchors.left: outerCircle.right + anchors.leftMargin: Appearance.spacing.smaller + } +} diff --git a/.config/quickshell/caelestia/components/controls/StyledScrollBar.qml b/.config/quickshell/caelestia/components/controls/StyledScrollBar.qml new file mode 100644 index 0000000..de8b679 --- /dev/null +++ b/.config/quickshell/caelestia/components/controls/StyledScrollBar.qml @@ -0,0 +1,190 @@ +import ".." +import qs.services +import qs.config +import QtQuick +import QtQuick.Templates + +ScrollBar { + id: root + + required property Flickable flickable + property bool shouldBeActive + property real nonAnimPosition + property bool animating + + onHoveredChanged: { + if (hovered) + shouldBeActive = true; + else + shouldBeActive = flickable.moving; + } + + property bool _updatingFromFlickable: false + property bool _updatingFromUser: false + + // Sync nonAnimPosition with Qt's automatic position binding + onPositionChanged: { + if (_updatingFromUser) { + _updatingFromUser = false; + return; + } + if (position === nonAnimPosition) { + animating = false; + return; + } + if (!animating && !_updatingFromFlickable && !fullMouse.pressed) { + nonAnimPosition = position; + } + } + + // Sync nonAnimPosition with flickable when not animating + Connections { + target: flickable + function onContentYChanged() { + if (!animating && !fullMouse.pressed) { + _updatingFromFlickable = true; + const contentHeight = flickable.contentHeight; + const height = flickable.height; + if (contentHeight > height) { + nonAnimPosition = Math.max(0, Math.min(1, flickable.contentY / (contentHeight - height))); + } else { + nonAnimPosition = 0; + } + _updatingFromFlickable = false; + } + } + } + + Component.onCompleted: { + if (flickable) { + const contentHeight = flickable.contentHeight; + const height = flickable.height; + if (contentHeight > height) { + nonAnimPosition = Math.max(0, Math.min(1, flickable.contentY / (contentHeight - height))); + } + } + } + implicitWidth: Appearance.padding.small + + contentItem: StyledRect { + anchors.left: parent.left + anchors.right: parent.right + opacity: { + if (root.size === 1) + return 0; + if (fullMouse.pressed) + return 1; + if (mouse.containsMouse) + return 0.8; + if (root.policy === ScrollBar.AlwaysOn || root.shouldBeActive) + return 0.6; + return 0; + } + radius: Appearance.rounding.full + color: Colours.palette.m3secondary + + MouseArea { + id: mouse + + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + acceptedButtons: Qt.NoButton + } + + Behavior on opacity { + Anim {} + } + } + + Connections { + target: root.flickable + + function onMovingChanged(): void { + if (root.flickable.moving) + root.shouldBeActive = true; + else + hideDelay.restart(); + } + } + + Timer { + id: hideDelay + + interval: 600 + onTriggered: root.shouldBeActive = root.flickable.moving || root.hovered + } + + CustomMouseArea { + id: fullMouse + + anchors.fill: parent + preventStealing: true + + onPressed: event => { + root.animating = true; + root._updatingFromUser = true; + const newPos = Math.max(0, Math.min(1 - root.size, event.y / root.height - root.size / 2)); + root.nonAnimPosition = newPos; + // Update flickable position + // Map scrollbar position [0, 1-size] to contentY [0, maxContentY] + if (root.flickable) { + const contentHeight = root.flickable.contentHeight; + const height = root.flickable.height; + if (contentHeight > height) { + const maxContentY = contentHeight - height; + const maxPos = 1 - root.size; + const contentY = maxPos > 0 ? (newPos / maxPos) * maxContentY : 0; + root.flickable.contentY = Math.max(0, Math.min(maxContentY, contentY)); + } + } + } + + onPositionChanged: event => { + root._updatingFromUser = true; + const newPos = Math.max(0, Math.min(1 - root.size, event.y / root.height - root.size / 2)); + root.nonAnimPosition = newPos; + // Update flickable position + // Map scrollbar position [0, 1-size] to contentY [0, maxContentY] + if (root.flickable) { + const contentHeight = root.flickable.contentHeight; + const height = root.flickable.height; + if (contentHeight > height) { + const maxContentY = contentHeight - height; + const maxPos = 1 - root.size; + const contentY = maxPos > 0 ? (newPos / maxPos) * maxContentY : 0; + root.flickable.contentY = Math.max(0, Math.min(maxContentY, contentY)); + } + } + } + + function onWheel(event: WheelEvent): void { + root.animating = true; + root._updatingFromUser = true; + let newPos = root.nonAnimPosition; + if (event.angleDelta.y > 0) + newPos = Math.max(0, root.nonAnimPosition - 0.1); + else if (event.angleDelta.y < 0) + newPos = Math.min(1 - root.size, root.nonAnimPosition + 0.1); + root.nonAnimPosition = newPos; + // Update flickable position + // Map scrollbar position [0, 1-size] to contentY [0, maxContentY] + if (root.flickable) { + const contentHeight = root.flickable.contentHeight; + const height = root.flickable.height; + if (contentHeight > height) { + const maxContentY = contentHeight - height; + const maxPos = 1 - root.size; + const contentY = maxPos > 0 ? (newPos / maxPos) * maxContentY : 0; + root.flickable.contentY = Math.max(0, Math.min(maxContentY, contentY)); + } + } + } + } + + Behavior on position { + enabled: !fullMouse.pressed + + Anim {} + } +} diff --git a/.config/quickshell/caelestia/components/controls/StyledSlider.qml b/.config/quickshell/caelestia/components/controls/StyledSlider.qml new file mode 100644 index 0000000..0ef229d --- /dev/null +++ b/.config/quickshell/caelestia/components/controls/StyledSlider.qml @@ -0,0 +1,57 @@ +import qs.components +import qs.services +import qs.config +import QtQuick +import QtQuick.Templates + +Slider { + id: root + + background: Item { + StyledRect { + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.topMargin: root.implicitHeight / 3 + anchors.bottomMargin: root.implicitHeight / 3 + + implicitWidth: root.handle.x - root.implicitHeight / 6 + + color: Colours.palette.m3primary + radius: Appearance.rounding.full + topRightRadius: root.implicitHeight / 15 + bottomRightRadius: root.implicitHeight / 15 + } + + StyledRect { + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.right: parent.right + anchors.topMargin: root.implicitHeight / 3 + anchors.bottomMargin: root.implicitHeight / 3 + + implicitWidth: parent.width - root.handle.x - root.handle.implicitWidth - root.implicitHeight / 6 + + color: Colours.palette.m3surfaceContainerHighest + radius: Appearance.rounding.full + topLeftRadius: root.implicitHeight / 15 + bottomLeftRadius: root.implicitHeight / 15 + } + } + + handle: StyledRect { + x: root.visualPosition * root.availableWidth - implicitWidth / 2 + + implicitWidth: root.implicitHeight / 4.5 + implicitHeight: root.implicitHeight + + color: Colours.palette.m3primary + radius: Appearance.rounding.full + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + cursorShape: Qt.PointingHandCursor + } + } +} diff --git a/.config/quickshell/caelestia/components/controls/StyledSwitch.qml b/.config/quickshell/caelestia/components/controls/StyledSwitch.qml new file mode 100644 index 0000000..ce93cd5 --- /dev/null +++ b/.config/quickshell/caelestia/components/controls/StyledSwitch.qml @@ -0,0 +1,152 @@ +import ".." +import qs.services +import qs.config +import QtQuick +import QtQuick.Templates +import QtQuick.Shapes + +Switch { + id: root + + property int cLayer: 1 + + implicitWidth: implicitIndicatorWidth + implicitHeight: implicitIndicatorHeight + + indicator: StyledRect { + radius: Appearance.rounding.full + color: root.checked ? Colours.palette.m3primary : Colours.layer(Colours.palette.m3surfaceContainerHighest, root.cLayer) + + implicitWidth: implicitHeight * 1.7 + implicitHeight: Appearance.font.size.normal + Appearance.padding.smaller * 2 + + StyledRect { + readonly property real nonAnimWidth: root.pressed ? implicitHeight * 1.3 : implicitHeight + + radius: Appearance.rounding.full + color: root.checked ? Colours.palette.m3onPrimary : Colours.layer(Colours.palette.m3outline, root.cLayer + 1) + + x: root.checked ? parent.implicitWidth - nonAnimWidth - Appearance.padding.small / 2 : Appearance.padding.small / 2 + implicitWidth: nonAnimWidth + implicitHeight: parent.implicitHeight - Appearance.padding.small + anchors.verticalCenter: parent.verticalCenter + + StyledRect { + anchors.fill: parent + radius: parent.radius + + color: root.checked ? Colours.palette.m3primary : Colours.palette.m3onSurface + opacity: root.pressed ? 0.1 : root.hovered ? 0.08 : 0 + + Behavior on opacity { + Anim {} + } + } + + Shape { + id: icon + + property point start1: { + if (root.pressed) + return Qt.point(width * 0.2, height / 2); + if (root.checked) + return Qt.point(width * 0.15, height / 2); + return Qt.point(width * 0.15, height * 0.15); + } + property point end1: { + if (root.pressed) { + if (root.checked) + return Qt.point(width * 0.4, height / 2); + return Qt.point(width * 0.8, height / 2); + } + if (root.checked) + return Qt.point(width * 0.4, height * 0.7); + return Qt.point(width * 0.85, height * 0.85); + } + property point start2: { + if (root.pressed) { + if (root.checked) + return Qt.point(width * 0.4, height / 2); + return Qt.point(width * 0.2, height / 2); + } + if (root.checked) + return Qt.point(width * 0.4, height * 0.7); + return Qt.point(width * 0.15, height * 0.85); + } + property point end2: { + if (root.pressed) + return Qt.point(width * 0.8, height / 2); + if (root.checked) + return Qt.point(width * 0.85, height * 0.2); + return Qt.point(width * 0.85, height * 0.15); + } + + anchors.centerIn: parent + width: height + height: parent.implicitHeight - Appearance.padding.small * 2 + preferredRendererType: Shape.CurveRenderer + asynchronous: true + + ShapePath { + strokeWidth: Appearance.font.size.larger * 0.15 + strokeColor: root.checked ? Colours.palette.m3primary : Colours.palette.m3surfaceContainerHighest + fillColor: "transparent" + capStyle: Appearance.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap + + startX: icon.start1.x + startY: icon.start1.y + + PathLine { + x: icon.end1.x + y: icon.end1.y + } + PathMove { + x: icon.start2.x + y: icon.start2.y + } + PathLine { + x: icon.end2.x + y: icon.end2.y + } + + Behavior on strokeColor { + CAnim {} + } + } + + Behavior on start1 { + PropAnim {} + } + Behavior on end1 { + PropAnim {} + } + Behavior on start2 { + PropAnim {} + } + Behavior on end2 { + PropAnim {} + } + } + + Behavior on x { + Anim {} + } + + Behavior on implicitWidth { + Anim {} + } + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + enabled: false + } + + component PropAnim: PropertyAnimation { + duration: Appearance.anim.durations.normal + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.anim.curves.standard + } +} diff --git a/.config/quickshell/caelestia/components/controls/StyledTextField.qml b/.config/quickshell/caelestia/components/controls/StyledTextField.qml new file mode 100644 index 0000000..60bcff2 --- /dev/null +++ b/.config/quickshell/caelestia/components/controls/StyledTextField.qml @@ -0,0 +1,76 @@ +pragma ComponentBehavior: Bound + +import ".." +import qs.services +import qs.config +import QtQuick +import QtQuick.Controls + +TextField { + id: root + + color: Colours.palette.m3onSurface + placeholderTextColor: Colours.palette.m3outline + font.family: Appearance.font.family.sans + font.pointSize: Appearance.font.size.smaller + renderType: echoMode === TextField.Password ? TextField.QtRendering : TextField.NativeRendering + cursorVisible: !readOnly + + background: null + + cursorDelegate: StyledRect { + id: cursor + + property bool disableBlink + + implicitWidth: 2 + color: Colours.palette.m3primary + radius: Appearance.rounding.normal + + Connections { + target: root + + function onCursorPositionChanged(): void { + if (root.activeFocus && root.cursorVisible) { + cursor.opacity = 1; + cursor.disableBlink = true; + enableBlink.restart(); + } + } + } + + Timer { + id: enableBlink + + interval: 100 + onTriggered: cursor.disableBlink = false + } + + Timer { + running: root.activeFocus && root.cursorVisible && !cursor.disableBlink + repeat: true + triggeredOnStart: true + interval: 500 + onTriggered: parent.opacity = parent.opacity === 1 ? 0 : 1 + } + + Binding { + when: !root.activeFocus || !root.cursorVisible + cursor.opacity: 0 + } + + Behavior on opacity { + Anim { + duration: Appearance.anim.durations.small + } + } + } + + Behavior on color { + CAnim {} + } + + Behavior on placeholderTextColor { + CAnim {} + } +} diff --git a/.config/quickshell/caelestia/components/controls/SwitchRow.qml b/.config/quickshell/caelestia/components/controls/SwitchRow.qml new file mode 100644 index 0000000..6dda3f0 --- /dev/null +++ b/.config/quickshell/caelestia/components/controls/SwitchRow.qml @@ -0,0 +1,48 @@ +import ".." +import qs.components +import qs.components.effects +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +StyledRect { + id: root + + required property string label + required property bool checked + property bool enabled: true + property var onToggled: function (checked) {} + + Layout.fillWidth: true + implicitHeight: row.implicitHeight + Appearance.padding.large * 2 + radius: Appearance.rounding.normal + color: Colours.layer(Colours.palette.m3surfaceContainer, 2) + + Behavior on implicitHeight { + Anim {} + } + + RowLayout { + id: row + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Appearance.padding.large + spacing: Appearance.spacing.normal + + StyledText { + Layout.fillWidth: true + text: root.label + } + + StyledSwitch { + checked: root.checked + enabled: root.enabled + onToggled: { + root.onToggled(checked); + } + } + } +} diff --git a/.config/quickshell/caelestia/components/controls/TextButton.qml b/.config/quickshell/caelestia/components/controls/TextButton.qml new file mode 100644 index 0000000..ecf7eb1 --- /dev/null +++ b/.config/quickshell/caelestia/components/controls/TextButton.qml @@ -0,0 +1,78 @@ +import ".." +import qs.services +import qs.config +import QtQuick + +StyledRect { + id: root + + enum Type { + Filled, + Tonal, + Text + } + + property alias text: label.text + property bool checked + property bool toggle + property real horizontalPadding: Appearance.padding.normal + property real verticalPadding: Appearance.padding.smaller + property alias font: label.font + property int type: TextButton.Filled + + property alias stateLayer: stateLayer + property alias label: label + + property bool internalChecked + property color activeColour: type === TextButton.Filled ? Colours.palette.m3primary : Colours.palette.m3secondary + property color inactiveColour: { + if (!toggle && type === TextButton.Filled) + return Colours.palette.m3primary; + return type === TextButton.Filled ? Colours.tPalette.m3surfaceContainer : Colours.palette.m3secondaryContainer; + } + property color activeOnColour: { + if (type === TextButton.Text) + return Colours.palette.m3primary; + return type === TextButton.Filled ? Colours.palette.m3onPrimary : Colours.palette.m3onSecondary; + } + property color inactiveOnColour: { + if (!toggle && type === TextButton.Filled) + return Colours.palette.m3onPrimary; + if (type === TextButton.Text) + return Colours.palette.m3primary; + return type === TextButton.Filled ? Colours.palette.m3onSurface : Colours.palette.m3onSecondaryContainer; + } + + signal clicked + + onCheckedChanged: internalChecked = checked + + radius: internalChecked ? Appearance.rounding.small : implicitHeight / 2 * Math.min(1, Appearance.rounding.scale) + color: type === TextButton.Text ? "transparent" : internalChecked ? activeColour : inactiveColour + + implicitWidth: label.implicitWidth + horizontalPadding * 2 + implicitHeight: label.implicitHeight + verticalPadding * 2 + + StateLayer { + id: stateLayer + + color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour + + function onClicked(): void { + if (root.toggle) + root.internalChecked = !root.internalChecked; + root.clicked(); + } + } + + StyledText { + id: label + + anchors.centerIn: parent + color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour + } + + Behavior on radius { + Anim {} + } +} diff --git a/.config/quickshell/caelestia/components/controls/ToggleButton.qml b/.config/quickshell/caelestia/components/controls/ToggleButton.qml new file mode 100644 index 0000000..98c7564 --- /dev/null +++ b/.config/quickshell/caelestia/components/controls/ToggleButton.qml @@ -0,0 +1,124 @@ +import ".." +import qs.components +import qs.components.controls +import qs.components.effects +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +StyledRect { + id: root + + required property bool toggled + property string icon + property string label + property string accent: "Secondary" + property real iconSize: Appearance.font.size.large + property real horizontalPadding: Appearance.padding.large + property real verticalPadding: Appearance.padding.normal + property string tooltip: "" + + property bool hovered: false + signal clicked + + Component.onCompleted: { + hovered = toggleStateLayer.containsMouse; + } + + Connections { + target: toggleStateLayer + function onContainsMouseChanged() { + const newHovered = toggleStateLayer.containsMouse; + if (hovered !== newHovered) { + hovered = newHovered; + } + } + } + + Layout.preferredWidth: implicitWidth + (toggleStateLayer.pressed ? Appearance.padding.normal * 2 : toggled ? Appearance.padding.small * 2 : 0) + implicitWidth: toggleBtnInner.implicitWidth + horizontalPadding * 2 + implicitHeight: toggleBtnIcon.implicitHeight + verticalPadding * 2 + + radius: toggled || toggleStateLayer.pressed ? Appearance.rounding.small : Math.min(width, height) / 2 * Math.min(1, Appearance.rounding.scale) + color: toggled ? Colours.palette[`m3${accent.toLowerCase()}`] : Colours.palette[`m3${accent.toLowerCase()}Container`] + + StateLayer { + id: toggleStateLayer + + color: root.toggled ? Colours.palette[`m3on${root.accent}`] : Colours.palette[`m3on${root.accent}Container`] + + function onClicked(): void { + root.clicked(); + } + } + + RowLayout { + id: toggleBtnInner + + anchors.centerIn: parent + spacing: Appearance.spacing.normal + + MaterialIcon { + id: toggleBtnIcon + + visible: !!text + fill: root.toggled ? 1 : 0 + text: root.icon + color: root.toggled ? Colours.palette[`m3on${root.accent}`] : Colours.palette[`m3on${root.accent}Container`] + font.pointSize: root.iconSize + + Behavior on fill { + Anim {} + } + } + + Loader { + active: !!root.label + visible: active + + sourceComponent: StyledText { + text: root.label + color: root.toggled ? Colours.palette[`m3on${root.accent}`] : Colours.palette[`m3on${root.accent}Container`] + } + } + } + + Behavior on radius { + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + } + } + + Behavior on Layout.preferredWidth { + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + } + } + + // Tooltip - positioned absolutely, doesn't affect layout + Loader { + id: tooltipLoader + active: root.tooltip !== "" + z: 10000 + width: 0 + height: 0 + sourceComponent: Component { + Tooltip { + target: root + text: root.tooltip + } + } + // Completely remove from layout + Layout.fillWidth: false + Layout.fillHeight: false + Layout.preferredWidth: 0 + Layout.preferredHeight: 0 + Layout.maximumWidth: 0 + Layout.maximumHeight: 0 + Layout.minimumWidth: 0 + Layout.minimumHeight: 0 + } +} diff --git a/.config/quickshell/caelestia/components/controls/ToggleRow.qml b/.config/quickshell/caelestia/components/controls/ToggleRow.qml new file mode 100644 index 0000000..269d3d6 --- /dev/null +++ b/.config/quickshell/caelestia/components/controls/ToggleRow.qml @@ -0,0 +1,28 @@ +import qs.components +import qs.components.controls +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +RowLayout { + id: root + + required property string label + property alias checked: toggle.checked + property alias toggle: toggle + + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + StyledText { + Layout.fillWidth: true + text: root.label + } + + StyledSwitch { + id: toggle + + cLayer: 2 + } +} diff --git a/.config/quickshell/caelestia/components/controls/Tooltip.qml b/.config/quickshell/caelestia/components/controls/Tooltip.qml new file mode 100644 index 0000000..b129a37 --- /dev/null +++ b/.config/quickshell/caelestia/components/controls/Tooltip.qml @@ -0,0 +1,185 @@ +import ".." +import qs.components.effects +import qs.services +import qs.config +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Popup { + id: root + + required property Item target + required property string text + property int delay: 500 + property int timeout: 0 + + property bool tooltipVisible: false + property Timer showTimer: Timer { + interval: root.delay + onTriggered: root.tooltipVisible = true + } + property Timer hideTimer: Timer { + interval: root.timeout + onTriggered: root.tooltipVisible = false + } + + // Popup properties - doesn't affect layout + parent: { + let p = target; + // Walk up to find the root Item (usually has anchors.fill: parent) + while (p && p.parent) { + const parentItem = p.parent; + // Check if this looks like a root pane Item + if (parentItem && parentItem.anchors && parentItem.anchors.fill !== undefined) { + return parentItem; + } + p = parentItem; + } + // Fallback + return target.parent?.parent?.parent ?? target.parent?.parent ?? target.parent ?? target; + } + + visible: tooltipVisible + modal: false + closePolicy: Popup.NoAutoClose + padding: 0 + margins: 0 + background: Item {} + + // Update position when target moves or tooltip becomes visible + onTooltipVisibleChanged: { + if (tooltipVisible) { + Qt.callLater(updatePosition); + } + } + Connections { + target: root.target + function onXChanged() { + if (root.tooltipVisible) + root.updatePosition(); + } + function onYChanged() { + if (root.tooltipVisible) + root.updatePosition(); + } + function onWidthChanged() { + if (root.tooltipVisible) + root.updatePosition(); + } + function onHeightChanged() { + if (root.tooltipVisible) + root.updatePosition(); + } + } + + function updatePosition() { + if (!target || !parent) + return; + + // Wait for tooltipRect to have its size calculated + Qt.callLater(() => { + if (!target || !parent || !tooltipRect) + return; + + // Get target position in parent's coordinate system + const targetPos = target.mapToItem(parent, 0, 0); + const targetCenterX = targetPos.x + target.width / 2; + + // Get tooltip size (use width/height if available, otherwise implicit) + const tooltipWidth = tooltipRect.width > 0 ? tooltipRect.width : tooltipRect.implicitWidth; + const tooltipHeight = tooltipRect.height > 0 ? tooltipRect.height : tooltipRect.implicitHeight; + + // Center tooltip horizontally on target + let newX = targetCenterX - tooltipWidth / 2; + + // Position tooltip above target + let newY = targetPos.y - tooltipHeight - Appearance.spacing.small; + + // Keep within bounds + const padding = Appearance.padding.normal; + if (newX < padding) { + newX = padding; + } else if (newX + tooltipWidth > (parent.width - padding)) { + newX = parent.width - tooltipWidth - padding; + } + + // Update popup position + x = newX; + y = newY; + }); + } + + enter: Transition { + Anim { + property: "opacity" + from: 0 + to: 1 + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + } + } + + exit: Transition { + Anim { + property: "opacity" + from: 1 + to: 0 + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + } + } + + // Monitor hover state + Connections { + target: root.target + function onHoveredChanged() { + if (target.hovered) { + showTimer.start(); + if (timeout > 0) { + hideTimer.stop(); + hideTimer.start(); + } + } else { + showTimer.stop(); + hideTimer.stop(); + tooltipVisible = false; + } + } + } + + contentItem: StyledRect { + id: tooltipRect + + implicitWidth: tooltipText.implicitWidth + Appearance.padding.normal * 2 + implicitHeight: tooltipText.implicitHeight + Appearance.padding.smaller * 2 + + color: Colours.palette.m3surfaceContainerHighest + radius: Appearance.rounding.small + antialiasing: true + + // Add elevation for depth + Elevation { + anchors.fill: parent + radius: parent.radius + z: -1 + level: 3 + } + + StyledText { + id: tooltipText + + anchors.centerIn: parent + + text: root.text + color: Colours.palette.m3onSurface + font.pointSize: Appearance.font.size.small + } + } + + Component.onCompleted: { + if (tooltipVisible) { + updatePosition(); + } + } +} diff --git a/.config/quickshell/caelestia/components/effects/ColouredIcon.qml b/.config/quickshell/caelestia/components/effects/ColouredIcon.qml new file mode 100644 index 0000000..5ef4d4c --- /dev/null +++ b/.config/quickshell/caelestia/components/effects/ColouredIcon.qml @@ -0,0 +1,35 @@ +pragma ComponentBehavior: Bound + +import Caelestia +import Quickshell.Widgets +import QtQuick + +IconImage { + id: root + + required property color colour + + asynchronous: true + + layer.enabled: true + layer.effect: Colouriser { + sourceColor: analyser.dominantColour + colorizationColor: root.colour + } + + layer.onEnabledChanged: { + if (layer.enabled && status === Image.Ready) + analyser.requestUpdate(); + } + + onStatusChanged: { + if (layer.enabled && status === Image.Ready) + analyser.requestUpdate(); + } + + ImageAnalyser { + id: analyser + + sourceItem: root + } +} diff --git a/.config/quickshell/caelestia/components/effects/Colouriser.qml b/.config/quickshell/caelestia/components/effects/Colouriser.qml new file mode 100644 index 0000000..2948155 --- /dev/null +++ b/.config/quickshell/caelestia/components/effects/Colouriser.qml @@ -0,0 +1,14 @@ +import ".." +import QtQuick +import QtQuick.Effects + +MultiEffect { + property color sourceColor: "black" + + colorization: 1 + brightness: 1 - sourceColor.hslLightness + + Behavior on colorizationColor { + CAnim {} + } +} diff --git a/.config/quickshell/caelestia/components/effects/Elevation.qml b/.config/quickshell/caelestia/components/effects/Elevation.qml new file mode 100644 index 0000000..fb29f16 --- /dev/null +++ b/.config/quickshell/caelestia/components/effects/Elevation.qml @@ -0,0 +1,18 @@ +import ".." +import qs.services +import QtQuick +import QtQuick.Effects + +RectangularShadow { + property int level + property real dp: [0, 1, 3, 6, 8, 12][level] + + color: Qt.alpha(Colours.palette.m3shadow, 0.7) + blur: (dp * 5) ** 0.7 + spread: -dp * 0.3 + (dp * 0.1) ** 2 + offset.y: dp / 2 + + Behavior on dp { + Anim {} + } +} diff --git a/.config/quickshell/caelestia/components/effects/InnerBorder.qml b/.config/quickshell/caelestia/components/effects/InnerBorder.qml new file mode 100644 index 0000000..d4a751f --- /dev/null +++ b/.config/quickshell/caelestia/components/effects/InnerBorder.qml @@ -0,0 +1,44 @@ +pragma ComponentBehavior: Bound + +import ".." +import qs.services +import qs.config +import QtQuick +import QtQuick.Effects + +StyledRect { + property alias innerRadius: maskInner.radius + property alias thickness: maskInner.anchors.margins + property alias leftThickness: maskInner.anchors.leftMargin + property alias topThickness: maskInner.anchors.topMargin + property alias rightThickness: maskInner.anchors.rightMargin + property alias bottomThickness: maskInner.anchors.bottomMargin + + anchors.fill: parent + color: Colours.tPalette.m3surfaceContainer + + layer.enabled: true + layer.effect: MultiEffect { + maskSource: mask + maskEnabled: true + maskInverted: true + maskThresholdMin: 0.5 + maskSpreadAtMin: 1 + } + + Item { + id: mask + + anchors.fill: parent + layer.enabled: true + visible: false + + Rectangle { + id: maskInner + + anchors.fill: parent + anchors.margins: Appearance.padding.normal + radius: Appearance.rounding.small + } + } +} diff --git a/.config/quickshell/caelestia/components/effects/OpacityMask.qml b/.config/quickshell/caelestia/components/effects/OpacityMask.qml new file mode 100644 index 0000000..22e4249 --- /dev/null +++ b/.config/quickshell/caelestia/components/effects/OpacityMask.qml @@ -0,0 +1,9 @@ +import Quickshell +import QtQuick + +ShaderEffect { + required property Item source + required property Item maskSource + + fragmentShader: Qt.resolvedUrl(`${Quickshell.shellDir}/assets/shaders/opacitymask.frag.qsb`) +} diff --git a/.config/quickshell/caelestia/components/filedialog/CurrentItem.qml b/.config/quickshell/caelestia/components/filedialog/CurrentItem.qml new file mode 100644 index 0000000..bb87133 --- /dev/null +++ b/.config/quickshell/caelestia/components/filedialog/CurrentItem.qml @@ -0,0 +1,102 @@ +import ".." +import qs.services +import qs.config +import QtQuick +import QtQuick.Shapes + +Item { + id: root + + required property var currentItem + + implicitWidth: content.implicitWidth + Appearance.padding.larger + content.anchors.rightMargin + implicitHeight: currentItem ? content.implicitHeight + Appearance.padding.normal + content.anchors.bottomMargin : 0 + + Shape { + preferredRendererType: Shape.CurveRenderer + + ShapePath { + id: path + + readonly property real rounding: Appearance.rounding.small + readonly property bool flatten: root.implicitHeight < rounding * 2 + readonly property real roundingY: flatten ? root.implicitHeight / 2 : rounding + + strokeWidth: -1 + fillColor: Colours.tPalette.m3surfaceContainer + + startX: root.implicitWidth + startY: root.implicitHeight + + PathLine { + relativeX: -(root.implicitWidth + path.rounding) + relativeY: 0 + } + PathArc { + relativeX: path.rounding + relativeY: -path.roundingY + radiusX: path.rounding + radiusY: Math.min(path.rounding, root.implicitHeight) + direction: PathArc.Counterclockwise + } + PathLine { + relativeX: 0 + relativeY: -(root.implicitHeight - path.roundingY * 2) + } + PathArc { + relativeX: path.rounding + relativeY: -path.roundingY + radiusX: path.rounding + radiusY: Math.min(path.rounding, root.implicitHeight) + } + PathLine { + relativeX: root.implicitHeight > 0 ? root.implicitWidth - path.rounding * 2 : root.implicitWidth + relativeY: 0 + } + PathArc { + relativeX: path.rounding + relativeY: -path.rounding + radiusX: path.rounding + radiusY: path.rounding + direction: PathArc.Counterclockwise + } + + Behavior on fillColor { + CAnim {} + } + } + } + + Item { + anchors.fill: parent + clip: true + + StyledText { + id: content + + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.rightMargin: Appearance.padding.larger - Appearance.padding.small + anchors.bottomMargin: Appearance.padding.normal - Appearance.padding.small + + Connections { + target: root + + function onCurrentItemChanged(): void { + if (root.currentItem) + content.text = qsTr(`"%1" selected`).arg(root.currentItem.modelData.name); + } + } + } + } + + Behavior on implicitWidth { + enabled: !!root.currentItem + + Anim {} + } + + Behavior on implicitHeight { + Anim {} + } +} diff --git a/.config/quickshell/caelestia/components/filedialog/DialogButtons.qml b/.config/quickshell/caelestia/components/filedialog/DialogButtons.qml new file mode 100644 index 0000000..bde9ac2 --- /dev/null +++ b/.config/quickshell/caelestia/components/filedialog/DialogButtons.qml @@ -0,0 +1,93 @@ +import ".." +import qs.services +import qs.config +import QtQuick.Layouts + +StyledRect { + id: root + + required property var dialog + required property FolderContents folder + + implicitHeight: inner.implicitHeight + Appearance.padding.normal * 2 + + color: Colours.tPalette.m3surfaceContainer + + RowLayout { + id: inner + + anchors.fill: parent + anchors.margins: Appearance.padding.normal + + spacing: Appearance.spacing.small + + StyledText { + text: qsTr("Filter:") + } + + StyledRect { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.rightMargin: Appearance.spacing.normal + + color: Colours.tPalette.m3surfaceContainerHigh + radius: Appearance.rounding.small + + StyledText { + anchors.fill: parent + anchors.margins: Appearance.padding.normal + + text: `${root.dialog.filterLabel} (${root.dialog.filters.map(f => `*.${f}`).join(", ")})` + } + } + + StyledRect { + color: Colours.tPalette.m3surfaceContainerHigh + radius: Appearance.rounding.small + + implicitWidth: cancelText.implicitWidth + Appearance.padding.normal * 2 + implicitHeight: cancelText.implicitHeight + Appearance.padding.normal * 2 + + StateLayer { + disabled: !root.dialog.selectionValid + + function onClicked(): void { + root.dialog.accepted(root.folder.currentItem.modelData.path); + } + } + + StyledText { + id: selectText + + anchors.centerIn: parent + anchors.margins: Appearance.padding.normal + + text: qsTr("Select") + color: root.dialog.selectionValid ? Colours.palette.m3onSurface : Colours.palette.m3outline + } + } + + StyledRect { + color: Colours.tPalette.m3surfaceContainerHigh + radius: Appearance.rounding.small + + implicitWidth: cancelText.implicitWidth + Appearance.padding.normal * 2 + implicitHeight: cancelText.implicitHeight + Appearance.padding.normal * 2 + + StateLayer { + function onClicked(): void { + root.dialog.rejected(); + } + } + + StyledText { + id: cancelText + + anchors.centerIn: parent + anchors.margins: Appearance.padding.normal + + text: qsTr("Cancel") + } + } + } +} diff --git a/.config/quickshell/caelestia/components/filedialog/FileDialog.qml b/.config/quickshell/caelestia/components/filedialog/FileDialog.qml new file mode 100644 index 0000000..f3187a5 --- /dev/null +++ b/.config/quickshell/caelestia/components/filedialog/FileDialog.qml @@ -0,0 +1,102 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.services +import Quickshell +import QtQuick +import QtQuick.Layouts + +LazyLoader { + id: loader + + property list cwd: ["Home"] + property string filterLabel: "All files" + property list filters: ["*"] + property string title: qsTr("Select a file") + + signal accepted(path: string) + signal rejected + + function open(): void { + activeAsync = true; + } + + function close(): void { + rejected(); + } + + onAccepted: activeAsync = false + onRejected: activeAsync = false + + FloatingWindow { + id: root + + property list cwd: loader.cwd + property string filterLabel: loader.filterLabel + property list filters: loader.filters + + readonly property bool selectionValid: { + const file = folderContents.currentItem?.modelData; + return (file && !file.isDir && (filters.includes("*") || filters.includes(file.suffix))) ?? false; + } + + function accepted(path: string): void { + loader.accepted(path); + } + + function rejected(): void { + loader.rejected(); + } + + implicitWidth: 1000 + implicitHeight: 600 + color: Colours.tPalette.m3surface + title: loader.title + + onVisibleChanged: { + if (!visible) + rejected(); + } + + RowLayout { + anchors.fill: parent + + spacing: 0 + + Sidebar { + Layout.fillHeight: true + dialog: root + } + + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + + spacing: 0 + + HeaderBar { + Layout.fillWidth: true + dialog: root + } + + FolderContents { + id: folderContents + + Layout.fillWidth: true + Layout.fillHeight: true + dialog: root + } + + DialogButtons { + Layout.fillWidth: true + dialog: root + folder: folderContents + } + } + } + + Behavior on color { + CAnim {} + } + } +} diff --git a/.config/quickshell/caelestia/components/filedialog/FolderContents.qml b/.config/quickshell/caelestia/components/filedialog/FolderContents.qml new file mode 100644 index 0000000..e16c7a1 --- /dev/null +++ b/.config/quickshell/caelestia/components/filedialog/FolderContents.qml @@ -0,0 +1,228 @@ +pragma ComponentBehavior: Bound + +import ".." +import "../controls" +import "../images" +import qs.services +import qs.config +import qs.utils +import Caelestia.Models +import Quickshell +import QtQuick +import QtQuick.Layouts +import QtQuick.Effects + +Item { + id: root + + required property var dialog + property alias currentItem: view.currentItem + + StyledRect { + anchors.fill: parent + color: Colours.tPalette.m3surfaceContainer + + layer.enabled: true + layer.effect: MultiEffect { + maskSource: mask + maskEnabled: true + maskInverted: true + maskThresholdMin: 0.5 + maskSpreadAtMin: 1 + } + } + + Item { + id: mask + + anchors.fill: parent + layer.enabled: true + visible: false + + Rectangle { + anchors.fill: parent + anchors.margins: Appearance.padding.small + radius: Appearance.rounding.small + } + } + + Loader { + anchors.centerIn: parent + + opacity: view.count === 0 ? 1 : 0 + active: opacity > 0 + + sourceComponent: ColumnLayout { + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + text: "scan_delete" + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.extraLarge * 2 + font.weight: 500 + } + + StyledText { + text: qsTr("This folder is empty") + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.large + font.weight: 500 + } + } + + Behavior on opacity { + Anim {} + } + } + + GridView { + id: view + + anchors.fill: parent + anchors.margins: Appearance.padding.small + Appearance.padding.normal + + cellWidth: Sizes.itemWidth + Appearance.spacing.small + cellHeight: Sizes.itemWidth + Appearance.spacing.small * 2 + Appearance.padding.normal * 2 + 1 + + clip: true + focus: true + currentIndex: -1 + Keys.onEscapePressed: currentIndex = -1 + + Keys.onReturnPressed: { + if (root.dialog.selectionValid) + root.dialog.accepted(currentItem.modelData.path); + } + Keys.onEnterPressed: { + if (root.dialog.selectionValid) + root.dialog.accepted(currentItem.modelData.path); + } + + StyledScrollBar.vertical: StyledScrollBar { + flickable: view + } + + model: FileSystemModel { + path: { + if (root.dialog.cwd[0] === "Home") + return `${Paths.home}/${root.dialog.cwd.slice(1).join("/")}`; + else + return root.dialog.cwd.join("/"); + } + onPathChanged: view.currentIndex = -1 + } + + delegate: StyledRect { + id: item + + required property int index + required property FileSystemEntry modelData + + readonly property real nonAnimHeight: icon.implicitHeight + name.anchors.topMargin + name.implicitHeight + Appearance.padding.normal * 2 + + implicitWidth: Sizes.itemWidth + implicitHeight: nonAnimHeight + + radius: Appearance.rounding.normal + color: Qt.alpha(Colours.tPalette.m3surfaceContainerHighest, GridView.isCurrentItem ? Colours.tPalette.m3surfaceContainerHighest.a : 0) + z: GridView.isCurrentItem || implicitHeight !== nonAnimHeight ? 1 : 0 + clip: true + + StateLayer { + onDoubleClicked: { + if (item.modelData.isDir) + root.dialog.cwd.push(item.modelData.name); + else if (root.dialog.selectionValid) + root.dialog.accepted(item.modelData.path); + } + + function onClicked(): void { + view.currentIndex = item.index; + } + } + + CachingIconImage { + id: icon + + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.topMargin: Appearance.padding.normal + + implicitSize: Sizes.itemWidth - Appearance.padding.normal * 2 + + Component.onCompleted: { + const file = item.modelData; + if (file.isImage) + source = Qt.resolvedUrl(file.path); + else if (!file.isDir) + source = Quickshell.iconPath(file.mimeType.replace("/", "-"), "application-x-zerosize"); + else if (root.dialog.cwd.length === 1 && ["Desktop", "Documents", "Downloads", "Music", "Pictures", "Public", "Templates", "Videos"].includes(file.name)) + source = Quickshell.iconPath(`folder-${file.name.toLowerCase()}`); + else + source = Quickshell.iconPath("inode-directory"); + } + } + + StyledText { + id: name + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: icon.bottom + anchors.topMargin: Appearance.spacing.small + anchors.margins: Appearance.padding.normal + + horizontalAlignment: Text.AlignHCenter + elide: item.GridView.isCurrentItem ? Text.ElideNone : Text.ElideRight + wrapMode: item.GridView.isCurrentItem ? Text.WrapAtWordBoundaryOrAnywhere : Text.NoWrap + + Component.onCompleted: text = item.modelData.name + } + + Behavior on implicitHeight { + Anim {} + } + } + + add: Transition { + Anim { + properties: "opacity,scale" + from: 0 + to: 1 + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + + remove: Transition { + Anim { + property: "opacity" + to: 0 + } + Anim { + property: "scale" + to: 0.5 + } + } + + displaced: Transition { + Anim { + properties: "opacity,scale" + to: 1 + easing.bezierCurve: Appearance.anim.curves.standardDecel + } + Anim { + properties: "x,y" + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + } + + CurrentItem { + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: Appearance.padding.small + + currentItem: view.currentItem + } +} diff --git a/.config/quickshell/caelestia/components/filedialog/HeaderBar.qml b/.config/quickshell/caelestia/components/filedialog/HeaderBar.qml new file mode 100644 index 0000000..c9a3feb --- /dev/null +++ b/.config/quickshell/caelestia/components/filedialog/HeaderBar.qml @@ -0,0 +1,139 @@ +pragma ComponentBehavior: Bound + +import ".." +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +StyledRect { + id: root + + required property var dialog + + implicitWidth: inner.implicitWidth + Appearance.padding.normal * 2 + implicitHeight: inner.implicitHeight + Appearance.padding.normal * 2 + + color: Colours.tPalette.m3surfaceContainer + + RowLayout { + id: inner + + anchors.fill: parent + anchors.margins: Appearance.padding.normal + spacing: Appearance.spacing.small + + Item { + implicitWidth: implicitHeight + implicitHeight: upIcon.implicitHeight + Appearance.padding.small * 2 + + StateLayer { + radius: Appearance.rounding.small + disabled: root.dialog.cwd.length === 1 + + function onClicked(): void { + root.dialog.cwd.pop(); + } + } + + MaterialIcon { + id: upIcon + + anchors.centerIn: parent + text: "drive_folder_upload" + color: root.dialog.cwd.length === 1 ? Colours.palette.m3outline : Colours.palette.m3onSurface + grade: 200 + } + } + + StyledRect { + Layout.fillWidth: true + + radius: Appearance.rounding.small + color: Colours.tPalette.m3surfaceContainerHigh + + implicitHeight: pathComponents.implicitHeight + pathComponents.anchors.margins * 2 + + RowLayout { + id: pathComponents + + anchors.fill: parent + anchors.margins: Appearance.padding.small / 2 + anchors.leftMargin: 0 + + spacing: Appearance.spacing.small + + Repeater { + model: root.dialog.cwd + + RowLayout { + id: folder + + required property string modelData + required property int index + + spacing: 0 + + Loader { + Layout.rightMargin: Appearance.spacing.small + active: folder.index > 0 + sourceComponent: StyledText { + text: "/" + color: Colours.palette.m3onSurfaceVariant + font.bold: true + } + } + + Item { + implicitWidth: homeIcon.implicitWidth + (homeIcon.active ? Appearance.padding.small : 0) + folderName.implicitWidth + Appearance.padding.normal * 2 + implicitHeight: folderName.implicitHeight + Appearance.padding.small * 2 + + Loader { + anchors.fill: parent + active: folder.index < root.dialog.cwd.length - 1 + sourceComponent: StateLayer { + radius: Appearance.rounding.small + + function onClicked(): void { + root.dialog.cwd = root.dialog.cwd.slice(0, folder.index + 1); + } + } + } + + Loader { + id: homeIcon + + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Appearance.padding.normal + + active: folder.index === 0 && folder.modelData === "Home" + sourceComponent: MaterialIcon { + text: "home" + color: root.dialog.cwd.length === 1 ? Colours.palette.m3onSurface : Colours.palette.m3onSurfaceVariant + fill: 1 + } + } + + StyledText { + id: folderName + + anchors.left: homeIcon.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: homeIcon.active ? Appearance.padding.small : 0 + + text: folder.modelData + color: folder.index < root.dialog.cwd.length - 1 ? Colours.palette.m3onSurfaceVariant : Colours.palette.m3onSurface + font.bold: true + } + } + } + } + + Item { + Layout.fillWidth: true + } + } + } + } +} diff --git a/.config/quickshell/caelestia/components/filedialog/Sidebar.qml b/.config/quickshell/caelestia/components/filedialog/Sidebar.qml new file mode 100644 index 0000000..b55d7b3 --- /dev/null +++ b/.config/quickshell/caelestia/components/filedialog/Sidebar.qml @@ -0,0 +1,113 @@ +pragma ComponentBehavior: Bound + +import ".." +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +StyledRect { + id: root + + required property var dialog + + implicitWidth: Sizes.sidebarWidth + implicitHeight: inner.implicitHeight + Appearance.padding.normal * 2 + + color: Colours.tPalette.m3surfaceContainer + + ColumnLayout { + id: inner + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Appearance.padding.normal + spacing: Appearance.spacing.small / 2 + + StyledText { + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: Appearance.padding.small / 2 + Layout.bottomMargin: Appearance.spacing.normal + text: qsTr("Files") + color: Colours.palette.m3onSurface + font.pointSize: Appearance.font.size.larger + font.bold: true + } + + Repeater { + model: ["Home", "Downloads", "Desktop", "Documents", "Music", "Pictures", "Videos"] + + StyledRect { + id: place + + required property string modelData + readonly property bool selected: modelData === root.dialog.cwd[root.dialog.cwd.length - 1] + + Layout.fillWidth: true + implicitHeight: placeInner.implicitHeight + Appearance.padding.normal * 2 + + radius: Appearance.rounding.full + color: Qt.alpha(Colours.palette.m3secondaryContainer, selected ? 1 : 0) + + StateLayer { + color: place.selected ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface + + function onClicked(): void { + if (place.modelData === "Home") + root.dialog.cwd = ["Home"]; + else + root.dialog.cwd = ["Home", place.modelData]; + } + } + + RowLayout { + id: placeInner + + anchors.fill: parent + anchors.margins: Appearance.padding.normal + anchors.leftMargin: Appearance.padding.large + anchors.rightMargin: Appearance.padding.large + + spacing: Appearance.spacing.normal + + MaterialIcon { + text: { + const p = place.modelData; + if (p === "Home") + return "home"; + if (p === "Downloads") + return "file_download"; + if (p === "Desktop") + return "desktop_windows"; + if (p === "Documents") + return "description"; + if (p === "Music") + return "music_note"; + if (p === "Pictures") + return "image"; + if (p === "Videos") + return "video_library"; + return "folder"; + } + color: place.selected ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface + font.pointSize: Appearance.font.size.large + fill: place.selected ? 1 : 0 + + Behavior on fill { + Anim {} + } + } + + StyledText { + Layout.fillWidth: true + text: place.modelData + color: place.selected ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface + font.pointSize: Appearance.font.size.normal + elide: Text.ElideRight + } + } + } + } + } +} diff --git a/.config/quickshell/caelestia/components/filedialog/Sizes.qml b/.config/quickshell/caelestia/components/filedialog/Sizes.qml new file mode 100644 index 0000000..2ad31f9 --- /dev/null +++ b/.config/quickshell/caelestia/components/filedialog/Sizes.qml @@ -0,0 +1,8 @@ +pragma Singleton + +import Quickshell + +Singleton { + property int itemWidth: 103 + property int sidebarWidth: 200 +} diff --git a/.config/quickshell/caelestia/components/images/CachingIconImage.qml b/.config/quickshell/caelestia/components/images/CachingIconImage.qml new file mode 100644 index 0000000..1acc6a1 --- /dev/null +++ b/.config/quickshell/caelestia/components/images/CachingIconImage.qml @@ -0,0 +1,42 @@ +pragma ComponentBehavior: Bound + +import qs.utils +import Quickshell.Widgets +import QtQuick + +Item { + id: root + + readonly property int status: loader.item?.status ?? Image.Null + readonly property real actualSize: Math.min(width, height) + property real implicitSize + property url source + + implicitWidth: implicitSize + implicitHeight: implicitSize + + Loader { + id: loader + + anchors.fill: parent + sourceComponent: root.source ? root.source.toString().startsWith("image://icon/") ? iconImage : cachingImage : null + } + + Component { + id: cachingImage + + CachingImage { + path: Paths.toLocalFile(root.source) + fillMode: Image.PreserveAspectFit + } + } + + Component { + id: iconImage + + IconImage { + source: root.source + asynchronous: true + } + } +} diff --git a/.config/quickshell/caelestia/components/images/CachingImage.qml b/.config/quickshell/caelestia/components/images/CachingImage.qml new file mode 100644 index 0000000..e8f957a --- /dev/null +++ b/.config/quickshell/caelestia/components/images/CachingImage.qml @@ -0,0 +1,28 @@ +import qs.utils +import Caelestia.Internal +import Quickshell +import QtQuick + +Image { + id: root + + property alias path: manager.path + + asynchronous: true + fillMode: Image.PreserveAspectCrop + + Connections { + target: QsWindow.window + + function onDevicePixelRatioChanged(): void { + manager.updateSource(); + } + } + + CachingImageManager { + id: manager + + item: root + cacheDir: Qt.resolvedUrl(Paths.imagecache) + } +} diff --git a/.config/quickshell/caelestia/components/misc/CustomShortcut.qml b/.config/quickshell/caelestia/components/misc/CustomShortcut.qml new file mode 100644 index 0000000..aa35ed8 --- /dev/null +++ b/.config/quickshell/caelestia/components/misc/CustomShortcut.qml @@ -0,0 +1,5 @@ +import Quickshell.Hyprland + +GlobalShortcut { + appid: "caelestia" +} diff --git a/.config/quickshell/caelestia/components/misc/Ref.qml b/.config/quickshell/caelestia/components/misc/Ref.qml new file mode 100644 index 0000000..0a694a4 --- /dev/null +++ b/.config/quickshell/caelestia/components/misc/Ref.qml @@ -0,0 +1,8 @@ +import QtQuick + +QtObject { + required property var service + + Component.onCompleted: service.refCount++ + Component.onDestruction: service.refCount-- +} diff --git a/.config/quickshell/caelestia/components/widgets/ExtraIndicator.qml b/.config/quickshell/caelestia/components/widgets/ExtraIndicator.qml new file mode 100644 index 0000000..db73ea0 --- /dev/null +++ b/.config/quickshell/caelestia/components/widgets/ExtraIndicator.qml @@ -0,0 +1,51 @@ +import ".." +import "../effects" +import qs.services +import qs.config +import QtQuick + +StyledRect { + required property int extra + + anchors.right: parent.right + anchors.margins: Appearance.padding.normal + + color: Colours.palette.m3tertiary + radius: Appearance.rounding.small + + implicitWidth: count.implicitWidth + Appearance.padding.normal * 2 + implicitHeight: count.implicitHeight + Appearance.padding.small * 2 + + opacity: extra > 0 ? 1 : 0 + scale: extra > 0 ? 1 : 0.5 + + Elevation { + anchors.fill: parent + radius: parent.radius + opacity: parent.opacity + z: -1 + level: 2 + } + + StyledText { + id: count + + anchors.centerIn: parent + animate: parent.opacity > 0 + text: qsTr("+%1").arg(parent.extra) + color: Colours.palette.m3onTertiary + } + + Behavior on opacity { + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + } + } + + Behavior on scale { + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + } + } +} diff --git a/.config/quickshell/caelestia/config/Appearance.qml b/.config/quickshell/caelestia/config/Appearance.qml new file mode 100644 index 0000000..241c21a --- /dev/null +++ b/.config/quickshell/caelestia/config/Appearance.qml @@ -0,0 +1,14 @@ +pragma Singleton + +import Quickshell + +Singleton { + // Literally just here to shorten accessing stuff :woe: + // Also kinda so I can keep accessing it with `Appearance.xxx` instead of `Config.appearance.xxx` + readonly property AppearanceConfig.Rounding rounding: Config.appearance.rounding + readonly property AppearanceConfig.Spacing spacing: Config.appearance.spacing + readonly property AppearanceConfig.Padding padding: Config.appearance.padding + readonly property AppearanceConfig.FontStuff font: Config.appearance.font + readonly property AppearanceConfig.Anim anim: Config.appearance.anim + readonly property AppearanceConfig.Transparency transparency: Config.appearance.transparency +} diff --git a/.config/quickshell/caelestia/config/AppearanceConfig.qml b/.config/quickshell/caelestia/config/AppearanceConfig.qml new file mode 100644 index 0000000..3d590dc --- /dev/null +++ b/.config/quickshell/caelestia/config/AppearanceConfig.qml @@ -0,0 +1,94 @@ +import Quickshell.Io + +JsonObject { + property Rounding rounding: Rounding {} + property Spacing spacing: Spacing {} + property Padding padding: Padding {} + property FontStuff font: FontStuff {} + property Anim anim: Anim {} + property Transparency transparency: Transparency {} + + component Rounding: JsonObject { + property real scale: 1 + property int small: 12 * scale + property int normal: 17 * scale + property int large: 25 * scale + property int full: 1000 * scale + } + + component Spacing: JsonObject { + property real scale: 1 + property int small: 7 * scale + property int smaller: 10 * scale + property int normal: 12 * scale + property int larger: 15 * scale + property int large: 20 * scale + } + + component Padding: JsonObject { + property real scale: 1 + property int small: 5 * scale + property int smaller: 7 * scale + property int normal: 10 * scale + property int larger: 12 * scale + property int large: 15 * scale + } + + component FontFamily: JsonObject { + property string sans: "Rubik" + property string mono: "CaskaydiaCove NF" + property string material: "Material Symbols Rounded" + property string clock: "Rubik" + } + + component FontSize: JsonObject { + property real scale: 1 + property int small: 11 * scale + property int smaller: 12 * scale + property int normal: 13 * scale + property int larger: 15 * scale + property int large: 18 * scale + property int extraLarge: 28 * scale + } + + component FontStuff: JsonObject { + property FontFamily family: FontFamily {} + property FontSize size: FontSize {} + } + + component AnimCurves: JsonObject { + property list emphasized: [0.05, 0, 2 / 15, 0.06, 1 / 6, 0.4, 5 / 24, 0.82, 0.25, 1, 1, 1] + property list emphasizedAccel: [0.3, 0, 0.8, 0.15, 1, 1] + property list emphasizedDecel: [0.05, 0.7, 0.1, 1, 1, 1] + property list standard: [0.2, 0, 0, 1, 1, 1] + property list standardAccel: [0.3, 0, 1, 1, 1, 1] + property list standardDecel: [0, 0, 0, 1, 1, 1] + property list expressiveFastSpatial: [0.42, 1.67, 0.21, 0.9, 1, 1] + property list expressiveDefaultSpatial: [0.38, 1.21, 0.22, 1, 1, 1] + property list expressiveEffects: [0.34, 0.8, 0.34, 1, 1, 1] + } + + component AnimDurations: JsonObject { + property real scale: 1 + property int small: 200 * scale + property int normal: 400 * scale + property int large: 600 * scale + property int extraLarge: 1000 * scale + property int expressiveFastSpatial: 350 * scale + property int expressiveDefaultSpatial: 500 * scale + property int expressiveEffects: 200 * scale + } + + component Anim: JsonObject { + property real mediaGifSpeedAdjustment: 300 + property real sessionGifSpeed: 0.7 + property AnimCurves curves: AnimCurves {} + property AnimDurations durations: AnimDurations {} + } + + component Transparency: JsonObject { + property bool enabled: false + property real base: 0.85 + property real layers: 0.4 + } +} diff --git a/.config/quickshell/caelestia/config/BackgroundConfig.qml b/.config/quickshell/caelestia/config/BackgroundConfig.qml new file mode 100644 index 0000000..8383f52 --- /dev/null +++ b/.config/quickshell/caelestia/config/BackgroundConfig.qml @@ -0,0 +1,37 @@ +import Quickshell.Io + +JsonObject { + property bool enabled: true + property bool wallpaperEnabled: true + property DesktopClock desktopClock: DesktopClock {} + property Visualiser visualiser: Visualiser {} + + component DesktopClock: JsonObject { + property bool enabled: false + property real scale: 1.0 + property string position: "bottom-right" + property bool invertColors: false + property DesktopClockBackground background: DesktopClockBackground {} + property DesktopClockShadow shadow: DesktopClockShadow {} + } + + component DesktopClockBackground: JsonObject { + property bool enabled: false + property real opacity: 0.7 + property bool blur: true + } + + component DesktopClockShadow: JsonObject { + property bool enabled: true + property real opacity: 0.7 + property real blur: 0.4 + } + + component Visualiser: JsonObject { + property bool enabled: false + property bool autoHide: true + property bool blur: false + property real rounding: 1 + property real spacing: 1 + } +} diff --git a/.config/quickshell/caelestia/config/BarConfig.qml b/.config/quickshell/caelestia/config/BarConfig.qml new file mode 100644 index 0000000..36a5f78 --- /dev/null +++ b/.config/quickshell/caelestia/config/BarConfig.qml @@ -0,0 +1,118 @@ +import Quickshell.Io + +JsonObject { + property bool persistent: true + property bool showOnHover: true + property int dragThreshold: 20 + property ScrollActions scrollActions: ScrollActions {} + property Popouts popouts: Popouts {} + property Workspaces workspaces: Workspaces {} + property ActiveWindow activeWindow: ActiveWindow {} + property Tray tray: Tray {} + property Status status: Status {} + property Clock clock: Clock {} + property Sizes sizes: Sizes {} + property list excludedScreens: [] + + property list entries: [ + { + id: "logo", + enabled: true + }, + { + id: "workspaces", + enabled: true + }, + { + id: "spacer", + enabled: true + }, + { + id: "activeWindow", + enabled: true + }, + { + id: "spacer", + enabled: true + }, + { + id: "tray", + enabled: true + }, + { + id: "clock", + enabled: true + }, + { + id: "statusIcons", + enabled: true + }, + { + id: "power", + enabled: true + } + ] + + component ScrollActions: JsonObject { + property bool workspaces: true + property bool volume: true + property bool brightness: true + } + + component Popouts: JsonObject { + property bool activeWindow: true + property bool tray: true + property bool statusIcons: true + } + + component Workspaces: JsonObject { + property int shown: 5 + property bool activeIndicator: true + property bool occupiedBg: false + property bool showWindows: true + property bool showWindowsOnSpecialWorkspaces: showWindows + property bool activeTrail: false + property bool perMonitorWorkspaces: true + property string label: " " // if empty, will show workspace name's first letter + property string occupiedLabel: "󰮯" + property string activeLabel: "󰮯" + property string capitalisation: "preserve" // upper, lower, or preserve - relevant only if label is empty + property list specialWorkspaceIcons: [] + } + + component ActiveWindow: JsonObject { + property bool inverted: false + } + + component Tray: JsonObject { + property bool background: false + property bool recolour: false + property bool compact: false + property list iconSubs: [] + property list hiddenIcons: [] + } + + component Status: JsonObject { + property bool showAudio: false + property bool showMicrophone: false + property bool showKbLayout: false + property bool showNetwork: true + property bool showWifi: true + property bool showBluetooth: true + property bool showBattery: true + property bool showLockStatus: true + } + + component Clock: JsonObject { + property bool showIcon: true + } + + component Sizes: JsonObject { + property int innerWidth: 40 + property int windowPreviewSize: 400 + property int trayMenuWidth: 300 + property int batteryWidth: 250 + property int networkWidth: 320 + property int kbLayoutWidth: 320 + } +} diff --git a/.config/quickshell/caelestia/config/BorderConfig.qml b/.config/quickshell/caelestia/config/BorderConfig.qml new file mode 100644 index 0000000..b15811f --- /dev/null +++ b/.config/quickshell/caelestia/config/BorderConfig.qml @@ -0,0 +1,6 @@ +import Quickshell.Io + +JsonObject { + property int thickness: Appearance.padding.normal + property int rounding: Appearance.rounding.large +} diff --git a/.config/quickshell/caelestia/config/Config.qml b/.config/quickshell/caelestia/config/Config.qml new file mode 100644 index 0000000..8c01014 --- /dev/null +++ b/.config/quickshell/caelestia/config/Config.qml @@ -0,0 +1,522 @@ +pragma Singleton + +import qs.utils +import Caelestia +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + property alias appearance: adapter.appearance + property alias general: adapter.general + property alias background: adapter.background + property alias bar: adapter.bar + property alias border: adapter.border + property alias dashboard: adapter.dashboard + property alias controlCenter: adapter.controlCenter + property alias launcher: adapter.launcher + property alias notifs: adapter.notifs + property alias osd: adapter.osd + property alias session: adapter.session + property alias winfo: adapter.winfo + property alias lock: adapter.lock + property alias utilities: adapter.utilities + property alias sidebar: adapter.sidebar + property alias services: adapter.services + property alias paths: adapter.paths + + // Public save function - call this to persist config changes + function save(): void { + saveTimer.restart(); + recentlySaved = true; + recentSaveCooldown.restart(); + } + + property bool recentlySaved: false + + ElapsedTimer { + id: timer + } + + Timer { + id: saveTimer + + interval: 500 + onTriggered: { + timer.restart(); + try { + // Parse current config to preserve structure and comments if possible + let config = {}; + try { + config = JSON.parse(fileView.text()); + } catch (e) { + // If parsing fails, start with empty object + config = {}; + } + + // Update config with current values + config = serializeConfig(); + + // Save to file with pretty printing + fileView.setText(JSON.stringify(config, null, 2)); + } catch (e) { + Toaster.toast(qsTr("Failed to serialize config"), e.message, "settings_alert", Toast.Error); + } + } + } + + Timer { + id: recentSaveCooldown + + interval: 2000 + onTriggered: { + recentlySaved = false; + } + } + + // Helper function to serialize the config object + function serializeConfig(): var { + return { + appearance: serializeAppearance(), + general: serializeGeneral(), + background: serializeBackground(), + bar: serializeBar(), + border: serializeBorder(), + dashboard: serializeDashboard(), + controlCenter: serializeControlCenter(), + launcher: serializeLauncher(), + notifs: serializeNotifs(), + osd: serializeOsd(), + session: serializeSession(), + winfo: serializeWinfo(), + lock: serializeLock(), + utilities: serializeUtilities(), + sidebar: serializeSidebar(), + services: serializeServices(), + paths: serializePaths() + }; + } + + function serializeAppearance(): var { + return { + rounding: { + scale: appearance.rounding.scale + }, + spacing: { + scale: appearance.spacing.scale + }, + padding: { + scale: appearance.padding.scale + }, + font: { + family: { + sans: appearance.font.family.sans, + mono: appearance.font.family.mono, + material: appearance.font.family.material, + clock: appearance.font.family.clock + }, + size: { + scale: appearance.font.size.scale + } + }, + anim: { + mediaGifSpeedAdjustment: 300, + sessionGifSpeed: 0.7, + durations: { + scale: appearance.anim.durations.scale + } + }, + transparency: { + enabled: appearance.transparency.enabled, + base: appearance.transparency.base, + layers: appearance.transparency.layers + } + }; + } + + function serializeGeneral(): var { + return { + logo: general.logo, + apps: { + terminal: general.apps.terminal, + audio: general.apps.audio, + playback: general.apps.playback, + explorer: general.apps.explorer + }, + idle: { + lockBeforeSleep: general.idle.lockBeforeSleep, + inhibitWhenAudio: general.idle.inhibitWhenAudio, + timeouts: general.idle.timeouts + }, + battery: { + warnLevels: general.battery.warnLevels, + criticalLevel: general.battery.criticalLevel + } + }; + } + + function serializeBackground(): var { + return { + enabled: background.enabled, + wallpaperEnabled: background.wallpaperEnabled, + desktopClock: { + enabled: background.desktopClock.enabled, + scale: background.desktopClock.scale, + position: background.desktopClock.position, + invertColors: background.desktopClock.invertColors, + background: { + enabled: background.desktopClock.background.enabled, + opacity: background.desktopClock.background.opacity, + blur: background.desktopClock.background.blur + }, + shadow: { + enabled: background.desktopClock.shadow.enabled, + opacity: background.desktopClock.shadow.opacity, + blur: background.desktopClock.shadow.blur + } + }, + visualiser: { + enabled: background.visualiser.enabled, + autoHide: background.visualiser.autoHide, + blur: background.visualiser.blur, + rounding: background.visualiser.rounding, + spacing: background.visualiser.spacing + } + }; + } + + function serializeBar(): var { + return { + persistent: bar.persistent, + showOnHover: bar.showOnHover, + dragThreshold: bar.dragThreshold, + scrollActions: { + workspaces: bar.scrollActions.workspaces, + volume: bar.scrollActions.volume, + brightness: bar.scrollActions.brightness + }, + popouts: { + activeWindow: bar.popouts.activeWindow, + tray: bar.popouts.tray, + statusIcons: bar.popouts.statusIcons + }, + workspaces: { + shown: bar.workspaces.shown, + activeIndicator: bar.workspaces.activeIndicator, + occupiedBg: bar.workspaces.occupiedBg, + showWindows: bar.workspaces.showWindows, + showWindowsOnSpecialWorkspaces: bar.workspaces.showWindowsOnSpecialWorkspaces, + activeTrail: bar.workspaces.activeTrail, + perMonitorWorkspaces: bar.workspaces.perMonitorWorkspaces, + label: bar.workspaces.label, + occupiedLabel: bar.workspaces.occupiedLabel, + activeLabel: bar.workspaces.activeLabel, + capitalisation: bar.workspaces.capitalisation, + specialWorkspaceIcons: bar.workspaces.specialWorkspaceIcons + }, + tray: { + background: bar.tray.background, + recolour: bar.tray.recolour, + compact: bar.tray.compact, + iconSubs: bar.tray.iconSubs + }, + status: { + showAudio: bar.status.showAudio, + showMicrophone: bar.status.showMicrophone, + showKbLayout: bar.status.showKbLayout, + showNetwork: bar.status.showNetwork, + showWifi: bar.status.showWifi, + showBluetooth: bar.status.showBluetooth, + showBattery: bar.status.showBattery, + showLockStatus: bar.status.showLockStatus + }, + clock: { + showIcon: bar.clock.showIcon + }, + sizes: { + innerWidth: bar.sizes.innerWidth, + windowPreviewSize: bar.sizes.windowPreviewSize, + trayMenuWidth: bar.sizes.trayMenuWidth, + batteryWidth: bar.sizes.batteryWidth, + networkWidth: bar.sizes.networkWidth + }, + entries: bar.entries, + excludedScreens: bar.excludedScreens + }; + } + + function serializeBorder(): var { + return { + thickness: border.thickness, + rounding: border.rounding + }; + } + + function serializeDashboard(): var { + return { + enabled: dashboard.enabled, + showOnHover: dashboard.showOnHover, + updateInterval: dashboard.updateInterval, + dragThreshold: dashboard.dragThreshold, + performance: { + showBattery: dashboard.performance.showBattery, + showGpu: dashboard.performance.showGpu, + showCpu: dashboard.performance.showCpu, + showMemory: dashboard.performance.showMemory, + showStorage: dashboard.performance.showStorage, + showNetwork: dashboard.performance.showNetwork + }, + sizes: { + tabIndicatorHeight: dashboard.sizes.tabIndicatorHeight, + tabIndicatorSpacing: dashboard.sizes.tabIndicatorSpacing, + infoWidth: dashboard.sizes.infoWidth, + infoIconSize: dashboard.sizes.infoIconSize, + dateTimeWidth: dashboard.sizes.dateTimeWidth, + mediaWidth: dashboard.sizes.mediaWidth, + mediaProgressSweep: dashboard.sizes.mediaProgressSweep, + mediaProgressThickness: dashboard.sizes.mediaProgressThickness, + resourceProgessThickness: dashboard.sizes.resourceProgessThickness, + weatherWidth: dashboard.sizes.weatherWidth, + mediaCoverArtSize: dashboard.sizes.mediaCoverArtSize, + mediaVisualiserSize: dashboard.sizes.mediaVisualiserSize, + resourceSize: dashboard.sizes.resourceSize + } + }; + } + + function serializeControlCenter(): var { + return { + sizes: { + heightMult: controlCenter.sizes.heightMult, + ratio: controlCenter.sizes.ratio + } + }; + } + + function serializeLauncher(): var { + return { + enabled: launcher.enabled, + showOnHover: launcher.showOnHover, + maxShown: launcher.maxShown, + maxWallpapers: launcher.maxWallpapers, + specialPrefix: launcher.specialPrefix, + actionPrefix: launcher.actionPrefix, + enableDangerousActions: launcher.enableDangerousActions, + dragThreshold: launcher.dragThreshold, + vimKeybinds: launcher.vimKeybinds, + favouriteApps: launcher.favouriteApps, + hiddenApps: launcher.hiddenApps, + useFuzzy: { + apps: launcher.useFuzzy.apps, + actions: launcher.useFuzzy.actions, + schemes: launcher.useFuzzy.schemes, + variants: launcher.useFuzzy.variants, + wallpapers: launcher.useFuzzy.wallpapers + }, + sizes: { + itemWidth: launcher.sizes.itemWidth, + itemHeight: launcher.sizes.itemHeight, + wallpaperWidth: launcher.sizes.wallpaperWidth, + wallpaperHeight: launcher.sizes.wallpaperHeight + }, + actions: launcher.actions + }; + } + + function serializeNotifs(): var { + return { + expire: notifs.expire, + defaultExpireTimeout: notifs.defaultExpireTimeout, + clearThreshold: notifs.clearThreshold, + expandThreshold: notifs.expandThreshold, + actionOnClick: notifs.actionOnClick, + groupPreviewNum: notifs.groupPreviewNum, + sizes: { + width: notifs.sizes.width, + image: notifs.sizes.image, + badge: notifs.sizes.badge + } + }; + } + + function serializeOsd(): var { + return { + enabled: osd.enabled, + hideDelay: osd.hideDelay, + enableBrightness: osd.enableBrightness, + enableMicrophone: osd.enableMicrophone, + sizes: { + sliderWidth: osd.sizes.sliderWidth, + sliderHeight: osd.sizes.sliderHeight + } + }; + } + + function serializeSession(): var { + return { + enabled: session.enabled, + dragThreshold: session.dragThreshold, + vimKeybinds: session.vimKeybinds, + icons: { + logout: session.icons.logout, + shutdown: session.icons.shutdown, + hibernate: session.icons.hibernate, + reboot: session.icons.reboot + }, + commands: { + logout: session.commands.logout, + shutdown: session.commands.shutdown, + hibernate: session.commands.hibernate, + reboot: session.commands.reboot + }, + sizes: { + button: session.sizes.button + } + }; + } + + function serializeWinfo(): var { + return { + sizes: { + heightMult: winfo.sizes.heightMult, + detailsWidth: winfo.sizes.detailsWidth + } + }; + } + + function serializeLock(): var { + return { + recolourLogo: lock.recolourLogo, + enableFprint: lock.enableFprint, + maxFprintTries: lock.maxFprintTries, + sizes: { + heightMult: lock.sizes.heightMult, + ratio: lock.sizes.ratio, + centerWidth: lock.sizes.centerWidth + } + }; + } + + function serializeUtilities(): var { + return { + enabled: utilities.enabled, + maxToasts: utilities.maxToasts, + sizes: { + width: utilities.sizes.width, + toastWidth: utilities.sizes.toastWidth + }, + toasts: { + configLoaded: utilities.toasts.configLoaded, + chargingChanged: utilities.toasts.chargingChanged, + gameModeChanged: utilities.toasts.gameModeChanged, + dndChanged: utilities.toasts.dndChanged, + audioOutputChanged: utilities.toasts.audioOutputChanged, + audioInputChanged: utilities.toasts.audioInputChanged, + capsLockChanged: utilities.toasts.capsLockChanged, + numLockChanged: utilities.toasts.numLockChanged, + kbLayoutChanged: utilities.toasts.kbLayoutChanged, + vpnChanged: utilities.toasts.vpnChanged, + nowPlaying: utilities.toasts.nowPlaying + }, + vpn: { + enabled: utilities.vpn.enabled, + provider: utilities.vpn.provider + } + }; + } + + function serializeSidebar(): var { + return { + enabled: sidebar.enabled, + dragThreshold: sidebar.dragThreshold, + sizes: { + width: sidebar.sizes.width + } + }; + } + + function serializeServices(): var { + return { + weatherLocation: services.weatherLocation, + useFahrenheit: services.useFahrenheit, + useFahrenheitPerformance: services.useFahrenheitPerformance, + useTwelveHourClock: services.useTwelveHourClock, + gpuType: services.gpuType, + visualiserBars: services.visualiserBars, + audioIncrement: services.audioIncrement, + brightnessIncrement: services.brightnessIncrement, + maxVolume: services.maxVolume, + smartScheme: services.smartScheme, + defaultPlayer: services.defaultPlayer, + playerAliases: services.playerAliases + }; + } + + function serializePaths(): var { + return { + wallpaperDir: paths.wallpaperDir, + sessionGif: paths.sessionGif, + mediaGif: paths.mediaGif + }; + } + + FileView { + id: fileView + + path: `${Paths.config}/shell.json` + watchChanges: true + onFileChanged: { + // Prevent reload loop - don't reload if we just saved + if (!recentlySaved) { + timer.restart(); + reload(); + } else { + // Self-initiated save - reload without toast + reload(); + } + } + onLoaded: { + try { + JSON.parse(text()); + const elapsed = timer.elapsedMs(); + // Only show toast for external changes (not our own saves) and when elapsed time is meaningful + if (adapter.utilities.toasts.configLoaded && !recentlySaved && elapsed > 0) { + Toaster.toast(qsTr("Config loaded"), qsTr("Config loaded in %1ms").arg(elapsed), "rule_settings"); + } else if (adapter.utilities.toasts.configLoaded && recentlySaved && elapsed > 0) { + Toaster.toast(qsTr("Config saved"), qsTr("Config reloaded in %1ms").arg(elapsed), "rule_settings"); + } + } catch (e) { + Toaster.toast(qsTr("Failed to load config"), e.message, "settings_alert", Toast.Error); + } + } + onLoadFailed: err => { + if (err !== FileViewError.FileNotFound) + Toaster.toast(qsTr("Failed to read config file"), FileViewError.toString(err), "settings_alert", Toast.Warning); + } + onSaveFailed: err => Toaster.toast(qsTr("Failed to save config"), FileViewError.toString(err), "settings_alert", Toast.Error) + + JsonAdapter { + id: adapter + + property AppearanceConfig appearance: AppearanceConfig {} + property GeneralConfig general: GeneralConfig {} + property BackgroundConfig background: BackgroundConfig {} + property BarConfig bar: BarConfig {} + property BorderConfig border: BorderConfig {} + property DashboardConfig dashboard: DashboardConfig {} + property ControlCenterConfig controlCenter: ControlCenterConfig {} + property LauncherConfig launcher: LauncherConfig {} + property NotifsConfig notifs: NotifsConfig {} + property OsdConfig osd: OsdConfig {} + property SessionConfig session: SessionConfig {} + property WInfoConfig winfo: WInfoConfig {} + property LockConfig lock: LockConfig {} + property UtilitiesConfig utilities: UtilitiesConfig {} + property SidebarConfig sidebar: SidebarConfig {} + property ServiceConfig services: ServiceConfig {} + property UserPaths paths: UserPaths {} + } + } +} diff --git a/.config/quickshell/caelestia/config/ControlCenterConfig.qml b/.config/quickshell/caelestia/config/ControlCenterConfig.qml new file mode 100644 index 0000000..a588949 --- /dev/null +++ b/.config/quickshell/caelestia/config/ControlCenterConfig.qml @@ -0,0 +1,10 @@ +import Quickshell.Io + +JsonObject { + property Sizes sizes: Sizes {} + + component Sizes: JsonObject { + property real heightMult: 0.7 + property real ratio: 16 / 9 + } +} diff --git a/.config/quickshell/caelestia/config/DashboardConfig.qml b/.config/quickshell/caelestia/config/DashboardConfig.qml new file mode 100644 index 0000000..e089550 --- /dev/null +++ b/.config/quickshell/caelestia/config/DashboardConfig.qml @@ -0,0 +1,36 @@ +import Quickshell.Io + +JsonObject { + property bool enabled: true + property bool showOnHover: true + property int mediaUpdateInterval: 500 + property int resourceUpdateInterval: 1000 + property int dragThreshold: 50 + property Sizes sizes: Sizes {} + property Performance performance: Performance {} + + component Performance: JsonObject { + property bool showBattery: true + property bool showGpu: true + property bool showCpu: true + property bool showMemory: true + property bool showStorage: true + property bool showNetwork: true + } + + component Sizes: JsonObject { + readonly property int tabIndicatorHeight: 3 + readonly property int tabIndicatorSpacing: 5 + readonly property int infoWidth: 200 + readonly property int infoIconSize: 25 + readonly property int dateTimeWidth: 110 + readonly property int mediaWidth: 200 + readonly property int mediaProgressSweep: 180 + readonly property int mediaProgressThickness: 8 + readonly property int resourceProgessThickness: 10 + readonly property int weatherWidth: 250 + readonly property int mediaCoverArtSize: 150 + readonly property int mediaVisualiserSize: 80 + readonly property int resourceSize: 200 + } +} diff --git a/.config/quickshell/caelestia/config/GeneralConfig.qml b/.config/quickshell/caelestia/config/GeneralConfig.qml new file mode 100644 index 0000000..52ef0de --- /dev/null +++ b/.config/quickshell/caelestia/config/GeneralConfig.qml @@ -0,0 +1,60 @@ +import Quickshell.Io + +JsonObject { + property string logo: "" + property Apps apps: Apps {} + property Idle idle: Idle {} + property Battery battery: Battery {} + + component Apps: JsonObject { + property list terminal: ["foot"] + property list audio: ["pavucontrol"] + property list playback: ["mpv"] + property list explorer: ["thunar"] + } + + component Idle: JsonObject { + property bool lockBeforeSleep: true + property bool inhibitWhenAudio: true + property list timeouts: [ + { + timeout: 180, + idleAction: "lock" + }, + { + timeout: 300, + idleAction: "dpms off", + returnAction: "dpms on" + }, + { + timeout: 600, + idleAction: ["systemctl", "suspend-then-hibernate"] + } + ] + } + + component Battery: JsonObject { + property list warnLevels: [ + { + level: 20, + title: qsTr("Low battery"), + message: qsTr("You might want to plug in a charger"), + icon: "battery_android_frame_2" + }, + { + level: 10, + title: qsTr("Did you see the previous message?"), + message: qsTr("You should probably plug in a charger now"), + icon: "battery_android_frame_1" + }, + { + level: 5, + title: qsTr("Critical battery level"), + message: qsTr("PLUG THE CHARGER RIGHT NOW!!"), + icon: "battery_android_alert", + critical: true + }, + ] + property int criticalLevel: 3 + } +} diff --git a/.config/quickshell/caelestia/config/LauncherConfig.qml b/.config/quickshell/caelestia/config/LauncherConfig.qml new file mode 100644 index 0000000..d9e3a73 --- /dev/null +++ b/.config/quickshell/caelestia/config/LauncherConfig.qml @@ -0,0 +1,147 @@ +import Quickshell.Io + +JsonObject { + property bool enabled: true + property bool showOnHover: false + property int maxShown: 7 + property int maxWallpapers: 9 // Warning: even numbers look bad + property string specialPrefix: "@" + property string actionPrefix: ">" + property bool enableDangerousActions: false // Allow actions that can cause losing data, like shutdown, reboot and logout + property int dragThreshold: 50 + property bool vimKeybinds: false + property list favouriteApps: [] + property list hiddenApps: [] + property UseFuzzy useFuzzy: UseFuzzy {} + property Sizes sizes: Sizes {} + + component UseFuzzy: JsonObject { + property bool apps: false + property bool actions: false + property bool schemes: false + property bool variants: false + property bool wallpapers: false + } + + component Sizes: JsonObject { + property int itemWidth: 600 + property int itemHeight: 57 + property int wallpaperWidth: 280 + property int wallpaperHeight: 200 + } + + property list actions: [ + { + name: "Calculator", + icon: "calculate", + description: "Do simple math equations (powered by Qalc)", + command: ["autocomplete", "calc"], + enabled: true, + dangerous: false + }, + { + name: "Scheme", + icon: "palette", + description: "Change the current colour scheme", + command: ["autocomplete", "scheme"], + enabled: true, + dangerous: false + }, + { + name: "Wallpaper", + icon: "image", + description: "Change the current wallpaper", + command: ["autocomplete", "wallpaper"], + enabled: true, + dangerous: false + }, + { + name: "Variant", + icon: "colors", + description: "Change the current scheme variant", + command: ["autocomplete", "variant"], + enabled: true, + dangerous: false + }, + { + name: "Transparency", + icon: "opacity", + description: "Change shell transparency", + command: ["autocomplete", "transparency"], + enabled: false, + dangerous: false + }, + { + name: "Random", + icon: "casino", + description: "Switch to a random wallpaper", + command: ["caelestia", "wallpaper", "-r"], + enabled: true, + dangerous: false + }, + { + name: "Light", + icon: "light_mode", + description: "Change the scheme to light mode", + command: ["setMode", "light"], + enabled: true, + dangerous: false + }, + { + name: "Dark", + icon: "dark_mode", + description: "Change the scheme to dark mode", + command: ["setMode", "dark"], + enabled: true, + dangerous: false + }, + { + name: "Shutdown", + icon: "power_settings_new", + description: "Shutdown the system", + command: ["systemctl", "poweroff"], + enabled: true, + dangerous: true + }, + { + name: "Reboot", + icon: "cached", + description: "Reboot the system", + command: ["systemctl", "reboot"], + enabled: true, + dangerous: true + }, + { + name: "Logout", + icon: "exit_to_app", + description: "Log out of the current session", + command: ["loginctl", "terminate-user", ""], + enabled: true, + dangerous: true + }, + { + name: "Lock", + icon: "lock", + description: "Lock the current session", + command: ["loginctl", "lock-session"], + enabled: true, + dangerous: false + }, + { + name: "Sleep", + icon: "bedtime", + description: "Suspend then hibernate", + command: ["systemctl", "suspend-then-hibernate"], + enabled: true, + dangerous: false + }, + { + name: "Settings", + icon: "settings", + description: "Configure the shell", + command: ["caelestia", "shell", "controlCenter", "open"], + enabled: true, + dangerous: false + } + ] +} diff --git a/.config/quickshell/caelestia/config/LockConfig.qml b/.config/quickshell/caelestia/config/LockConfig.qml new file mode 100644 index 0000000..2af4e2c --- /dev/null +++ b/.config/quickshell/caelestia/config/LockConfig.qml @@ -0,0 +1,14 @@ +import Quickshell.Io + +JsonObject { + property bool recolourLogo: false + property bool enableFprint: true + property int maxFprintTries: 3 + property Sizes sizes: Sizes {} + + component Sizes: JsonObject { + property real heightMult: 0.7 + property real ratio: 16 / 9 + property int centerWidth: 600 + } +} diff --git a/.config/quickshell/caelestia/config/NotifsConfig.qml b/.config/quickshell/caelestia/config/NotifsConfig.qml new file mode 100644 index 0000000..fa2db49 --- /dev/null +++ b/.config/quickshell/caelestia/config/NotifsConfig.qml @@ -0,0 +1,18 @@ +import Quickshell.Io + +JsonObject { + property bool expire: true + property int defaultExpireTimeout: 5000 + property real clearThreshold: 0.3 + property int expandThreshold: 20 + property bool actionOnClick: false + property int groupPreviewNum: 3 + property bool openExpanded: false // Show the notifichation in expanded state when opening + property Sizes sizes: Sizes {} + + component Sizes: JsonObject { + property int width: 400 + property int image: 41 + property int badge: 20 + } +} diff --git a/.config/quickshell/caelestia/config/OsdConfig.qml b/.config/quickshell/caelestia/config/OsdConfig.qml new file mode 100644 index 0000000..543fc41 --- /dev/null +++ b/.config/quickshell/caelestia/config/OsdConfig.qml @@ -0,0 +1,14 @@ +import Quickshell.Io + +JsonObject { + property bool enabled: true + property int hideDelay: 2000 + property bool enableBrightness: true + property bool enableMicrophone: false + property Sizes sizes: Sizes {} + + component Sizes: JsonObject { + property int sliderWidth: 30 + property int sliderHeight: 150 + } +} diff --git a/.config/quickshell/caelestia/config/ServiceConfig.qml b/.config/quickshell/caelestia/config/ServiceConfig.qml new file mode 100644 index 0000000..29600cc --- /dev/null +++ b/.config/quickshell/caelestia/config/ServiceConfig.qml @@ -0,0 +1,22 @@ +import Quickshell.Io +import QtQuick + +JsonObject { + property string weatherLocation: "" // A lat,long pair or empty for autodetection, e.g. "37.8267,-122.4233" + property bool useFahrenheit: [Locale.ImperialUSSystem, Locale.ImperialSystem].includes(Qt.locale().measurementSystem) + property bool useFahrenheitPerformance: [Locale.ImperialUSSystem, Locale.ImperialSystem].includes(Qt.locale().measurementSystem) + property bool useTwelveHourClock: Qt.locale().timeFormat(Locale.ShortFormat).toLowerCase().includes("a") + property string gpuType: "" + property int visualiserBars: 45 + property real audioIncrement: 0.1 + property real brightnessIncrement: 0.1 + property real maxVolume: 1.0 + property bool smartScheme: true + property string defaultPlayer: "Spotify" + property list playerAliases: [ + { + "from": "com.github.th_ch.youtube_music", + "to": "YT Music" + } + ] +} diff --git a/.config/quickshell/caelestia/config/SessionConfig.qml b/.config/quickshell/caelestia/config/SessionConfig.qml new file mode 100644 index 0000000..414f821 --- /dev/null +++ b/.config/quickshell/caelestia/config/SessionConfig.qml @@ -0,0 +1,29 @@ +import Quickshell.Io + +JsonObject { + property bool enabled: true + property int dragThreshold: 30 + property bool vimKeybinds: false + property Icons icons: Icons {} + property Commands commands: Commands {} + + property Sizes sizes: Sizes {} + + component Icons: JsonObject { + property string logout: "logout" + property string shutdown: "power_settings_new" + property string hibernate: "downloading" + property string reboot: "cached" + } + + component Commands: JsonObject { + property list logout: ["loginctl", "terminate-user", ""] + property list shutdown: ["systemctl", "poweroff"] + property list hibernate: ["systemctl", "hibernate"] + property list reboot: ["systemctl", "reboot"] + } + + component Sizes: JsonObject { + property int button: 80 + } +} diff --git a/.config/quickshell/caelestia/config/SidebarConfig.qml b/.config/quickshell/caelestia/config/SidebarConfig.qml new file mode 100644 index 0000000..a871562 --- /dev/null +++ b/.config/quickshell/caelestia/config/SidebarConfig.qml @@ -0,0 +1,11 @@ +import Quickshell.Io + +JsonObject { + property bool enabled: true + property int dragThreshold: 80 + property Sizes sizes: Sizes {} + + component Sizes: JsonObject { + property int width: 430 + } +} diff --git a/.config/quickshell/caelestia/config/UserPaths.qml b/.config/quickshell/caelestia/config/UserPaths.qml new file mode 100644 index 0000000..f8de267 --- /dev/null +++ b/.config/quickshell/caelestia/config/UserPaths.qml @@ -0,0 +1,8 @@ +import qs.utils +import Quickshell.Io + +JsonObject { + property string wallpaperDir: `${Paths.pictures}/Wallpapers` + property string sessionGif: "root:/assets/kurukuru.gif" + property string mediaGif: "root:/assets/bongocat.gif" +} diff --git a/.config/quickshell/caelestia/config/UtilitiesConfig.qml b/.config/quickshell/caelestia/config/UtilitiesConfig.qml new file mode 100644 index 0000000..cf46446 --- /dev/null +++ b/.config/quickshell/caelestia/config/UtilitiesConfig.qml @@ -0,0 +1,35 @@ +import Quickshell.Io + +JsonObject { + property bool enabled: true + property int maxToasts: 4 + + property Sizes sizes: Sizes {} + property Toasts toasts: Toasts {} + property Vpn vpn: Vpn {} + + component Sizes: JsonObject { + property int width: 430 + property int toastWidth: 430 + } + + component Toasts: JsonObject { + property bool configLoaded: true + property bool chargingChanged: true + property bool gameModeChanged: true + property bool dndChanged: true + property bool audioOutputChanged: true + property bool audioInputChanged: true + property bool capsLockChanged: true + property bool numLockChanged: true + property bool kbLayoutChanged: true + property bool kbLimit: true + property bool vpnChanged: true + property bool nowPlaying: false + } + + component Vpn: JsonObject { + property bool enabled: false + property list provider: ["netbird"] + } +} diff --git a/.config/quickshell/caelestia/config/WInfoConfig.qml b/.config/quickshell/caelestia/config/WInfoConfig.qml new file mode 100644 index 0000000..5025780 --- /dev/null +++ b/.config/quickshell/caelestia/config/WInfoConfig.qml @@ -0,0 +1,10 @@ +import Quickshell.Io + +JsonObject { + property Sizes sizes: Sizes {} + + component Sizes: JsonObject { + property real heightMult: 0.7 + property real detailsWidth: 500 + } +} diff --git a/.config/quickshell/caelestia/extras/CMakeLists.txt b/.config/quickshell/caelestia/extras/CMakeLists.txt new file mode 100644 index 0000000..52fe17c --- /dev/null +++ b/.config/quickshell/caelestia/extras/CMakeLists.txt @@ -0,0 +1,9 @@ +# Version +add_executable(version version.cpp) +target_compile_definitions(version PRIVATE + PROJECT_NAME="${PROJECT_NAME}" + VERSION="${VERSION}" + GIT_REVISION="${GIT_REVISION}" + DISTRIBUTOR="${DISTRIBUTOR}" +) +install(TARGETS version DESTINATION ${INSTALL_LIBDIR}) diff --git a/.config/quickshell/caelestia/extras/version.cpp b/.config/quickshell/caelestia/extras/version.cpp new file mode 100644 index 0000000..e1a0cf3 --- /dev/null +++ b/.config/quickshell/caelestia/extras/version.cpp @@ -0,0 +1,26 @@ +#include + +int main(int argc, char* argv[]) { + if (argc > 1) { + std::string arg = argv[1]; + + if (arg == "-t" || arg == "--terse") { + std::cout << PROJECT_NAME << std::endl; + std::cout << VERSION << std::endl; + std::cout << GIT_REVISION << std::endl; + std::cout << DISTRIBUTOR << std::endl; + } else if (arg == "-s" || arg == "--short") { + std::cout << PROJECT_NAME << " " << VERSION << ", revision " << GIT_REVISION << ", distrubuted by: " << DISTRIBUTOR << std::endl; + } else { + std::cout << "Usage: " << argv[0] << " [-t | --terse] [-s | --short]" << std::endl; + return arg != "-h" && arg != "--help"; + } + } else { + std::cout << "Project: " << PROJECT_NAME << std::endl; + std::cout << "Version: " << VERSION << std::endl; + std::cout << "Git revision: " << GIT_REVISION << std::endl; + std::cout << "Distributor: " << DISTRIBUTOR << std::endl; + } + + return 0; +} diff --git a/.config/quickshell/caelestia/flake.lock b/.config/quickshell/caelestia/flake.lock new file mode 100644 index 0000000..9f1b2ea --- /dev/null +++ b/.config/quickshell/caelestia/flake.lock @@ -0,0 +1,70 @@ +{ + "nodes": { + "caelestia-cli": { + "inputs": { + "caelestia-shell": [], + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1772764582, + "narHash": "sha256-hSwjmpXHFqzSXrndVekA0IheKrbC7wi0IbfZTYwlmXw=", + "owner": "caelestia-dots", + "repo": "cli", + "rev": "4bcd42f482d038b98145b0b03388244b68b7d35d", + "type": "github" + }, + "original": { + "owner": "caelestia-dots", + "repo": "cli", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1772773019, + "narHash": "sha256-E1bxHxNKfDoQUuvriG71+f+s/NT0qWkImXsYZNFFfCs=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "aca4d95fce4914b3892661bcb80b8087293536c6", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "quickshell": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1772925576, + "narHash": "sha256-mMoiXABDtkSJxCYDrkhJ/TrrJf5M46oUfIlJvv2gkZ0=", + "ref": "refs/heads/master", + "rev": "15a84097653593dd15fad59a56befc2b7bdc270d", + "revCount": 750, + "type": "git", + "url": "https://git.outfoxxed.me/outfoxxed/quickshell" + }, + "original": { + "type": "git", + "url": "https://git.outfoxxed.me/outfoxxed/quickshell" + } + }, + "root": { + "inputs": { + "caelestia-cli": "caelestia-cli", + "nixpkgs": "nixpkgs", + "quickshell": "quickshell" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/.config/quickshell/caelestia/flake.nix b/.config/quickshell/caelestia/flake.nix new file mode 100644 index 0000000..5c88411 --- /dev/null +++ b/.config/quickshell/caelestia/flake.nix @@ -0,0 +1,60 @@ +{ + description = "Desktop shell for Caelestia dots"; + + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; + + quickshell = { + url = "git+https://git.outfoxxed.me/outfoxxed/quickshell"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + + caelestia-cli = { + url = "github:caelestia-dots/cli"; + inputs.nixpkgs.follows = "nixpkgs"; + inputs.caelestia-shell.follows = ""; + }; + }; + + outputs = { + self, + nixpkgs, + ... + } @ inputs: let + forAllSystems = fn: + nixpkgs.lib.genAttrs nixpkgs.lib.platforms.linux ( + system: fn nixpkgs.legacyPackages.${system} + ); + in { + formatter = forAllSystems (pkgs: pkgs.alejandra); + + packages = forAllSystems (pkgs: rec { + caelestia-shell = pkgs.callPackage ./nix { + rev = self.rev or self.dirtyRev; + stdenv = pkgs.clangStdenv; + quickshell = inputs.quickshell.packages.${pkgs.stdenv.hostPlatform.system}.default.override { + withX11 = false; + withI3 = false; + }; + app2unit = pkgs.callPackage ./nix/app2unit.nix {inherit pkgs;}; + caelestia-cli = inputs.caelestia-cli.packages.${pkgs.stdenv.hostPlatform.system}.default; + }; + with-cli = caelestia-shell.override {withCli = true;}; + debug = caelestia-shell.override {debug = true;}; + default = caelestia-shell; + }); + + devShells = forAllSystems (pkgs: { + default = let + shell = self.packages.${pkgs.stdenv.hostPlatform.system}.caelestia-shell; + in + pkgs.mkShell.override {stdenv = shell.stdenv;} { + inputsFrom = [shell shell.plugin shell.extras]; + packages = with pkgs; [clazy material-symbols rubik nerd-fonts.caskaydia-cove]; + CAELESTIA_XKB_RULES_PATH = "${pkgs.xkeyboard-config}/share/xkeyboard-config-2/rules/base.lst"; + }; + }); + + homeManagerModules.default = import ./nix/hm-module.nix self; + }; +} diff --git a/.config/quickshell/caelestia/modules/BatteryMonitor.qml b/.config/quickshell/caelestia/modules/BatteryMonitor.qml new file mode 100644 index 0000000..d24cff2 --- /dev/null +++ b/.config/quickshell/caelestia/modules/BatteryMonitor.qml @@ -0,0 +1,56 @@ +import qs.config +import Caelestia +import Quickshell +import Quickshell.Services.UPower +import QtQuick + +Scope { + id: root + + readonly property list warnLevels: [...Config.general.battery.warnLevels].sort((a, b) => b.level - a.level) + + Connections { + target: UPower + + function onOnBatteryChanged(): void { + if (UPower.onBattery) { + if (Config.utilities.toasts.chargingChanged) + Toaster.toast(qsTr("Charger unplugged"), qsTr("Battery is discharging"), "power_off"); + } else { + if (Config.utilities.toasts.chargingChanged) + Toaster.toast(qsTr("Charger plugged in"), qsTr("Battery is charging"), "power"); + for (const level of root.warnLevels) + level.warned = false; + } + } + } + + Connections { + target: UPower.displayDevice + + function onPercentageChanged(): void { + if (!UPower.onBattery) + return; + + const p = UPower.displayDevice.percentage * 100; + for (const level of root.warnLevels) { + if (p <= level.level && !level.warned) { + level.warned = true; + Toaster.toast(level.title ?? qsTr("Battery warning"), level.message ?? qsTr("Battery level is low"), level.icon ?? "battery_android_alert", level.critical ? Toast.Error : Toast.Warning); + } + } + + if (!hibernateTimer.running && p <= Config.general.battery.criticalLevel) { + Toaster.toast(qsTr("Hibernating in 5 seconds"), qsTr("Hibernating to prevent data loss"), "battery_android_alert", Toast.Error); + hibernateTimer.start(); + } + } + } + + Timer { + id: hibernateTimer + + interval: 5000 + onTriggered: Quickshell.execDetached(["systemctl", "hibernate"]) + } +} diff --git a/.config/quickshell/caelestia/modules/IdleMonitors.qml b/.config/quickshell/caelestia/modules/IdleMonitors.qml new file mode 100644 index 0000000..b7ce058 --- /dev/null +++ b/.config/quickshell/caelestia/modules/IdleMonitors.qml @@ -0,0 +1,51 @@ +pragma ComponentBehavior: Bound + +import "lock" +import qs.config +import qs.services +import Caelestia.Internal +import Quickshell +import Quickshell.Wayland + +Scope { + id: root + + required property Lock lock + readonly property bool enabled: !Config.general.idle.inhibitWhenAudio || !Players.list.some(p => p.isPlaying) + + function handleIdleAction(action: var): void { + if (!action) + return; + + if (action === "lock") + lock.lock.locked = true; + else if (action === "unlock") + lock.lock.locked = false; + else if (typeof action === "string") + Hypr.dispatch(action); + else + Quickshell.execDetached(action); + } + + LogindManager { + onAboutToSleep: { + if (Config.general.idle.lockBeforeSleep) + root.lock.lock.locked = true; + } + onLockRequested: root.lock.lock.locked = true + onUnlockRequested: root.lock.lock.unlock() + } + + Variants { + model: Config.general.idle.timeouts + + IdleMonitor { + required property var modelData + + enabled: root.enabled && (modelData.enabled ?? true) + timeout: modelData.timeout + respectInhibitors: modelData.respectInhibitors ?? true + onIsIdleChanged: root.handleIdleAction(isIdle ? modelData.idleAction : modelData.returnAction) + } + } +} diff --git a/.config/quickshell/caelestia/modules/Shortcuts.qml b/.config/quickshell/caelestia/modules/Shortcuts.qml new file mode 100644 index 0000000..3bf20a4 --- /dev/null +++ b/.config/quickshell/caelestia/modules/Shortcuts.qml @@ -0,0 +1,142 @@ +import qs.components.misc +import qs.modules.controlcenter +import qs.services +import Caelestia +import Quickshell +import Quickshell.Io + +Scope { + id: root + + property bool launcherInterrupted + readonly property bool hasFullscreen: Hypr.focusedWorkspace?.toplevels.values.some(t => t.lastIpcObject.fullscreen === 2) ?? false + + CustomShortcut { + name: "controlCenter" + description: "Open control center" + onPressed: WindowFactory.create() + } + + CustomShortcut { + name: "showall" + description: "Toggle launcher, dashboard and osd" + onPressed: { + if (root.hasFullscreen) + return; + const v = Visibilities.getForActive(); + v.launcher = v.dashboard = v.osd = v.utilities = !(v.launcher || v.dashboard || v.osd || v.utilities); + } + } + + CustomShortcut { + name: "dashboard" + description: "Toggle dashboard" + onPressed: { + if (root.hasFullscreen) + return; + const visibilities = Visibilities.getForActive(); + visibilities.dashboard = !visibilities.dashboard; + } + } + + CustomShortcut { + name: "session" + description: "Toggle session menu" + onPressed: { + if (root.hasFullscreen) + return; + const visibilities = Visibilities.getForActive(); + visibilities.session = !visibilities.session; + } + } + + CustomShortcut { + name: "launcher" + description: "Toggle launcher" + onPressed: root.launcherInterrupted = false + onReleased: { + if (!root.launcherInterrupted && !root.hasFullscreen) { + const visibilities = Visibilities.getForActive(); + visibilities.launcher = !visibilities.launcher; + } + root.launcherInterrupted = false; + } + } + + CustomShortcut { + name: "launcherInterrupt" + description: "Interrupt launcher keybind" + onPressed: root.launcherInterrupted = true + } + + + CustomShortcut { + name: "sidebar" + description: "Toggle sidebar" + onPressed: { + if (root.hasFullscreen) + return; + const visibilities = Visibilities.getForActive(); + visibilities.sidebar = !visibilities.sidebar; + } + } + + CustomShortcut { + name: "utilities" + description: "Toggle utilities" + onPressed: { + if (root.hasFullscreen) + return; + const visibilities = Visibilities.getForActive(); + visibilities.utilities = !visibilities.utilities; + } + } + + IpcHandler { + target: "drawers" + + function toggle(drawer: string): void { + if (list().split("\n").includes(drawer)) { + if (root.hasFullscreen && ["launcher", "session", "dashboard"].includes(drawer)) + return; + const visibilities = Visibilities.getForActive(); + visibilities[drawer] = !visibilities[drawer]; + } else { + console.warn(`[IPC] Drawer "${drawer}" does not exist`); + } + } + + function list(): string { + const visibilities = Visibilities.getForActive(); + return Object.keys(visibilities).filter(k => typeof visibilities[k] === "boolean").join("\n"); + } + } + + IpcHandler { + target: "controlCenter" + + function open(): void { + WindowFactory.create(); + } + } + + IpcHandler { + target: "toaster" + + function info(title: string, message: string, icon: string): void { + Toaster.toast(title, message, icon, Toast.Info); + } + + function success(title: string, message: string, icon: string): void { + Toaster.toast(title, message, icon, Toast.Success); + } + + function warn(title: string, message: string, icon: string): void { + Toaster.toast(title, message, icon, Toast.Warning); + } + + function error(title: string, message: string, icon: string): void { + Toaster.toast(title, message, icon, Toast.Error); + } + } +} diff --git a/.config/quickshell/caelestia/modules/areapicker/AreaPicker.qml b/.config/quickshell/caelestia/modules/areapicker/AreaPicker.qml new file mode 100644 index 0000000..0d8b2fe --- /dev/null +++ b/.config/quickshell/caelestia/modules/areapicker/AreaPicker.qml @@ -0,0 +1,124 @@ +pragma ComponentBehavior: Bound + +import qs.components.containers +import qs.components.misc +import Quickshell +import Quickshell.Wayland +import Quickshell.Io + +Scope { + LazyLoader { + id: root + + property bool freeze + property bool closing + property bool clipboardOnly + + Variants { + model: Quickshell.screens + + StyledWindow { + id: win + + required property ShellScreen modelData + + screen: modelData + name: "area-picker" + WlrLayershell.exclusionMode: ExclusionMode.Ignore + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: root.closing ? WlrKeyboardFocus.None : WlrKeyboardFocus.Exclusive + mask: root.closing ? empty : null + + anchors.top: true + anchors.bottom: true + anchors.left: true + anchors.right: true + + Region { + id: empty + } + + Picker { + loader: root + screen: win.modelData + } + } + } + } + + IpcHandler { + target: "picker" + + function open(): void { + root.freeze = false; + root.closing = false; + root.clipboardOnly = false; + root.activeAsync = true; + } + + function openFreeze(): void { + root.freeze = true; + root.closing = false; + root.clipboardOnly = false; + root.activeAsync = true; + } + + function openClip(): void { + root.freeze = false; + root.closing = false; + root.clipboardOnly = true; + root.activeAsync = true; + } + + function openFreezeClip(): void { + root.freeze = true; + root.closing = false; + root.clipboardOnly = true; + root.activeAsync = true; + } + } + + CustomShortcut { + name: "screenshot" + description: "Open screenshot tool" + onPressed: { + root.freeze = false; + root.closing = false; + root.clipboardOnly = false; + root.activeAsync = true; + } + } + + CustomShortcut { + name: "screenshotFreeze" + description: "Open screenshot tool (freeze mode)" + onPressed: { + root.freeze = true; + root.closing = false; + root.clipboardOnly = false; + root.activeAsync = true; + } + } + + CustomShortcut { + name: "screenshotClip" + description: "Open screenshot tool (clipboard)" + onPressed: { + root.freeze = false; + root.closing = false; + root.clipboardOnly = true; + root.activeAsync = true; + } + } + + CustomShortcut { + name: "screenshotFreezeClip" + description: "Open screenshot tool (freeze mode, clipboard)" + onPressed: { + root.freeze = true; + root.closing = false; + root.clipboardOnly = true; + root.activeAsync = true; + } + } +} diff --git a/.config/quickshell/caelestia/modules/areapicker/Picker.qml b/.config/quickshell/caelestia/modules/areapicker/Picker.qml new file mode 100644 index 0000000..08f46df --- /dev/null +++ b/.config/quickshell/caelestia/modules/areapicker/Picker.qml @@ -0,0 +1,300 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.services +import qs.config +import Caelestia +import Quickshell +import Quickshell.Wayland +import QtQuick +import QtQuick.Effects + +MouseArea { + id: root + + required property LazyLoader loader + required property ShellScreen screen + + property bool onClient + + property real realBorderWidth: onClient ? (Hypr.options["general:border_size"] ?? 1) : 2 + property real realRounding: onClient ? (Hypr.options["decoration:rounding"] ?? 0) : 0 + + property real ssx + property real ssy + + property real sx: 0 + property real sy: 0 + property real ex: screen.width + property real ey: screen.height + + property real rsx: Math.min(sx, ex) + property real rsy: Math.min(sy, ey) + property real sw: Math.abs(sx - ex) + property real sh: Math.abs(sy - ey) + + property list clients: { + const mon = Hypr.monitorFor(screen); + if (!mon) + return []; + + const special = mon.lastIpcObject.specialWorkspace; + const wsId = special.name ? special.id : mon.activeWorkspace.id; + + return Hypr.toplevels.values.filter(c => c.workspace?.id === wsId).sort((a, b) => { + // Pinned first, then fullscreen, then floating, then any other + const ac = a.lastIpcObject; + const bc = b.lastIpcObject; + return (bc.pinned - ac.pinned) || ((bc.fullscreen !== 0) - (ac.fullscreen !== 0)) || (bc.floating - ac.floating); + }); + } + + function checkClientRects(x: real, y: real): void { + for (const client of clients) { + if (!client) + continue; + + let { + at: [cx, cy], + size: [cw, ch] + } = client.lastIpcObject; + cx -= screen.x; + cy -= screen.y; + if (cx <= x && cy <= y && cx + cw >= x && cy + ch >= y) { + onClient = true; + sx = cx; + sy = cy; + ex = cx + cw; + ey = cy + ch; + break; + } + } + } + + function save(): void { + const tmpfile = Qt.resolvedUrl(`/tmp/caelestia-picker-${Quickshell.processId}-${Date.now()}.png`); + CUtils.saveItem(screencopy, tmpfile, Qt.rect(Math.ceil(rsx), Math.ceil(rsy), Math.floor(sw), Math.floor(sh)), path => { + if (root.loader.clipboardOnly) { + Quickshell.execDetached(["sh", "-c", "wl-copy --type image/png < " + path]); + Quickshell.execDetached(["notify-send", "-a", "caelestia-cli", "-i", path, "Screenshot taken", "Screenshot copied to clipboard"]); + } else { + Quickshell.execDetached(["swappy", "-f", path]); + } + closeAnim.start(); + }); + } + + onClientsChanged: checkClientRects(mouseX, mouseY) + + anchors.fill: parent + opacity: 0 + hoverEnabled: true + cursorShape: Qt.CrossCursor + + Component.onCompleted: { + Hypr.extras.refreshOptions(); + + // Break binding if frozen + if (loader.freeze) + clients = clients; + + opacity = 1; + + const c = clients[0]; + if (c) { + const cx = c.lastIpcObject.at[0] - screen.x; + const cy = c.lastIpcObject.at[1] - screen.y; + onClient = true; + sx = cx; + sy = cy; + ex = cx + c.lastIpcObject.size[0]; + ey = cy + c.lastIpcObject.size[1]; + } else { + sx = screen.width / 2 - 100; + sy = screen.height / 2 - 100; + ex = screen.width / 2 + 100; + ey = screen.height / 2 + 100; + } + } + + onPressed: event => { + ssx = event.x; + ssy = event.y; + } + + onReleased: { + if (closeAnim.running) + return; + + if (root.loader.freeze) { + save(); + } else { + overlay.visible = border.visible = false; + screencopy.visible = false; + screencopy.active = true; + } + } + + onPositionChanged: event => { + const x = event.x; + const y = event.y; + + if (pressed) { + onClient = false; + sx = ssx; + sy = ssy; + ex = x; + ey = y; + } else { + checkClientRects(x, y); + } + } + + focus: true + Keys.onEscapePressed: closeAnim.start() + + SequentialAnimation { + id: closeAnim + + PropertyAction { + target: root.loader + property: "closing" + value: true + } + ParallelAnimation { + Anim { + target: root + property: "opacity" + to: 0 + duration: Appearance.anim.durations.large + } + ExAnim { + target: root + properties: "rsx,rsy" + to: 0 + } + ExAnim { + target: root + property: "sw" + to: root.screen.width + } + ExAnim { + target: root + property: "sh" + to: root.screen.height + } + } + PropertyAction { + target: root.loader + property: "activeAsync" + value: false + } + } + + Loader { + id: screencopy + + anchors.fill: parent + + active: root.loader.freeze + + sourceComponent: ScreencopyView { + captureSource: root.screen + + onHasContentChanged: { + if (hasContent && !root.loader.freeze) { + overlay.visible = border.visible = true; + root.save(); + } + } + } + } + + StyledRect { + id: overlay + + anchors.fill: parent + color: Colours.palette.m3secondaryContainer + opacity: 0.3 + + layer.enabled: true + layer.effect: MultiEffect { + maskSource: selectionWrapper + maskEnabled: true + maskInverted: true + maskSpreadAtMin: 1 + maskThresholdMin: 0.5 + } + } + + Item { + id: selectionWrapper + + anchors.fill: parent + layer.enabled: true + visible: false + + Rectangle { + id: selectionRect + + radius: root.realRounding + x: root.rsx + y: root.rsy + implicitWidth: root.sw + implicitHeight: root.sh + } + } + + Rectangle { + id: border + + color: "transparent" + radius: root.realRounding > 0 ? root.realRounding + root.realBorderWidth : 0 + border.width: root.realBorderWidth + border.color: Colours.palette.m3primary + + x: selectionRect.x - root.realBorderWidth + y: selectionRect.y - root.realBorderWidth + implicitWidth: selectionRect.implicitWidth + root.realBorderWidth * 2 + implicitHeight: selectionRect.implicitHeight + root.realBorderWidth * 2 + + Behavior on border.color { + CAnim {} + } + } + + Behavior on opacity { + Anim { + duration: Appearance.anim.durations.large + } + } + + Behavior on rsx { + enabled: !root.pressed + + ExAnim {} + } + + Behavior on rsy { + enabled: !root.pressed + + ExAnim {} + } + + Behavior on sw { + enabled: !root.pressed + + ExAnim {} + } + + Behavior on sh { + enabled: !root.pressed + + ExAnim {} + } + + component ExAnim: Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } +} diff --git a/.config/quickshell/caelestia/modules/background/Background.qml b/.config/quickshell/caelestia/modules/background/Background.qml new file mode 100644 index 0000000..682da62 --- /dev/null +++ b/.config/quickshell/caelestia/modules/background/Background.qml @@ -0,0 +1,153 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.components.containers +import qs.services +import qs.config +import Quickshell +import Quickshell.Wayland +import QtQuick + +Loader { + active: Config.background.enabled + + sourceComponent: Variants { + model: Quickshell.screens + + StyledWindow { + id: win + + required property ShellScreen modelData + + screen: modelData + name: "background" + WlrLayershell.exclusionMode: ExclusionMode.Ignore + WlrLayershell.layer: Config.background.wallpaperEnabled ? WlrLayer.Background : WlrLayer.Bottom + color: Config.background.wallpaperEnabled ? "black" : "transparent" + surfaceFormat.opaque: false + + anchors.top: true + anchors.bottom: true + anchors.left: true + anchors.right: true + + Item { + id: behindClock + + anchors.fill: parent + + Loader { + id: wallpaper + + anchors.fill: parent + active: Config.background.wallpaperEnabled + + sourceComponent: Wallpaper {} + } + + Visualiser { + anchors.fill: parent + screen: win.modelData + wallpaper: wallpaper + } + } + + Loader { + id: clockLoader + active: Config.background.desktopClock.enabled + + anchors.margins: Appearance.padding.large * 2 + anchors.leftMargin: Appearance.padding.large * 2 + Config.bar.sizes.innerWidth + Math.max(Appearance.padding.smaller, Config.border.thickness) + + state: Config.background.desktopClock.position + states: [ + State { + name: "top-left" + AnchorChanges { + target: clockLoader + anchors.top: parent.top + anchors.left: parent.left + } + }, + State { + name: "top-center" + AnchorChanges { + target: clockLoader + anchors.top: parent.top + anchors.horizontalCenter: parent.horizontalCenter + } + }, + State { + name: "top-right" + AnchorChanges { + target: clockLoader + anchors.top: parent.top + anchors.right: parent.right + } + }, + State { + name: "middle-left" + AnchorChanges { + target: clockLoader + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + } + }, + State { + name: "middle-center" + AnchorChanges { + target: clockLoader + anchors.verticalCenter: parent.verticalCenter + anchors.horizontalCenter: parent.horizontalCenter + } + }, + State { + name: "middle-right" + AnchorChanges { + target: clockLoader + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + } + }, + State { + name: "bottom-left" + AnchorChanges { + target: clockLoader + anchors.bottom: parent.bottom + anchors.left: parent.left + } + }, + State { + name: "bottom-center" + AnchorChanges { + target: clockLoader + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + } + }, + State { + name: "bottom-right" + AnchorChanges { + target: clockLoader + anchors.bottom: parent.bottom + anchors.right: parent.right + } + } + ] + + transitions: Transition { + AnchorAnimation { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + + sourceComponent: DesktopClock { + wallpaper: behindClock + absX: clockLoader.x + absY: clockLoader.y + } + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/background/DesktopClock.qml b/.config/quickshell/caelestia/modules/background/DesktopClock.qml new file mode 100644 index 0000000..f9a06a2 --- /dev/null +++ b/.config/quickshell/caelestia/modules/background/DesktopClock.qml @@ -0,0 +1,169 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts +import QtQuick.Effects + +Item { + id: root + + required property Item wallpaper + required property real absX + required property real absY + + property real scale: Config.background.desktopClock.scale + readonly property bool bgEnabled: Config.background.desktopClock.background.enabled + readonly property bool blurEnabled: bgEnabled && Config.background.desktopClock.background.blur && !GameMode.enabled + readonly property bool invertColors: Config.background.desktopClock.invertColors + readonly property bool useLightSet: Colours.light ? !invertColors : invertColors + readonly property color safePrimary: useLightSet ? Colours.palette.m3primaryContainer : Colours.palette.m3primary + readonly property color safeSecondary: useLightSet ? Colours.palette.m3secondaryContainer : Colours.palette.m3secondary + readonly property color safeTertiary: useLightSet ? Colours.palette.m3tertiaryContainer : Colours.palette.m3tertiary + + implicitWidth: layout.implicitWidth + (Appearance.padding.large * 4 * root.scale) + implicitHeight: layout.implicitHeight + (Appearance.padding.large * 2 * root.scale) + + Item { + id: clockContainer + + anchors.fill: parent + + layer.enabled: Config.background.desktopClock.shadow.enabled + layer.effect: MultiEffect { + shadowEnabled: true + shadowColor: Colours.palette.m3shadow + shadowOpacity: Config.background.desktopClock.shadow.opacity + shadowBlur: Config.background.desktopClock.shadow.blur + } + + Loader { + anchors.fill: parent + active: root.blurEnabled + + sourceComponent: MultiEffect { + source: ShaderEffectSource { + sourceItem: root.wallpaper + sourceRect: Qt.rect(root.absX, root.absY, root.width, root.height) + } + maskSource: backgroundPlate + maskEnabled: true + blurEnabled: true + blur: 1 + blurMax: 64 + autoPaddingEnabled: false + } + } + + StyledRect { + id: backgroundPlate + + visible: root.bgEnabled + anchors.fill: parent + radius: Appearance.rounding.large * root.scale + opacity: Config.background.desktopClock.background.opacity + color: Colours.palette.m3surface + + layer.enabled: root.blurEnabled + } + + RowLayout { + id: layout + + anchors.centerIn: parent + spacing: Appearance.spacing.larger * root.scale + + RowLayout { + spacing: Appearance.spacing.small + + StyledText { + text: Time.hourStr + font.pointSize: Appearance.font.size.extraLarge * 3 * root.scale + font.weight: Font.Bold + color: root.safePrimary + } + + StyledText { + text: ":" + font.pointSize: Appearance.font.size.extraLarge * 3 * root.scale + color: root.safeTertiary + opacity: 0.8 + Layout.topMargin: -Appearance.padding.large * 1.5 * root.scale + } + + StyledText { + text: Time.minuteStr + font.pointSize: Appearance.font.size.extraLarge * 3 * root.scale + font.weight: Font.Bold + color: root.safeSecondary + } + + Loader { + Layout.alignment: Qt.AlignTop + Layout.topMargin: Appearance.padding.large * 1.4 * root.scale + + active: Config.services.useTwelveHourClock + visible: active + + sourceComponent: StyledText { + text: Time.amPmStr + font.pointSize: Appearance.font.size.large * root.scale + color: root.safeSecondary + } + } + } + + StyledRect { + Layout.fillHeight: true + Layout.preferredWidth: 4 * root.scale + Layout.topMargin: Appearance.spacing.larger * root.scale + Layout.bottomMargin: Appearance.spacing.larger * root.scale + radius: Appearance.rounding.full + color: root.safePrimary + opacity: 0.8 + } + + ColumnLayout { + spacing: 0 + + StyledText { + text: Time.format("MMMM").toUpperCase() + font.pointSize: Appearance.font.size.large * root.scale + font.letterSpacing: 4 + font.weight: Font.Bold + color: root.safeSecondary + } + + StyledText { + text: Time.format("dd") + font.pointSize: Appearance.font.size.extraLarge * root.scale + font.letterSpacing: 2 + font.weight: Font.Medium + color: root.safePrimary + } + + StyledText { + text: Time.format("dddd") + font.pointSize: Appearance.font.size.larger * root.scale + font.letterSpacing: 2 + color: root.safeSecondary + } + } + } + } + + Behavior on scale { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + + Behavior on implicitWidth { + Anim { + duration: Appearance.anim.durations.small + } + } +} diff --git a/.config/quickshell/caelestia/modules/background/Visualiser.qml b/.config/quickshell/caelestia/modules/background/Visualiser.qml new file mode 100644 index 0000000..35a086b --- /dev/null +++ b/.config/quickshell/caelestia/modules/background/Visualiser.qml @@ -0,0 +1,151 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.services +import qs.config +import Caelestia.Services +import Quickshell +import Quickshell.Widgets +import QtQuick +import QtQuick.Effects + +Item { + id: root + + required property ShellScreen screen + required property Item wallpaper + + readonly property bool shouldBeActive: Config.background.visualiser.enabled && (!Config.background.visualiser.autoHide || (Hypr.monitorFor(screen)?.activeWorkspace?.toplevels?.values.every(t => t.lastIpcObject?.floating) ?? true)) + property real offset: shouldBeActive ? 0 : screen.height * 0.2 + + opacity: shouldBeActive ? 1 : 0 + + Loader { + anchors.fill: parent + active: root.opacity > 0 && Config.background.visualiser.blur + + sourceComponent: MultiEffect { + source: root.wallpaper + maskSource: wrapper + maskEnabled: true + blurEnabled: true + blur: 1 + blurMax: 32 + autoPaddingEnabled: false + } + } + + Item { + id: wrapper + + anchors.fill: parent + layer.enabled: true + + Loader { + anchors.fill: parent + anchors.topMargin: root.offset + anchors.bottomMargin: -root.offset + + active: root.opacity > 0 + + sourceComponent: Item { + ServiceRef { + service: Audio.cava + } + + Item { + id: content + + anchors.fill: parent + anchors.margins: Config.border.thickness + anchors.leftMargin: Visibilities.bars.get(root.screen).exclusiveZone + Appearance.spacing.small * Config.background.visualiser.spacing + + Side { + content: content + } + Side { + content: content + isRight: true + } + + Behavior on anchors.leftMargin { + Anim {} + } + } + } + } + } + + Behavior on offset { + Anim {} + } + + Behavior on opacity { + Anim {} + } + + component Side: Repeater { + id: side + + required property Item content + property bool isRight + + model: Config.services.visualiserBars + + ClippingRectangle { + id: bar + + required property int modelData + property real value: Math.max(0, Math.min(1, Audio.cava.values[side.isRight ? modelData : side.count - modelData - 1])) + + clip: true + + x: modelData * ((side.content.width * 0.4) / Config.services.visualiserBars) + (side.isRight ? side.content.width * 0.6 : 0) + implicitWidth: (side.content.width * 0.4) / Config.services.visualiserBars - Appearance.spacing.small * Config.background.visualiser.spacing + + y: side.content.height - height + implicitHeight: bar.value * side.content.height * 0.4 + + color: "transparent" + topLeftRadius: Appearance.rounding.small * Config.background.visualiser.rounding + topRightRadius: Appearance.rounding.small * Config.background.visualiser.rounding + + Rectangle { + topLeftRadius: parent.topLeftRadius + topRightRadius: parent.topRightRadius + + gradient: Gradient { + orientation: Gradient.Vertical + + GradientStop { + position: 0 + color: Qt.alpha(Colours.palette.m3primary, 0.7) + + Behavior on color { + CAnim {} + } + } + GradientStop { + position: 1 + color: Qt.alpha(Colours.palette.m3inversePrimary, 0.7) + + Behavior on color { + CAnim {} + } + } + } + + anchors.left: parent.left + anchors.right: parent.right + y: parent.height - height + implicitHeight: side.content.height * 0.4 + } + + Behavior on value { + Anim { + duration: Appearance.anim.durations.small + } + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/background/Wallpaper.qml b/.config/quickshell/caelestia/modules/background/Wallpaper.qml new file mode 100644 index 0000000..39a48fc --- /dev/null +++ b/.config/quickshell/caelestia/modules/background/Wallpaper.qml @@ -0,0 +1,145 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.components.images +import qs.components.filedialog +import qs.services +import qs.config +import qs.utils +import QtQuick + +Item { + id: root + + property string source: Wallpapers.current + property Image current: one + + onSourceChanged: { + if (!source) + current = null; + else if (current === one) + two.update(); + else + one.update(); + } + + Component.onCompleted: { + if (source) + Qt.callLater(() => one.update()); + } + + Loader { + anchors.fill: parent + + active: !root.source + + sourceComponent: StyledRect { + color: Colours.palette.m3surfaceContainer + + Row { + anchors.centerIn: parent + spacing: Appearance.spacing.large + + MaterialIcon { + text: "sentiment_stressed" + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.extraLarge * 5 + } + + Column { + anchors.verticalCenter: parent.verticalCenter + spacing: Appearance.spacing.small + + StyledText { + text: qsTr("Wallpaper missing?") + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.extraLarge * 2 + font.bold: true + } + + StyledRect { + implicitWidth: selectWallText.implicitWidth + Appearance.padding.large * 2 + implicitHeight: selectWallText.implicitHeight + Appearance.padding.small * 2 + + radius: Appearance.rounding.full + color: Colours.palette.m3primary + + FileDialog { + id: dialog + + title: qsTr("Select a wallpaper") + filterLabel: qsTr("Image files") + filters: Images.validImageExtensions + onAccepted: path => Wallpapers.setWallpaper(path) + } + + StateLayer { + radius: parent.radius + color: Colours.palette.m3onPrimary + + function onClicked(): void { + dialog.open(); + } + } + + StyledText { + id: selectWallText + + anchors.centerIn: parent + + text: qsTr("Set it now!") + color: Colours.palette.m3onPrimary + font.pointSize: Appearance.font.size.large + } + } + } + } + } + } + + Img { + id: one + } + + Img { + id: two + } + + component Img: CachingImage { + id: img + + function update(): void { + if (path === root.source) + root.current = this; + else + path = root.source; + } + + anchors.fill: parent + + opacity: 0 + scale: Wallpapers.showPreview ? 1 : 0.8 + + onStatusChanged: { + if (status === Image.Ready) + root.current = this; + } + + states: State { + name: "visible" + when: root.current === img + + PropertyChanges { + img.opacity: 1 + img.scale: 1 + } + } + + transitions: Transition { + Anim { + target: img + properties: "opacity,scale" + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/bar/Bar.qml b/.config/quickshell/caelestia/modules/bar/Bar.qml new file mode 100644 index 0000000..cb384e3 --- /dev/null +++ b/.config/quickshell/caelestia/modules/bar/Bar.qml @@ -0,0 +1,205 @@ +pragma ComponentBehavior: Bound + +import qs.services +import qs.config +import "popouts" as BarPopouts +import "components" +import "components/workspaces" +import Quickshell +import QtQuick +import QtQuick.Layouts + +ColumnLayout { + id: root + + required property ShellScreen screen + required property PersistentProperties visibilities + required property BarPopouts.Wrapper popouts + readonly property int vPadding: Appearance.padding.large + + function closeTray(): void { + if (!Config.bar.tray.compact) + return; + + for (let i = 0; i < repeater.count; i++) { + const item = repeater.itemAt(i); + if (item?.enabled && item.id === "tray") { + item.item.expanded = false; + } + } + } + + function checkPopout(y: real): void { + const ch = childAt(width / 2, y) as WrappedLoader; + + if (ch?.id !== "tray") + closeTray(); + + if (!ch) { + popouts.hasCurrent = false; + return; + } + + const id = ch.id; + const top = ch.y; + const item = ch.item; + const itemHeight = item.implicitHeight; + + if (id === "statusIcons" && Config.bar.popouts.statusIcons) { + const items = item.items; + const icon = items.childAt(items.width / 2, mapToItem(items, 0, y).y); + if (icon) { + popouts.currentName = icon.name; + popouts.currentCenter = Qt.binding(() => icon.mapToItem(root, 0, icon.implicitHeight / 2).y); + popouts.hasCurrent = true; + } + } else if (id === "tray" && Config.bar.popouts.tray) { + if (!Config.bar.tray.compact || (item.expanded && !item.expandIcon.contains(mapToItem(item.expandIcon, item.implicitWidth / 2, y)))) { + const index = Math.floor(((y - top - item.padding * 2 + item.spacing) / item.layout.implicitHeight) * item.items.count); + const trayItem = item.items.itemAt(index); + if (trayItem) { + popouts.currentName = `traymenu${index}`; + popouts.currentCenter = Qt.binding(() => trayItem.mapToItem(root, 0, trayItem.implicitHeight / 2).y); + popouts.hasCurrent = true; + } else { + popouts.hasCurrent = false; + } + } else { + popouts.hasCurrent = false; + item.expanded = true; + } + } else if (id === "activeWindow" && Config.bar.popouts.activeWindow) { + popouts.currentName = id.toLowerCase(); + popouts.currentCenter = item.mapToItem(root, 0, itemHeight / 2).y; + popouts.hasCurrent = true; + } + } + + function handleWheel(y: real, angleDelta: point): void { + const ch = childAt(width / 2, y) as WrappedLoader; + if (ch?.id === "workspaces" && Config.bar.scrollActions.workspaces) { + // Workspace scroll + const mon = (Config.bar.workspaces.perMonitorWorkspaces ? Hypr.monitorFor(screen) : Hypr.focusedMonitor); + const specialWs = mon?.lastIpcObject.specialWorkspace.name; + if (specialWs?.length > 0) + Hypr.dispatch(`togglespecialworkspace ${specialWs.slice(8)}`); + else if (angleDelta.y < 0 || (Config.bar.workspaces.perMonitorWorkspaces ? mon.activeWorkspace?.id : Hypr.activeWsId) > 1) + Hypr.dispatch(`workspace r${angleDelta.y > 0 ? "-" : "+"}1`); + } else if (y < screen.height / 2 && Config.bar.scrollActions.volume) { + // Volume scroll on top half + if (angleDelta.y > 0) + Audio.incrementVolume(); + else if (angleDelta.y < 0) + Audio.decrementVolume(); + } else if (Config.bar.scrollActions.brightness) { + // Brightness scroll on bottom half + const monitor = Brightness.getMonitorForScreen(screen); + if (angleDelta.y > 0) + monitor.setBrightness(monitor.brightness + Config.services.brightnessIncrement); + else if (angleDelta.y < 0) + monitor.setBrightness(monitor.brightness - Config.services.brightnessIncrement); + } + } + + spacing: Appearance.spacing.normal + + Repeater { + id: repeater + + model: Config.bar.entries + + DelegateChooser { + role: "id" + + DelegateChoice { + roleValue: "spacer" + delegate: WrappedLoader { + Layout.fillHeight: enabled + } + } + DelegateChoice { + roleValue: "logo" + delegate: WrappedLoader { + sourceComponent: OsIcon {} + } + } + DelegateChoice { + roleValue: "workspaces" + delegate: WrappedLoader { + sourceComponent: Workspaces { + screen: root.screen + } + } + } + DelegateChoice { + roleValue: "activeWindow" + delegate: WrappedLoader { + sourceComponent: ActiveWindow { + bar: root + monitor: Brightness.getMonitorForScreen(root.screen) + } + } + } + DelegateChoice { + roleValue: "tray" + delegate: WrappedLoader { + sourceComponent: Tray {} + } + } + DelegateChoice { + roleValue: "clock" + delegate: WrappedLoader { + sourceComponent: Clock {} + } + } + DelegateChoice { + roleValue: "statusIcons" + delegate: WrappedLoader { + sourceComponent: StatusIcons {} + } + } + DelegateChoice { + roleValue: "power" + delegate: WrappedLoader { + sourceComponent: Power { + visibilities: root.visibilities + } + } + } + } + } + + component WrappedLoader: Loader { + required property bool enabled + required property string id + required property int index + + function findFirstEnabled(): Item { + const count = repeater.count; + for (let i = 0; i < count; i++) { + const item = repeater.itemAt(i); + if (item?.enabled) + return item; + } + return null; + } + + function findLastEnabled(): Item { + for (let i = repeater.count - 1; i >= 0; i--) { + const item = repeater.itemAt(i); + if (item?.enabled) + return item; + } + return null; + } + + Layout.alignment: Qt.AlignHCenter + + // Cursed ahh thing to add padding to first and last enabled components + Layout.topMargin: findFirstEnabled() === this ? root.vPadding : 0 + Layout.bottomMargin: findLastEnabled() === this ? root.vPadding : 0 + + visible: enabled + active: enabled + } +} diff --git a/.config/quickshell/caelestia/modules/bar/BarWrapper.qml b/.config/quickshell/caelestia/modules/bar/BarWrapper.qml new file mode 100644 index 0000000..29961b6 --- /dev/null +++ b/.config/quickshell/caelestia/modules/bar/BarWrapper.qml @@ -0,0 +1,87 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.config +import "popouts" as BarPopouts +import Quickshell +import QtQuick + +Item { + id: root + + required property ShellScreen screen + required property PersistentProperties visibilities + required property BarPopouts.Wrapper popouts + required property bool disabled + + readonly property int padding: Math.max(Appearance.padding.smaller, Config.border.thickness) + readonly property int contentWidth: Config.bar.sizes.innerWidth + padding * 2 + readonly property int exclusiveZone: !disabled && (Config.bar.persistent || visibilities.bar) ? contentWidth : Config.border.thickness + readonly property bool shouldBeVisible: !disabled && (Config.bar.persistent || visibilities.bar || isHovered) + property bool isHovered + + function closeTray(): void { + content.item?.closeTray(); + } + + function checkPopout(y: real): void { + content.item?.checkPopout(y); + } + + function handleWheel(y: real, angleDelta: point): void { + content.item?.handleWheel(y, angleDelta); + } + + visible: width > Config.border.thickness + implicitWidth: Config.border.thickness + + states: State { + name: "visible" + when: root.shouldBeVisible + + PropertyChanges { + root.implicitWidth: root.contentWidth + } + } + + transitions: [ + Transition { + from: "" + to: "visible" + + Anim { + target: root + property: "implicitWidth" + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + }, + Transition { + from: "visible" + to: "" + + Anim { + target: root + property: "implicitWidth" + easing.bezierCurve: Appearance.anim.curves.emphasized + } + } + ] + + Loader { + id: content + + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.right: parent.right + + active: root.shouldBeVisible || root.visible + + sourceComponent: Bar { + width: root.contentWidth + screen: root.screen + visibilities: root.visibilities + popouts: root.popouts + } + } +} diff --git a/.config/quickshell/caelestia/modules/bar/components/ActiveWindow.qml b/.config/quickshell/caelestia/modules/bar/components/ActiveWindow.qml new file mode 100644 index 0000000..0c9b21e --- /dev/null +++ b/.config/quickshell/caelestia/modules/bar/components/ActiveWindow.qml @@ -0,0 +1,100 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.services +import qs.utils +import qs.config +import QtQuick + +Item { + id: root + + required property var bar + required property Brightness.Monitor monitor + property color colour: Colours.palette.m3primary + + readonly property int maxHeight: { + const otherModules = bar.children.filter(c => c.id && c.item !== this && c.id !== "spacer"); + const otherHeight = otherModules.reduce((acc, curr) => acc + (curr.item.nonAnimHeight ?? curr.height), 0); + // Length - 2 cause repeater counts as a child + return bar.height - otherHeight - bar.spacing * (bar.children.length - 1) - bar.vPadding * 2; + } + property Title current: text1 + + clip: true + implicitWidth: Math.max(icon.implicitWidth, current.implicitHeight) + implicitHeight: icon.implicitHeight + current.implicitWidth + current.anchors.topMargin + + MaterialIcon { + id: icon + + anchors.horizontalCenter: parent.horizontalCenter + + animate: true + text: Icons.getAppCategoryIcon(Hypr.activeToplevel?.lastIpcObject.class, "desktop_windows") + color: root.colour + } + + Title { + id: text1 + } + + Title { + id: text2 + } + + TextMetrics { + id: metrics + + text: Hypr.activeToplevel?.title ?? qsTr("Desktop") + font.pointSize: Appearance.font.size.smaller + font.family: Appearance.font.family.mono + elide: Qt.ElideRight + elideWidth: root.maxHeight - icon.height + + onTextChanged: { + const next = root.current === text1 ? text2 : text1; + next.text = elidedText; + root.current = next; + } + onElideWidthChanged: root.current.text = elidedText + } + + Behavior on implicitHeight { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + + component Title: StyledText { + id: text + + anchors.horizontalCenter: icon.horizontalCenter + anchors.top: icon.bottom + anchors.topMargin: Appearance.spacing.small + + font.pointSize: metrics.font.pointSize + font.family: metrics.font.family + color: root.colour + opacity: root.current === this ? 1 : 0 + + transform: [ + Translate { + x: Config.bar.activeWindow.inverted ? -implicitWidth + text.implicitHeight : 0 + }, + Rotation { + angle: Config.bar.activeWindow.inverted ? 270 : 90 + origin.x: text.implicitHeight / 2 + origin.y: text.implicitHeight / 2 + } + ] + + width: implicitHeight + height: implicitWidth + + Behavior on opacity { + Anim {} + } + } +} diff --git a/.config/quickshell/caelestia/modules/bar/components/Clock.qml b/.config/quickshell/caelestia/modules/bar/components/Clock.qml new file mode 100644 index 0000000..801e93d --- /dev/null +++ b/.config/quickshell/caelestia/modules/bar/components/Clock.qml @@ -0,0 +1,38 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.services +import qs.config +import QtQuick + +Column { + id: root + + property color colour: Colours.palette.m3tertiary + + spacing: Appearance.spacing.small + + Loader { + anchors.horizontalCenter: parent.horizontalCenter + + active: Config.bar.clock.showIcon + visible: active + + sourceComponent: MaterialIcon { + text: "calendar_month" + color: root.colour + } + } + + StyledText { + id: text + + anchors.horizontalCenter: parent.horizontalCenter + + horizontalAlignment: StyledText.AlignHCenter + text: Time.format(Config.services.useTwelveHourClock ? "hh\nmm\nA" : "hh\nmm") + font.pointSize: Appearance.font.size.smaller + font.family: Appearance.font.family.mono + color: root.colour + } +} diff --git a/.config/quickshell/caelestia/modules/bar/components/OsIcon.qml b/.config/quickshell/caelestia/modules/bar/components/OsIcon.qml new file mode 100644 index 0000000..a61500a --- /dev/null +++ b/.config/quickshell/caelestia/modules/bar/components/OsIcon.qml @@ -0,0 +1,46 @@ +import qs.components.effects +import qs.services +import qs.config +import qs.utils +import QtQuick +import qs.components + +Item { + id: root + + implicitWidth: Appearance.font.size.large * 1.2 + implicitHeight: Appearance.font.size.large * 1.2 + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + const visibilities = Visibilities.getForActive(); + visibilities.launcher = !visibilities.launcher; + } + } + + Loader { + anchors.centerIn: parent + sourceComponent: SysInfo.isDefaultLogo ? caelestiaLogo : distroIcon + } + + Component { + id: caelestiaLogo + + Logo { + implicitWidth: Appearance.font.size.large * 1.8 + implicitHeight: Appearance.font.size.large * 1.8 + } + } + + Component { + id: distroIcon + + ColouredIcon { + source: SysInfo.osLogo + implicitSize: Appearance.font.size.large * 1.2 + colour: Colours.palette.m3tertiary + } + } +} diff --git a/.config/quickshell/caelestia/modules/bar/components/Power.qml b/.config/quickshell/caelestia/modules/bar/components/Power.qml new file mode 100644 index 0000000..917bdf7 --- /dev/null +++ b/.config/quickshell/caelestia/modules/bar/components/Power.qml @@ -0,0 +1,40 @@ +import qs.components +import qs.services +import qs.config +import Quickshell +import QtQuick + +Item { + id: root + + required property PersistentProperties visibilities + + implicitWidth: icon.implicitHeight + Appearance.padding.small * 2 + implicitHeight: icon.implicitHeight + + StateLayer { + // Cursed workaround to make the height larger than the parent + anchors.fill: undefined + anchors.centerIn: parent + implicitWidth: implicitHeight + implicitHeight: icon.implicitHeight + Appearance.padding.small * 2 + + radius: Appearance.rounding.full + + function onClicked(): void { + root.visibilities.session = !root.visibilities.session; + } + } + + MaterialIcon { + id: icon + + anchors.centerIn: parent + anchors.horizontalCenterOffset: -1 + + text: "power_settings_new" + color: Colours.palette.m3error + font.bold: true + font.pointSize: Appearance.font.size.normal + } +} diff --git a/.config/quickshell/caelestia/modules/bar/components/Settings.qml b/.config/quickshell/caelestia/modules/bar/components/Settings.qml new file mode 100644 index 0000000..5d562ce --- /dev/null +++ b/.config/quickshell/caelestia/modules/bar/components/Settings.qml @@ -0,0 +1,41 @@ +import qs.components +import qs.modules.controlcenter +import qs.services +import qs.config +import Quickshell +import QtQuick + +Item { + id: root + + implicitWidth: icon.implicitHeight + Appearance.padding.small * 2 + implicitHeight: icon.implicitHeight + + StateLayer { + // Cursed workaround to make the height larger than the parent + anchors.fill: undefined + anchors.centerIn: parent + implicitWidth: implicitHeight + implicitHeight: icon.implicitHeight + Appearance.padding.small * 2 + + radius: Appearance.rounding.full + + function onClicked(): void { + WindowFactory.create(null, { + active: "network" + }); + } + } + + MaterialIcon { + id: icon + + anchors.centerIn: parent + anchors.horizontalCenterOffset: -1 + + text: "settings" + color: Colours.palette.m3onSurface + font.bold: true + font.pointSize: Appearance.font.size.normal + } +} diff --git a/.config/quickshell/caelestia/modules/bar/components/SettingsIcon.qml b/.config/quickshell/caelestia/modules/bar/components/SettingsIcon.qml new file mode 100644 index 0000000..5d562ce --- /dev/null +++ b/.config/quickshell/caelestia/modules/bar/components/SettingsIcon.qml @@ -0,0 +1,41 @@ +import qs.components +import qs.modules.controlcenter +import qs.services +import qs.config +import Quickshell +import QtQuick + +Item { + id: root + + implicitWidth: icon.implicitHeight + Appearance.padding.small * 2 + implicitHeight: icon.implicitHeight + + StateLayer { + // Cursed workaround to make the height larger than the parent + anchors.fill: undefined + anchors.centerIn: parent + implicitWidth: implicitHeight + implicitHeight: icon.implicitHeight + Appearance.padding.small * 2 + + radius: Appearance.rounding.full + + function onClicked(): void { + WindowFactory.create(null, { + active: "network" + }); + } + } + + MaterialIcon { + id: icon + + anchors.centerIn: parent + anchors.horizontalCenterOffset: -1 + + text: "settings" + color: Colours.palette.m3onSurface + font.bold: true + font.pointSize: Appearance.font.size.normal + } +} diff --git a/.config/quickshell/caelestia/modules/bar/components/StatusIcons.qml b/.config/quickshell/caelestia/modules/bar/components/StatusIcons.qml new file mode 100644 index 0000000..ca7dc2e --- /dev/null +++ b/.config/quickshell/caelestia/modules/bar/components/StatusIcons.qml @@ -0,0 +1,270 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.services +import qs.utils +import qs.config +import Quickshell +import Quickshell.Bluetooth +import Quickshell.Services.UPower +import QtQuick +import QtQuick.Layouts + +StyledRect { + id: root + + property color colour: Colours.palette.m3secondary + readonly property alias items: iconColumn + + color: Colours.tPalette.m3surfaceContainer + radius: Appearance.rounding.full + + clip: true + implicitWidth: Config.bar.sizes.innerWidth + implicitHeight: iconColumn.implicitHeight + Appearance.padding.normal * 2 - (Config.bar.status.showLockStatus && !Hypr.capsLock && !Hypr.numLock ? iconColumn.spacing : 0) + + ColumnLayout { + id: iconColumn + + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.bottomMargin: Appearance.padding.normal + + spacing: Appearance.spacing.smaller / 2 + + // Lock keys status + WrappedLoader { + name: "lockstatus" + active: Config.bar.status.showLockStatus + + sourceComponent: ColumnLayout { + spacing: 0 + + Item { + implicitWidth: capslockIcon.implicitWidth + implicitHeight: Hypr.capsLock ? capslockIcon.implicitHeight : 0 + + MaterialIcon { + id: capslockIcon + + anchors.centerIn: parent + + scale: Hypr.capsLock ? 1 : 0.5 + opacity: Hypr.capsLock ? 1 : 0 + + text: "keyboard_capslock_badge" + color: root.colour + + Behavior on opacity { + Anim {} + } + + Behavior on scale { + Anim {} + } + } + + Behavior on implicitHeight { + Anim {} + } + } + + Item { + Layout.topMargin: Hypr.capsLock && Hypr.numLock ? iconColumn.spacing : 0 + + implicitWidth: numlockIcon.implicitWidth + implicitHeight: Hypr.numLock ? numlockIcon.implicitHeight : 0 + + MaterialIcon { + id: numlockIcon + + anchors.centerIn: parent + + scale: Hypr.numLock ? 1 : 0.5 + opacity: Hypr.numLock ? 1 : 0 + + text: "looks_one" + color: root.colour + + Behavior on opacity { + Anim {} + } + + Behavior on scale { + Anim {} + } + } + + Behavior on implicitHeight { + Anim {} + } + } + } + } + + // Audio icon + WrappedLoader { + name: "audio" + active: Config.bar.status.showAudio + + sourceComponent: MaterialIcon { + animate: true + text: Icons.getVolumeIcon(Audio.volume, Audio.muted) + color: root.colour + } + } + + // Microphone icon + WrappedLoader { + name: "audio" + active: Config.bar.status.showMicrophone + + sourceComponent: MaterialIcon { + animate: true + text: Icons.getMicVolumeIcon(Audio.sourceVolume, Audio.sourceMuted) + color: root.colour + } + } + + // Keyboard layout icon + WrappedLoader { + name: "kblayout" + active: Config.bar.status.showKbLayout + + sourceComponent: StyledText { + animate: true + text: Hypr.kbLayout + color: root.colour + font.family: Appearance.font.family.mono + } + } + + // Network icon + WrappedLoader { + name: "network" + active: Config.bar.status.showNetwork && (!Nmcli.activeEthernet || Config.bar.status.showWifi) + + sourceComponent: MaterialIcon { + animate: true + text: Nmcli.active ? Icons.getNetworkIcon(Nmcli.active.strength ?? 0) : "wifi_off" + color: root.colour + } + } + + // Ethernet icon + WrappedLoader { + name: "ethernet" + active: Config.bar.status.showNetwork && Nmcli.activeEthernet + + sourceComponent: MaterialIcon { + animate: true + text: "cable" + color: root.colour + } + } + + // Bluetooth section + WrappedLoader { + Layout.preferredHeight: implicitHeight + + name: "bluetooth" + active: Config.bar.status.showBluetooth + + sourceComponent: ColumnLayout { + spacing: Appearance.spacing.smaller / 2 + + // Bluetooth icon + MaterialIcon { + animate: true + text: { + if (!Bluetooth.defaultAdapter?.enabled) + return "bluetooth_disabled"; + if (Bluetooth.devices.values.some(d => d.connected)) + return "bluetooth_connected"; + return "bluetooth"; + } + color: root.colour + } + + // Connected bluetooth devices + Repeater { + model: ScriptModel { + values: Bluetooth.devices.values.filter(d => d.state !== BluetoothDeviceState.Disconnected) + } + + MaterialIcon { + id: device + + required property BluetoothDevice modelData + + animate: true + text: Icons.getBluetoothIcon(modelData?.icon) + color: root.colour + fill: 1 + + SequentialAnimation on opacity { + running: device.modelData?.state !== BluetoothDeviceState.Connected + alwaysRunToEnd: true + loops: Animation.Infinite + + Anim { + from: 1 + to: 0 + duration: Appearance.anim.durations.large + easing.bezierCurve: Appearance.anim.curves.standardAccel + } + Anim { + from: 0 + to: 1 + duration: Appearance.anim.durations.large + easing.bezierCurve: Appearance.anim.curves.standardDecel + } + } + } + } + } + + Behavior on Layout.preferredHeight { + Anim {} + } + } + + // Battery icon + WrappedLoader { + name: "battery" + active: Config.bar.status.showBattery + + sourceComponent: MaterialIcon { + animate: true + text: { + if (!UPower.displayDevice.isLaptopBattery) { + if (PowerProfiles.profile === PowerProfile.PowerSaver) + return "energy_savings_leaf"; + if (PowerProfiles.profile === PowerProfile.Performance) + return "rocket_launch"; + return "balance"; + } + + const perc = UPower.displayDevice.percentage; + const charging = [UPowerDeviceState.Charging, UPowerDeviceState.FullyCharged, UPowerDeviceState.PendingCharge].includes(UPower.displayDevice.state); + if (perc === 1) + return charging ? "battery_charging_full" : "battery_full"; + let level = Math.floor(perc * 7); + if (charging && (level === 4 || level === 1)) + level--; + return charging ? `battery_charging_${(level + 3) * 10}` : `battery_${level}_bar`; + } + color: !UPower.onBattery || UPower.displayDevice.percentage > 0.2 ? root.colour : Colours.palette.m3error + fill: 1 + } + } + } + + component WrappedLoader: Loader { + required property string name + + Layout.alignment: Qt.AlignHCenter + visible: active + } +} diff --git a/.config/quickshell/caelestia/modules/bar/components/Tray.qml b/.config/quickshell/caelestia/modules/bar/components/Tray.qml new file mode 100644 index 0000000..7bafda1 --- /dev/null +++ b/.config/quickshell/caelestia/modules/bar/components/Tray.qml @@ -0,0 +1,121 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.services +import qs.config +import Quickshell +import Quickshell.Services.SystemTray +import QtQuick + +StyledRect { + id: root + + readonly property alias layout: layout + readonly property alias items: items + readonly property alias expandIcon: expandIcon + + readonly property int padding: Config.bar.tray.background ? Appearance.padding.normal : Appearance.padding.small + readonly property int spacing: Config.bar.tray.background ? Appearance.spacing.small : 0 + + property bool expanded + + readonly property real nonAnimHeight: { + if (!Config.bar.tray.compact) + return layout.implicitHeight + padding * 2; + return (expanded ? expandIcon.implicitHeight + layout.implicitHeight + spacing : expandIcon.implicitHeight) + padding * 2; + } + + clip: true + visible: height > 0 + + implicitWidth: Config.bar.sizes.innerWidth + implicitHeight: nonAnimHeight + + color: Qt.alpha(Colours.tPalette.m3surfaceContainer, (Config.bar.tray.background && items.count > 0) ? Colours.tPalette.m3surfaceContainer.a : 0) + radius: Appearance.rounding.full + + Column { + id: layout + + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.topMargin: root.padding + spacing: Appearance.spacing.small + + opacity: root.expanded || !Config.bar.tray.compact ? 1 : 0 + + add: Transition { + Anim { + properties: "scale" + from: 0 + to: 1 + easing.bezierCurve: Appearance.anim.curves.standardDecel + } + } + + move: Transition { + Anim { + properties: "scale" + to: 1 + easing.bezierCurve: Appearance.anim.curves.standardDecel + } + Anim { + properties: "x,y" + } + } + + Repeater { + id: items + + model: ScriptModel { + values: SystemTray.items.values.filter(i => !Config.bar.tray.hiddenIcons.includes(i.id)) + } + + TrayItem {} + } + + Behavior on opacity { + Anim {} + } + } + + Loader { + id: expandIcon + + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + + active: Config.bar.tray.compact && items.count > 0 + + sourceComponent: Item { + implicitWidth: expandIconInner.implicitWidth + implicitHeight: expandIconInner.implicitHeight - Appearance.padding.small * 2 + + MaterialIcon { + id: expandIconInner + + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: Config.bar.tray.background ? Appearance.padding.small : -Appearance.padding.small + text: "expand_less" + font.pointSize: Appearance.font.size.large + rotation: root.expanded ? 180 : 0 + + Behavior on rotation { + Anim {} + } + + Behavior on anchors.bottomMargin { + Anim {} + } + } + } + } + + Behavior on implicitHeight { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } +} diff --git a/.config/quickshell/caelestia/modules/bar/components/TrayItem.qml b/.config/quickshell/caelestia/modules/bar/components/TrayItem.qml new file mode 100644 index 0000000..9911907 --- /dev/null +++ b/.config/quickshell/caelestia/modules/bar/components/TrayItem.qml @@ -0,0 +1,34 @@ +pragma ComponentBehavior: Bound + +import qs.components.effects +import qs.services +import qs.config +import qs.utils +import Quickshell.Services.SystemTray +import QtQuick + +MouseArea { + id: root + + required property SystemTrayItem modelData + + acceptedButtons: Qt.LeftButton | Qt.RightButton + implicitWidth: Appearance.font.size.small * 2 + implicitHeight: Appearance.font.size.small * 2 + + onClicked: event => { + if (event.button === Qt.LeftButton) + modelData.activate(); + else + modelData.secondaryActivate(); + } + + ColouredIcon { + id: icon + + anchors.fill: parent + source: Icons.getTrayIcon(root.modelData.id, root.modelData.icon) + colour: Colours.palette.m3secondary + layer.enabled: Config.bar.tray.recolour + } +} diff --git a/.config/quickshell/caelestia/modules/bar/components/workspaces/ActiveIndicator.qml b/.config/quickshell/caelestia/modules/bar/components/workspaces/ActiveIndicator.qml new file mode 100644 index 0000000..dae54b3 --- /dev/null +++ b/.config/quickshell/caelestia/modules/bar/components/workspaces/ActiveIndicator.qml @@ -0,0 +1,98 @@ +import qs.components +import qs.components.effects +import qs.services +import qs.config +import QtQuick + +StyledRect { + id: root + + required property int activeWsId + required property Repeater workspaces + required property Item mask + + readonly property int currentWsIdx: { + let i = activeWsId - 1; + while (i < 0) + i += Config.bar.workspaces.shown; + return i % Config.bar.workspaces.shown; + } + + property real leading: workspaces.count > 0 ? workspaces.itemAt(currentWsIdx)?.y ?? 0 : 0 + property real trailing: workspaces.count > 0 ? workspaces.itemAt(currentWsIdx)?.y ?? 0 : 0 + property real currentSize: workspaces.count > 0 ? workspaces.itemAt(currentWsIdx)?.size ?? 0 : 0 + property real offset: Math.min(leading, trailing) + property real size: { + const s = Math.abs(leading - trailing) + currentSize; + if (Config.bar.workspaces.activeTrail && lastWs > currentWsIdx) { + const ws = workspaces.itemAt(lastWs); + // console.log(ws, lastWs); + return ws ? Math.min(ws.y + ws.size - offset, s) : 0; + } + return s; + } + + property int cWs + property int lastWs + + onCurrentWsIdxChanged: { + lastWs = cWs; + cWs = currentWsIdx; + } + + clip: true + y: offset + mask.y + implicitWidth: Config.bar.sizes.innerWidth - Appearance.padding.small * 2 + implicitHeight: size + radius: Appearance.rounding.full + color: Colours.palette.m3primary + + Colouriser { + source: root.mask + sourceColor: Colours.palette.m3onSurface + colorizationColor: Colours.palette.m3onPrimary + + x: 0 + y: -parent.offset + implicitWidth: root.mask.implicitWidth + implicitHeight: root.mask.implicitHeight + + anchors.horizontalCenter: parent.horizontalCenter + } + + Behavior on leading { + enabled: Config.bar.workspaces.activeTrail + + EAnim {} + } + + Behavior on trailing { + enabled: Config.bar.workspaces.activeTrail + + EAnim { + duration: Appearance.anim.durations.normal * 2 + } + } + + Behavior on currentSize { + enabled: Config.bar.workspaces.activeTrail + + EAnim {} + } + + Behavior on offset { + enabled: !Config.bar.workspaces.activeTrail + + EAnim {} + } + + Behavior on size { + enabled: !Config.bar.workspaces.activeTrail + + EAnim {} + } + + component EAnim: Anim { + easing.bezierCurve: Appearance.anim.curves.emphasized + } +} diff --git a/.config/quickshell/caelestia/modules/bar/components/workspaces/OccupiedBg.qml b/.config/quickshell/caelestia/modules/bar/components/workspaces/OccupiedBg.qml new file mode 100644 index 0000000..56b215e --- /dev/null +++ b/.config/quickshell/caelestia/modules/bar/components/workspaces/OccupiedBg.qml @@ -0,0 +1,103 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.services +import qs.config +import Quickshell +import QtQuick + +Item { + id: root + + required property Repeater workspaces + required property var occupied + required property int groupOffset + + property list pills: [] + + onOccupiedChanged: { + if (!occupied) + return; + let count = 0; + const start = groupOffset; + const end = start + Config.bar.workspaces.shown; + for (const [ws, occ] of Object.entries(occupied)) { + if (ws > start && ws <= end && occ) { + const isFirstInGroup = Number(ws) === start + 1; + const isLastInGroup = Number(ws) === end; + if (isFirstInGroup || !occupied[ws - 1]) { + if (pills[count]) + pills[count].start = ws; + else + pills.push(pillComp.createObject(root, { + start: ws + })); + count++; + } + if ((isLastInGroup || !occupied[ws + 1]) && pills[count - 1]) + pills[count - 1].end = ws; + } + } + if (pills.length > count) + pills.splice(count, pills.length - count).forEach(p => p.destroy()); + } + + Repeater { + model: ScriptModel { + values: root.pills.filter(p => p) + } + + StyledRect { + id: rect + + required property var modelData + + readonly property Workspace start: root.workspaces.count > 0 ? root.workspaces.itemAt(getWsIdx(modelData.start)) ?? null : null + readonly property Workspace end: root.workspaces.count > 0 ? root.workspaces.itemAt(getWsIdx(modelData.end)) ?? null : null + + function getWsIdx(ws: int): int { + let i = ws - 1; + while (i < 0) + i += Config.bar.workspaces.shown; + return i % Config.bar.workspaces.shown; + } + + anchors.horizontalCenter: root.horizontalCenter + + y: (start?.y ?? 0) - 1 + implicitWidth: Config.bar.sizes.innerWidth - Appearance.padding.small * 2 + 2 + implicitHeight: start && end ? end.y + end.size - start.y + 2 : 0 + + color: Colours.layer(Colours.palette.m3surfaceContainerHigh, 2) + radius: Appearance.rounding.full + + scale: 0 + Component.onCompleted: scale = 1 + + Behavior on scale { + Anim { + easing.bezierCurve: Appearance.anim.curves.standardDecel + } + } + + Behavior on y { + Anim {} + } + + Behavior on implicitHeight { + Anim {} + } + } + } + + component Pill: QtObject { + property int start + property int end + } + + Component { + id: pillComp + + Pill {} + } +} diff --git a/.config/quickshell/caelestia/modules/bar/components/workspaces/SpecialWorkspaces.qml b/.config/quickshell/caelestia/modules/bar/components/workspaces/SpecialWorkspaces.qml new file mode 100644 index 0000000..ad85af8 --- /dev/null +++ b/.config/quickshell/caelestia/modules/bar/components/workspaces/SpecialWorkspaces.qml @@ -0,0 +1,359 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.components.effects +import qs.services +import qs.utils +import qs.config +import Quickshell +import Quickshell.Hyprland +import QtQuick +import QtQuick.Layouts + +Item { + id: root + + required property ShellScreen screen + readonly property HyprlandMonitor monitor: Hypr.monitorFor(screen) + readonly property string activeSpecial: (Config.bar.workspaces.perMonitorWorkspaces ? monitor : Hypr.focusedMonitor)?.lastIpcObject?.specialWorkspace?.name ?? "" + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: mask + } + + Item { + id: mask + + anchors.fill: parent + layer.enabled: true + visible: false + + Rectangle { + anchors.fill: parent + radius: Appearance.rounding.full + + gradient: Gradient { + orientation: Gradient.Vertical + + GradientStop { + position: 0 + color: Qt.rgba(0, 0, 0, 0) + } + GradientStop { + position: 0.3 + color: Qt.rgba(0, 0, 0, 1) + } + GradientStop { + position: 0.7 + color: Qt.rgba(0, 0, 0, 1) + } + GradientStop { + position: 1 + color: Qt.rgba(0, 0, 0, 0) + } + } + } + + Rectangle { + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + + radius: Appearance.rounding.full + implicitHeight: parent.height / 2 + opacity: view.contentY > 0 ? 0 : 1 + + Behavior on opacity { + Anim {} + } + } + + Rectangle { + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + + radius: Appearance.rounding.full + implicitHeight: parent.height / 2 + opacity: view.contentY < view.contentHeight - parent.height + Appearance.padding.small ? 0 : 1 + + Behavior on opacity { + Anim {} + } + } + } + + ListView { + id: view + + anchors.fill: parent + spacing: Appearance.spacing.normal + interactive: false + + currentIndex: model.values.findIndex(w => w.name === root.activeSpecial) + onCurrentIndexChanged: currentIndex = Qt.binding(() => model.values.findIndex(w => w.name === root.activeSpecial)) + + model: ScriptModel { + values: Hypr.workspaces.values.filter(w => w.name.startsWith("special:") && (!Config.bar.workspaces.perMonitorWorkspaces || w.monitor === root.monitor)) + } + + preferredHighlightBegin: 0 + preferredHighlightEnd: height + highlightRangeMode: ListView.StrictlyEnforceRange + + highlightFollowsCurrentItem: false + highlight: Item { + y: view.currentItem?.y ?? 0 + implicitHeight: view.currentItem?.size ?? 0 + + Behavior on y { + Anim {} + } + } + + delegate: ColumnLayout { + id: ws + + required property HyprlandWorkspace modelData + readonly property int size: label.Layout.preferredHeight + (hasWindows ? windows.implicitHeight + Appearance.padding.small : 0) + property int wsId + property string icon + property bool hasWindows + + anchors.left: view.contentItem.left + anchors.right: view.contentItem.right + + spacing: 0 + + Component.onCompleted: { + wsId = modelData.id; + icon = Icons.getSpecialWsIcon(modelData.name); + hasWindows = Config.bar.workspaces.showWindowsOnSpecialWorkspaces && modelData.lastIpcObject.windows > 0; + } + + // Hacky thing cause modelData gets destroyed before the remove anim finishes + Connections { + target: ws.modelData + + function onIdChanged(): void { + if (ws.modelData) + ws.wsId = ws.modelData.id; + } + + function onNameChanged(): void { + if (ws.modelData) + ws.icon = Icons.getSpecialWsIcon(ws.modelData.name); + } + + function onLastIpcObjectChanged(): void { + if (ws.modelData) + ws.hasWindows = Config.bar.workspaces.showWindowsOnSpecialWorkspaces && ws.modelData.lastIpcObject.windows > 0; + } + } + + Connections { + target: Config.bar.workspaces + + function onShowWindowsOnSpecialWorkspacesChanged(): void { + if (ws.modelData) + ws.hasWindows = Config.bar.workspaces.showWindowsOnSpecialWorkspaces && ws.modelData.lastIpcObject.windows > 0; + } + } + + Loader { + id: label + + Layout.alignment: Qt.AlignHCenter | Qt.AlignTop + Layout.preferredHeight: Config.bar.sizes.innerWidth - Appearance.padding.small * 2 + + sourceComponent: ws.icon.length === 1 ? letterComp : iconComp + + Component { + id: iconComp + + MaterialIcon { + fill: 1 + text: ws.icon + verticalAlignment: Qt.AlignVCenter + } + } + + Component { + id: letterComp + + StyledText { + text: ws.icon + verticalAlignment: Qt.AlignVCenter + } + } + } + + Loader { + id: windows + + Layout.alignment: Qt.AlignHCenter + Layout.fillHeight: true + Layout.preferredHeight: implicitHeight + + visible: active + active: ws.hasWindows + + sourceComponent: Column { + spacing: 0 + + add: Transition { + Anim { + properties: "scale" + from: 0 + to: 1 + easing.bezierCurve: Appearance.anim.curves.standardDecel + } + } + + move: Transition { + Anim { + properties: "scale" + to: 1 + easing.bezierCurve: Appearance.anim.curves.standardDecel + } + Anim { + properties: "x,y" + } + } + + Repeater { + model: ScriptModel { + values: Hypr.toplevels.values.filter(c => c.workspace?.id === ws.wsId) + } + + MaterialIcon { + required property var modelData + + grade: 0 + text: Icons.getAppCategoryIcon(modelData.lastIpcObject.class, "terminal") + color: Colours.palette.m3onSurfaceVariant + } + } + } + + Behavior on Layout.preferredHeight { + Anim {} + } + } + } + + add: Transition { + Anim { + properties: "scale" + from: 0 + to: 1 + easing.bezierCurve: Appearance.anim.curves.standardDecel + } + } + + remove: Transition { + Anim { + property: "scale" + to: 0.5 + duration: Appearance.anim.durations.small + } + Anim { + property: "opacity" + to: 0 + duration: Appearance.anim.durations.small + } + } + + move: Transition { + Anim { + properties: "scale" + to: 1 + easing.bezierCurve: Appearance.anim.curves.standardDecel + } + Anim { + properties: "x,y" + } + } + + displaced: Transition { + Anim { + properties: "scale" + to: 1 + easing.bezierCurve: Appearance.anim.curves.standardDecel + } + Anim { + properties: "x,y" + } + } + } + + Loader { + active: Config.bar.workspaces.activeIndicator + anchors.fill: parent + + sourceComponent: Item { + StyledClippingRect { + id: indicator + + anchors.left: parent.left + anchors.right: parent.right + + y: (view.currentItem?.y ?? 0) - view.contentY + implicitHeight: view.currentItem?.size ?? 0 + + color: Colours.palette.m3tertiary + radius: Appearance.rounding.full + + Colouriser { + source: view + sourceColor: Colours.palette.m3onSurface + colorizationColor: Colours.palette.m3onTertiary + + anchors.horizontalCenter: parent.horizontalCenter + + x: 0 + y: -indicator.y + implicitWidth: view.width + implicitHeight: view.height + } + + Behavior on y { + Anim { + easing.bezierCurve: Appearance.anim.curves.emphasized + } + } + + Behavior on implicitHeight { + Anim { + easing.bezierCurve: Appearance.anim.curves.emphasized + } + } + } + } + } + + MouseArea { + property real startY + + anchors.fill: view + + drag.target: view.contentItem + drag.axis: Drag.YAxis + drag.maximumY: 0 + drag.minimumY: Math.min(0, view.height - view.contentHeight - Appearance.padding.small) + + onPressed: event => startY = event.y + + onClicked: event => { + if (Math.abs(event.y - startY) > drag.threshold) + return; + + const ws = view.itemAt(event.x, event.y); + if (ws?.modelData) + Hypr.dispatch(`togglespecialworkspace ${ws.modelData.name.slice(8)}`); + else + Hypr.dispatch("togglespecialworkspace special"); + } + } +} diff --git a/.config/quickshell/caelestia/modules/bar/components/workspaces/Workspace.qml b/.config/quickshell/caelestia/modules/bar/components/workspaces/Workspace.qml new file mode 100644 index 0000000..3c8238b --- /dev/null +++ b/.config/quickshell/caelestia/modules/bar/components/workspaces/Workspace.qml @@ -0,0 +1,107 @@ +import qs.components +import qs.services +import qs.utils +import qs.config +import Quickshell +import QtQuick +import QtQuick.Layouts + +ColumnLayout { + id: root + + required property int index + required property int activeWsId + required property var occupied + required property int groupOffset + + readonly property bool isWorkspace: true // Flag for finding workspace children + // Unanimated prop for others to use as reference + readonly property int size: implicitHeight + (hasWindows ? Appearance.padding.small : 0) + + readonly property int ws: groupOffset + index + 1 + readonly property bool isOccupied: occupied[ws] ?? false + readonly property bool hasWindows: isOccupied && Config.bar.workspaces.showWindows + + Layout.alignment: Qt.AlignHCenter + Layout.preferredHeight: size + + spacing: 0 + + StyledText { + id: indicator + + Layout.alignment: Qt.AlignHCenter | Qt.AlignTop + Layout.preferredHeight: Config.bar.sizes.innerWidth - Appearance.padding.small * 2 + + animate: true + text: { + const ws = Hypr.workspaces.values.find(w => w.id === root.ws); + const wsName = !ws || ws.name == root.ws ? root.ws : ws.name[0]; + let displayName = wsName.toString(); + if (Config.bar.workspaces.capitalisation.toLowerCase() === "upper") { + displayName = displayName.toUpperCase(); + } else if (Config.bar.workspaces.capitalisation.toLowerCase() === "lower") { + displayName = displayName.toLowerCase(); + } + const label = Config.bar.workspaces.label || displayName; + const occupiedLabel = Config.bar.workspaces.occupiedLabel || label; + const activeLabel = Config.bar.workspaces.activeLabel || (root.isOccupied ? occupiedLabel : label); + return root.activeWsId === root.ws ? activeLabel : root.isOccupied ? occupiedLabel : label; + } + color: Config.bar.workspaces.occupiedBg || root.isOccupied || root.activeWsId === root.ws ? Colours.palette.m3onSurface : Colours.layer(Colours.palette.m3outlineVariant, 2) + verticalAlignment: Qt.AlignVCenter + } + + Loader { + id: windows + + Layout.alignment: Qt.AlignHCenter + Layout.fillHeight: true + Layout.topMargin: -Config.bar.sizes.innerWidth / 10 + + visible: active + active: root.hasWindows + + sourceComponent: Column { + spacing: 0 + + add: Transition { + Anim { + properties: "scale" + from: 0 + to: 1 + easing.bezierCurve: Appearance.anim.curves.standardDecel + } + } + + move: Transition { + Anim { + properties: "scale" + to: 1 + easing.bezierCurve: Appearance.anim.curves.standardDecel + } + Anim { + properties: "x,y" + } + } + + Repeater { + model: ScriptModel { + values: Hypr.toplevels.values.filter(c => c.workspace?.id === root.ws) + } + + MaterialIcon { + required property var modelData + + grade: 0 + text: Icons.getAppCategoryIcon(modelData.lastIpcObject.class, "terminal") + color: Colours.palette.m3onSurfaceVariant + } + } + } + } + + Behavior on Layout.preferredHeight { + Anim {} + } +} diff --git a/.config/quickshell/caelestia/modules/bar/components/workspaces/Workspaces.qml b/.config/quickshell/caelestia/modules/bar/components/workspaces/Workspaces.qml new file mode 100644 index 0000000..bfa80ab --- /dev/null +++ b/.config/quickshell/caelestia/modules/bar/components/workspaces/Workspaces.qml @@ -0,0 +1,137 @@ +pragma ComponentBehavior: Bound + +import qs.services +import qs.config +import qs.components +import Quickshell +import QtQuick +import QtQuick.Layouts +import QtQuick.Effects + +StyledClippingRect { + id: root + + required property ShellScreen screen + + readonly property bool onSpecial: (Config.bar.workspaces.perMonitorWorkspaces ? Hypr.monitorFor(screen) : Hypr.focusedMonitor)?.lastIpcObject?.specialWorkspace?.name !== "" + readonly property int activeWsId: Config.bar.workspaces.perMonitorWorkspaces ? (Hypr.monitorFor(screen).activeWorkspace?.id ?? 1) : Hypr.activeWsId + + readonly property var occupied: Hypr.workspaces.values.reduce((acc, curr) => { + acc[curr.id] = curr.lastIpcObject.windows > 0; + return acc; + }, {}) + readonly property int groupOffset: Math.floor((activeWsId - 1) / Config.bar.workspaces.shown) * Config.bar.workspaces.shown + + property real blur: onSpecial ? 1 : 0 + + implicitWidth: Config.bar.sizes.innerWidth + implicitHeight: layout.implicitHeight + Appearance.padding.small * 2 + + color: Colours.tPalette.m3surfaceContainer + radius: Appearance.rounding.full + + Item { + anchors.fill: parent + scale: root.onSpecial ? 0.8 : 1 + opacity: root.onSpecial ? 0.5 : 1 + + layer.enabled: root.blur > 0 + layer.effect: MultiEffect { + blurEnabled: true + blur: root.blur + blurMax: 32 + } + + Loader { + active: Config.bar.workspaces.occupiedBg + + anchors.fill: parent + anchors.margins: Appearance.padding.small + + sourceComponent: OccupiedBg { + workspaces: workspaces + occupied: root.occupied + groupOffset: root.groupOffset + } + } + + ColumnLayout { + id: layout + + anchors.centerIn: parent + spacing: Math.floor(Appearance.spacing.small / 2) + + Repeater { + id: workspaces + + model: Config.bar.workspaces.shown + + Workspace { + activeWsId: root.activeWsId + occupied: root.occupied + groupOffset: root.groupOffset + } + } + } + + Loader { + anchors.horizontalCenter: parent.horizontalCenter + active: Config.bar.workspaces.activeIndicator + + sourceComponent: ActiveIndicator { + activeWsId: root.activeWsId + workspaces: workspaces + mask: layout + } + } + + MouseArea { + anchors.fill: layout + onClicked: event => { + const ws = layout.childAt(event.x, event.y).ws; + if (Hypr.activeWsId !== ws) + Hypr.dispatch(`workspace ${ws}`); + else + Hypr.dispatch("togglespecialworkspace special"); + } + } + + Behavior on scale { + Anim {} + } + + Behavior on opacity { + Anim {} + } + } + + Loader { + id: specialWs + + anchors.fill: parent + anchors.margins: Appearance.padding.small + + active: opacity > 0 + + scale: root.onSpecial ? 1 : 0.5 + opacity: root.onSpecial ? 1 : 0 + + sourceComponent: SpecialWorkspaces { + screen: root.screen + } + + Behavior on scale { + Anim {} + } + + Behavior on opacity { + Anim {} + } + } + + Behavior on blur { + Anim { + duration: Appearance.anim.durations.small + } + } +} diff --git a/.config/quickshell/caelestia/modules/bar/popouts/ActiveWindow.qml b/.config/quickshell/caelestia/modules/bar/popouts/ActiveWindow.qml new file mode 100644 index 0000000..adf7b77 --- /dev/null +++ b/.config/quickshell/caelestia/modules/bar/popouts/ActiveWindow.qml @@ -0,0 +1,102 @@ +import qs.components +import qs.services +import qs.utils +import qs.config +import Quickshell.Widgets +import Quickshell.Wayland +import QtQuick +import QtQuick.Layouts + +Item { + id: root + + required property Item wrapper + + implicitWidth: Hypr.activeToplevel ? child.implicitWidth : -Appearance.padding.large * 2 + implicitHeight: child.implicitHeight + + Column { + id: child + + anchors.centerIn: parent + spacing: Appearance.spacing.normal + + RowLayout { + id: detailsRow + + anchors.left: parent.left + anchors.right: parent.right + spacing: Appearance.spacing.normal + + IconImage { + id: icon + + Layout.alignment: Qt.AlignVCenter + implicitSize: details.implicitHeight + source: Icons.getAppIcon(Hypr.activeToplevel?.lastIpcObject.class ?? "", "image-missing") + } + + ColumnLayout { + id: details + + spacing: 0 + Layout.fillWidth: true + + StyledText { + Layout.fillWidth: true + text: Hypr.activeToplevel?.title ?? "" + font.pointSize: Appearance.font.size.normal + elide: Text.ElideRight + } + + StyledText { + Layout.fillWidth: true + text: Hypr.activeToplevel?.lastIpcObject.class ?? "" + color: Colours.palette.m3onSurfaceVariant + elide: Text.ElideRight + } + } + + Item { + implicitWidth: expandIcon.implicitHeight + Appearance.padding.small * 2 + implicitHeight: expandIcon.implicitHeight + Appearance.padding.small * 2 + + Layout.alignment: Qt.AlignVCenter + + StateLayer { + radius: Appearance.rounding.normal + + function onClicked(): void { + root.wrapper.detach("winfo"); + } + } + + MaterialIcon { + id: expandIcon + + anchors.centerIn: parent + anchors.horizontalCenterOffset: font.pointSize * 0.05 + + text: "chevron_right" + + font.pointSize: Appearance.font.size.large + } + } + } + + ClippingWrapperRectangle { + color: "transparent" + radius: Appearance.rounding.small + + ScreencopyView { + id: preview + + captureSource: Hypr.activeToplevel?.wayland ?? null + live: visible + + constraintSize.width: Config.bar.sizes.windowPreviewSize + constraintSize.height: Config.bar.sizes.windowPreviewSize + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/bar/popouts/Audio.qml b/.config/quickshell/caelestia/modules/bar/popouts/Audio.qml new file mode 100644 index 0000000..58b29ba --- /dev/null +++ b/.config/quickshell/caelestia/modules/bar/popouts/Audio.qml @@ -0,0 +1,120 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.components.controls +import qs.services +import qs.config +import Quickshell +import Quickshell.Services.Pipewire +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import "../../controlcenter/network" + +Item { + id: root + + required property var wrapper + + implicitWidth: layout.implicitWidth + Appearance.padding.normal * 2 + implicitHeight: layout.implicitHeight + Appearance.padding.normal * 2 + + ButtonGroup { + id: sinks + } + + ButtonGroup { + id: sources + } + + ColumnLayout { + id: layout + + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + spacing: Appearance.spacing.normal + + StyledText { + text: qsTr("Output device") + font.weight: 500 + } + + Repeater { + model: Audio.sinks + + StyledRadioButton { + id: control + + required property PwNode modelData + + ButtonGroup.group: sinks + checked: Audio.sink?.id === modelData.id + onClicked: Audio.setAudioSink(modelData) + text: modelData.description + } + } + + StyledText { + Layout.topMargin: Appearance.spacing.smaller + text: qsTr("Input device") + font.weight: 500 + } + + Repeater { + model: Audio.sources + + StyledRadioButton { + required property PwNode modelData + + ButtonGroup.group: sources + checked: Audio.source?.id === modelData.id + onClicked: Audio.setAudioSource(modelData) + text: modelData.description + } + } + + StyledText { + Layout.topMargin: Appearance.spacing.smaller + Layout.bottomMargin: -Appearance.spacing.small / 2 + text: qsTr("Volume (%1)").arg(Audio.muted ? qsTr("Muted") : `${Math.round(Audio.volume * 100)}%`) + font.weight: 500 + } + + CustomMouseArea { + Layout.fillWidth: true + implicitHeight: Appearance.padding.normal * 3 + + onWheel: event => { + if (event.angleDelta.y > 0) + Audio.incrementVolume(); + else if (event.angleDelta.y < 0) + Audio.decrementVolume(); + } + + StyledSlider { + anchors.left: parent.left + anchors.right: parent.right + implicitHeight: parent.implicitHeight + + value: Audio.volume + onMoved: Audio.setVolume(value) + + Behavior on value { + Anim {} + } + } + } + + IconTextButton { + Layout.fillWidth: true + Layout.topMargin: Appearance.spacing.normal + inactiveColour: Colours.palette.m3primaryContainer + inactiveOnColour: Colours.palette.m3onPrimaryContainer + verticalPadding: Appearance.padding.small + text: qsTr("Open settings") + icon: "settings" + + onClicked: root.wrapper.detach("audio") + } + } +} diff --git a/.config/quickshell/caelestia/modules/bar/popouts/Background.qml b/.config/quickshell/caelestia/modules/bar/popouts/Background.qml new file mode 100644 index 0000000..075b698 --- /dev/null +++ b/.config/quickshell/caelestia/modules/bar/popouts/Background.qml @@ -0,0 +1,73 @@ +import qs.components +import qs.services +import qs.config +import QtQuick +import QtQuick.Shapes + +ShapePath { + id: root + + required property Wrapper wrapper + required property bool invertBottomRounding + readonly property real rounding: wrapper.isDetached ? Appearance.rounding.normal : Config.border.rounding + readonly property bool flatten: wrapper.width < rounding * 2 + readonly property real roundingX: flatten ? wrapper.width / 2 : rounding + property real ibr: invertBottomRounding ? -1 : 1 + + property real sideRounding: startX > 0 ? -1 : 1 + + strokeWidth: -1 + fillColor: Colours.palette.m3surface + + PathArc { + relativeX: root.roundingX + relativeY: root.rounding * root.sideRounding + radiusX: Math.min(root.rounding, root.wrapper.width) + radiusY: root.rounding + direction: root.sideRounding < 0 ? PathArc.Clockwise : PathArc.Counterclockwise + } + PathLine { + relativeX: root.wrapper.width - root.roundingX * 2 + relativeY: 0 + } + PathArc { + relativeX: root.roundingX + relativeY: root.rounding + radiusX: Math.min(root.rounding, root.wrapper.width) + radiusY: root.rounding + } + PathLine { + relativeX: 0 + relativeY: root.wrapper.height - root.rounding * 2 + } + PathArc { + relativeX: -root.roundingX * root.ibr + relativeY: root.rounding + radiusX: Math.min(root.rounding, root.wrapper.width) + radiusY: root.rounding + direction: root.ibr < 0 ? PathArc.Counterclockwise : PathArc.Clockwise + } + PathLine { + relativeX: -(root.wrapper.width - root.roundingX - root.roundingX * root.ibr) + relativeY: 0 + } + PathArc { + relativeX: -root.roundingX + relativeY: root.rounding * root.sideRounding + radiusX: Math.min(root.rounding, root.wrapper.width) + radiusY: root.rounding + direction: root.sideRounding < 0 ? PathArc.Clockwise : PathArc.Counterclockwise + } + + Behavior on fillColor { + CAnim {} + } + + Behavior on ibr { + Anim {} + } + + Behavior on sideRounding { + Anim {} + } +} diff --git a/.config/quickshell/caelestia/modules/bar/popouts/Battery.qml b/.config/quickshell/caelestia/modules/bar/popouts/Battery.qml new file mode 100644 index 0000000..ac975e1 --- /dev/null +++ b/.config/quickshell/caelestia/modules/bar/popouts/Battery.qml @@ -0,0 +1,230 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.services +import qs.config +import Quickshell.Services.UPower +import QtQuick + +Column { + id: root + + spacing: Appearance.spacing.normal + width: Config.bar.sizes.batteryWidth + + StyledText { + text: UPower.displayDevice.isLaptopBattery ? qsTr("Remaining: %1%").arg(Math.round(UPower.displayDevice.percentage * 100)) : qsTr("No battery detected") + } + + StyledText { + function formatSeconds(s: int, fallback: string): string { + const day = Math.floor(s / 86400); + const hr = Math.floor(s / 3600) % 60; + const min = Math.floor(s / 60) % 60; + + let comps = []; + if (day > 0) + comps.push(`${day} days`); + if (hr > 0) + comps.push(`${hr} hours`); + if (min > 0) + comps.push(`${min} mins`); + + return comps.join(", ") || fallback; + } + + text: UPower.displayDevice.isLaptopBattery ? qsTr("Time %1: %2").arg(UPower.onBattery ? "remaining" : "until charged").arg(UPower.onBattery ? formatSeconds(UPower.displayDevice.timeToEmpty, "Calculating...") : formatSeconds(UPower.displayDevice.timeToFull, "Fully charged!")) : qsTr("Power profile: %1").arg(PowerProfile.toString(PowerProfiles.profile)) + } + + Loader { + anchors.horizontalCenter: parent.horizontalCenter + + active: PowerProfiles.degradationReason !== PerformanceDegradationReason.None + + height: active ? (item?.implicitHeight ?? 0) : 0 + + sourceComponent: StyledRect { + implicitWidth: child.implicitWidth + Appearance.padding.normal * 2 + implicitHeight: child.implicitHeight + Appearance.padding.smaller * 2 + + color: Colours.palette.m3error + radius: Appearance.rounding.normal + + Column { + id: child + + anchors.centerIn: parent + + Row { + anchors.horizontalCenter: parent.horizontalCenter + spacing: Appearance.spacing.small + + MaterialIcon { + anchors.verticalCenter: parent.verticalCenter + anchors.verticalCenterOffset: -font.pointSize / 10 + + text: "warning" + color: Colours.palette.m3onError + } + + StyledText { + anchors.verticalCenter: parent.verticalCenter + text: qsTr("Performance Degraded") + color: Colours.palette.m3onError + font.family: Appearance.font.family.mono + font.weight: 500 + } + + MaterialIcon { + anchors.verticalCenter: parent.verticalCenter + anchors.verticalCenterOffset: -font.pointSize / 10 + + text: "warning" + color: Colours.palette.m3onError + } + } + + StyledText { + anchors.horizontalCenter: parent.horizontalCenter + + text: qsTr("Reason: %1").arg(PerformanceDegradationReason.toString(PowerProfiles.degradationReason)) + color: Colours.palette.m3onError + } + } + } + } + + StyledRect { + id: profiles + + property string current: { + const p = PowerProfiles.profile; + if (p === PowerProfile.PowerSaver) + return saver.icon; + if (p === PowerProfile.Performance) + return perf.icon; + return balance.icon; + } + + anchors.horizontalCenter: parent.horizontalCenter + + implicitWidth: saver.implicitHeight + balance.implicitHeight + perf.implicitHeight + Appearance.padding.normal * 2 + Appearance.spacing.large * 2 + implicitHeight: Math.max(saver.implicitHeight, balance.implicitHeight, perf.implicitHeight) + Appearance.padding.small * 2 + + color: Colours.tPalette.m3surfaceContainer + radius: Appearance.rounding.full + + StyledRect { + id: indicator + + color: Colours.palette.m3primary + radius: Appearance.rounding.full + state: profiles.current + + states: [ + State { + name: saver.icon + + Fill { + item: saver + } + }, + State { + name: balance.icon + + Fill { + item: balance + } + }, + State { + name: perf.icon + + Fill { + item: perf + } + } + ] + + transitions: Transition { + AnchorAnimation { + duration: Appearance.anim.durations.normal + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.anim.curves.emphasized + } + } + } + + Profile { + id: saver + + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: Appearance.padding.small + + profile: PowerProfile.PowerSaver + icon: "energy_savings_leaf" + } + + Profile { + id: balance + + anchors.centerIn: parent + + profile: PowerProfile.Balanced + icon: "balance" + } + + Profile { + id: perf + + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + anchors.rightMargin: Appearance.padding.small + + profile: PowerProfile.Performance + icon: "rocket_launch" + } + } + + component Fill: AnchorChanges { + required property Item item + + target: indicator + anchors.left: item.left + anchors.right: item.right + anchors.top: item.top + anchors.bottom: item.bottom + } + + component Profile: Item { + required property string icon + required property int profile + + implicitWidth: icon.implicitHeight + Appearance.padding.small * 2 + implicitHeight: icon.implicitHeight + Appearance.padding.small * 2 + + StateLayer { + radius: Appearance.rounding.full + color: profiles.current === parent.icon ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface + + function onClicked(): void { + PowerProfiles.profile = parent.profile; + } + } + + MaterialIcon { + id: icon + + anchors.centerIn: parent + + text: parent.icon + font.pointSize: Appearance.font.size.large + color: profiles.current === text ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface + fill: profiles.current === text ? 1 : 0 + + Behavior on fill { + Anim {} + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/bar/popouts/Bluetooth.qml b/.config/quickshell/caelestia/modules/bar/popouts/Bluetooth.qml new file mode 100644 index 0000000..676da82 --- /dev/null +++ b/.config/quickshell/caelestia/modules/bar/popouts/Bluetooth.qml @@ -0,0 +1,197 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.components.controls +import qs.services +import qs.config +import qs.utils +import Quickshell +import Quickshell.Bluetooth +import QtQuick +import QtQuick.Layouts +import "../../controlcenter/network" + +ColumnLayout { + id: root + + required property Item wrapper + + spacing: Appearance.spacing.small + + StyledText { + Layout.topMargin: Appearance.padding.normal + Layout.rightMargin: Appearance.padding.small + text: qsTr("Bluetooth") + font.weight: 500 + } + + Toggle { + label: qsTr("Enabled") + checked: Bluetooth.defaultAdapter?.enabled ?? false + toggle.onToggled: { + const adapter = Bluetooth.defaultAdapter; + if (adapter) + adapter.enabled = checked; + } + } + + Toggle { + label: qsTr("Discovering") + checked: Bluetooth.defaultAdapter?.discovering ?? false + toggle.onToggled: { + const adapter = Bluetooth.defaultAdapter; + if (adapter) + adapter.discovering = checked; + } + } + + StyledText { + Layout.topMargin: Appearance.spacing.small + Layout.rightMargin: Appearance.padding.small + text: { + const devices = Bluetooth.devices.values; + let available = qsTr("%1 device%2 available").arg(devices.length).arg(devices.length === 1 ? "" : "s"); + const connected = devices.filter(d => d.connected).length; + if (connected > 0) + available += qsTr(" (%1 connected)").arg(connected); + return available; + } + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small + } + + Repeater { + model: ScriptModel { + values: [...Bluetooth.devices.values].sort((a, b) => (b.connected - a.connected) || (b.paired - a.paired) || a.name.localeCompare(b.name)).slice(0, 5) + } + + RowLayout { + id: device + + required property BluetoothDevice modelData + readonly property bool loading: modelData.state === BluetoothDeviceState.Connecting || modelData.state === BluetoothDeviceState.Disconnecting + + Layout.fillWidth: true + Layout.rightMargin: Appearance.padding.small + spacing: Appearance.spacing.small + + opacity: 0 + scale: 0.7 + + Component.onCompleted: { + opacity = 1; + scale = 1; + } + + Behavior on opacity { + Anim {} + } + + Behavior on scale { + Anim {} + } + + MaterialIcon { + text: Icons.getBluetoothIcon(device.modelData.icon) + } + + StyledText { + Layout.leftMargin: Appearance.spacing.small / 2 + Layout.rightMargin: Appearance.spacing.small / 2 + Layout.fillWidth: true + text: device.modelData.name + } + + StyledRect { + id: connectBtn + + implicitWidth: implicitHeight + implicitHeight: connectIcon.implicitHeight + Appearance.padding.small + + radius: Appearance.rounding.full + color: Qt.alpha(Colours.palette.m3primary, device.modelData.state === BluetoothDeviceState.Connected ? 1 : 0) + + CircularIndicator { + anchors.fill: parent + running: device.loading + } + + StateLayer { + color: device.modelData.state === BluetoothDeviceState.Connected ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface + disabled: device.loading + + function onClicked(): void { + device.modelData.connected = !device.modelData.connected; + } + } + + MaterialIcon { + id: connectIcon + + anchors.centerIn: parent + animate: true + text: device.modelData.connected ? "link_off" : "link" + color: device.modelData.state === BluetoothDeviceState.Connected ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface + + opacity: device.loading ? 0 : 1 + + Behavior on opacity { + Anim {} + } + } + } + + Loader { + active: device.modelData.bonded + sourceComponent: Item { + implicitWidth: connectBtn.implicitWidth + implicitHeight: connectBtn.implicitHeight + + StateLayer { + radius: Appearance.rounding.full + + function onClicked(): void { + device.modelData.forget(); + } + } + + MaterialIcon { + anchors.centerIn: parent + text: "delete" + } + } + } + } + } + + IconTextButton { + Layout.fillWidth: true + Layout.topMargin: Appearance.spacing.normal + inactiveColour: Colours.palette.m3primaryContainer + inactiveOnColour: Colours.palette.m3onPrimaryContainer + verticalPadding: Appearance.padding.small + text: qsTr("Open settings") + icon: "settings" + + onClicked: root.wrapper.detach("bluetooth") + } + + component Toggle: RowLayout { + required property string label + property alias checked: toggle.checked + property alias toggle: toggle + + Layout.fillWidth: true + Layout.rightMargin: Appearance.padding.small + spacing: Appearance.spacing.normal + + StyledText { + Layout.fillWidth: true + text: parent.label + } + + StyledSwitch { + id: toggle + } + } +} diff --git a/.config/quickshell/caelestia/modules/bar/popouts/Content.qml b/.config/quickshell/caelestia/modules/bar/popouts/Content.qml new file mode 100644 index 0000000..6543e58 --- /dev/null +++ b/.config/quickshell/caelestia/modules/bar/popouts/Content.qml @@ -0,0 +1,222 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.config +import Quickshell +import Quickshell.Services.SystemTray +import QtQuick + +import "./kblayout" + +Item { + id: root + + required property Item wrapper + readonly property Popout currentPopout: content.children.find(c => c.shouldBeActive) ?? null + readonly property Item current: currentPopout?.item ?? null + + anchors.centerIn: parent + + implicitWidth: (currentPopout?.implicitWidth ?? 0) + Appearance.padding.large * 2 + implicitHeight: (currentPopout?.implicitHeight ?? 0) + Appearance.padding.large * 2 + + Item { + id: content + + anchors.fill: parent + anchors.margins: Appearance.padding.large + + Popout { + name: "activewindow" + sourceComponent: ActiveWindow { + wrapper: root.wrapper + } + } + + Popout { + id: networkPopout + name: "network" + sourceComponent: Network { + wrapper: root.wrapper + view: "wireless" + } + } + + Popout { + name: "ethernet" + sourceComponent: Network { + wrapper: root.wrapper + view: "ethernet" + } + } + + Popout { + id: passwordPopout + name: "wirelesspassword" + sourceComponent: WirelessPassword { + id: passwordComponent + wrapper: root.wrapper + network: networkPopout.item?.passwordNetwork ?? null + } + + Connections { + target: root.wrapper + function onCurrentNameChanged() { + // Update network immediately when password popout becomes active + if (root.wrapper.currentName === "wirelesspassword") { + // Set network immediately if available + if (networkPopout.item && networkPopout.item.passwordNetwork) { + if (passwordPopout.item) { + passwordPopout.item.network = networkPopout.item.passwordNetwork; + } + } + // Also try after a short delay in case networkPopout.item wasn't ready + Qt.callLater(() => { + if (passwordPopout.item && networkPopout.item && networkPopout.item.passwordNetwork) { + passwordPopout.item.network = networkPopout.item.passwordNetwork; + } + }, 100); + } + } + } + + Connections { + target: networkPopout + function onItemChanged() { + // When network popout loads, update password popout if it's active + if (root.wrapper.currentName === "wirelesspassword" && passwordPopout.item) { + Qt.callLater(() => { + if (networkPopout.item && networkPopout.item.passwordNetwork) { + passwordPopout.item.network = networkPopout.item.passwordNetwork; + } + }); + } + } + } + } + + Popout { + name: "bluetooth" + sourceComponent: Bluetooth { + wrapper: root.wrapper + } + } + + Popout { + name: "battery" + sourceComponent: Battery {} + } + + Popout { + name: "audio" + sourceComponent: Audio { + wrapper: root.wrapper + } + } + + Popout { + name: "kblayout" + sourceComponent: KbLayout { + wrapper: root.wrapper + } + } + + Popout { + name: "lockstatus" + sourceComponent: LockStatus {} + } + + Repeater { + model: ScriptModel { + values: SystemTray.items.values.filter(i => !Config.bar.tray.hiddenIcons.includes(i.id)) + } + + Popout { + id: trayMenu + + required property SystemTrayItem modelData + required property int index + + name: `traymenu${index}` + sourceComponent: trayMenuComp + + Connections { + target: root.wrapper + + function onHasCurrentChanged(): void { + if (root.wrapper.hasCurrent && trayMenu.shouldBeActive) { + trayMenu.sourceComponent = null; + trayMenu.sourceComponent = trayMenuComp; + } + } + } + + Component { + id: trayMenuComp + + TrayMenu { + popouts: root.wrapper + trayItem: trayMenu.modelData.menu + } + } + } + } + } + + component Popout: Loader { + id: popout + + required property string name + readonly property bool shouldBeActive: root.wrapper.currentName === name + + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + + opacity: 0 + scale: 0.8 + active: false + + states: State { + name: "active" + when: popout.shouldBeActive + + PropertyChanges { + popout.active: true + popout.opacity: 1 + popout.scale: 1 + } + } + + transitions: [ + Transition { + from: "active" + to: "" + + SequentialAnimation { + Anim { + properties: "opacity,scale" + duration: Appearance.anim.durations.small + } + PropertyAction { + target: popout + property: "active" + } + } + }, + Transition { + from: "" + to: "active" + + SequentialAnimation { + PropertyAction { + target: popout + property: "active" + } + Anim { + properties: "opacity,scale" + } + } + } + ] + } +} diff --git a/.config/quickshell/caelestia/modules/bar/popouts/LockStatus.qml b/.config/quickshell/caelestia/modules/bar/popouts/LockStatus.qml new file mode 100644 index 0000000..7d74530 --- /dev/null +++ b/.config/quickshell/caelestia/modules/bar/popouts/LockStatus.qml @@ -0,0 +1,16 @@ +import qs.components +import qs.services +import qs.config +import QtQuick.Layouts + +ColumnLayout { + spacing: Appearance.spacing.small + + StyledText { + text: qsTr("Capslock: %1").arg(Hypr.capsLock ? "Enabled" : "Disabled") + } + + StyledText { + text: qsTr("Numlock: %1").arg(Hypr.numLock ? "Enabled" : "Disabled") + } +} diff --git a/.config/quickshell/caelestia/modules/bar/popouts/Network.qml b/.config/quickshell/caelestia/modules/bar/popouts/Network.qml new file mode 100644 index 0000000..5b32e4a --- /dev/null +++ b/.config/quickshell/caelestia/modules/bar/popouts/Network.qml @@ -0,0 +1,388 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.components.controls +import qs.services +import qs.config +import qs.utils +import Quickshell +import QtQuick +import QtQuick.Layouts + +ColumnLayout { + id: root + + required property Item wrapper + + property string connectingToSsid: "" + property string view: "wireless" // "wireless" or "ethernet" + property var passwordNetwork: null + property bool showPasswordDialog: false + + spacing: Appearance.spacing.small + width: Config.bar.sizes.networkWidth + + // Wireless section + StyledText { + visible: root.view === "wireless" + Layout.preferredHeight: visible ? implicitHeight : 0 + Layout.topMargin: visible ? Appearance.padding.normal : 0 + Layout.rightMargin: Appearance.padding.small + text: qsTr("Wireless") + font.weight: 500 + } + + Toggle { + visible: root.view === "wireless" + Layout.preferredHeight: visible ? implicitHeight : 0 + label: qsTr("Enabled") + checked: Nmcli.wifiEnabled + toggle.onToggled: Nmcli.enableWifi(checked) + } + + StyledText { + visible: root.view === "wireless" + Layout.preferredHeight: visible ? implicitHeight : 0 + Layout.topMargin: visible ? Appearance.spacing.small : 0 + Layout.rightMargin: Appearance.padding.small + text: qsTr("%1 networks available").arg(Nmcli.networks.length) + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small + } + + Repeater { + visible: root.view === "wireless" + model: ScriptModel { + values: [...Nmcli.networks].sort((a, b) => { + if (a.active !== b.active) + return b.active - a.active; + return b.strength - a.strength; + }).slice(0, 8) + } + + RowLayout { + id: networkItem + + required property Nmcli.AccessPoint modelData + readonly property bool isConnecting: root.connectingToSsid === modelData.ssid + readonly property bool loading: networkItem.isConnecting + + visible: root.view === "wireless" + Layout.preferredHeight: visible ? implicitHeight : 0 + Layout.fillWidth: true + Layout.rightMargin: Appearance.padding.small + spacing: Appearance.spacing.small + + opacity: 0 + scale: 0.7 + + Component.onCompleted: { + opacity = 1; + scale = 1; + } + + Behavior on opacity { + Anim {} + } + + Behavior on scale { + Anim {} + } + + MaterialIcon { + text: Icons.getNetworkIcon(networkItem.modelData.strength) + color: networkItem.modelData.active ? Colours.palette.m3primary : Colours.palette.m3onSurfaceVariant + } + + MaterialIcon { + visible: networkItem.modelData.isSecure + text: "lock" + font.pointSize: Appearance.font.size.small + } + + StyledText { + Layout.leftMargin: Appearance.spacing.small / 2 + Layout.rightMargin: Appearance.spacing.small / 2 + Layout.fillWidth: true + text: networkItem.modelData.ssid + elide: Text.ElideRight + font.weight: networkItem.modelData.active ? 500 : 400 + color: networkItem.modelData.active ? Colours.palette.m3primary : Colours.palette.m3onSurface + } + + StyledRect { + implicitWidth: implicitHeight + implicitHeight: wirelessConnectIcon.implicitHeight + Appearance.padding.small + + radius: Appearance.rounding.full + color: Qt.alpha(Colours.palette.m3primary, networkItem.modelData.active ? 1 : 0) + + CircularIndicator { + anchors.fill: parent + running: networkItem.loading + } + + StateLayer { + color: networkItem.modelData.active ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface + disabled: networkItem.loading || !Nmcli.wifiEnabled + + function onClicked(): void { + if (networkItem.modelData.active) { + Nmcli.disconnectFromNetwork(); + } else { + root.connectingToSsid = networkItem.modelData.ssid; + NetworkConnection.handleConnect(networkItem.modelData, null, network => { + // Password is required - show password dialog + root.passwordNetwork = network; + root.showPasswordDialog = true; + root.wrapper.currentName = "wirelesspassword"; + }); + + // Clear connecting state if connection succeeds immediately (saved profile) + // This is handled by the onActiveChanged connection below + } + } + } + + MaterialIcon { + id: wirelessConnectIcon + + anchors.centerIn: parent + animate: true + text: networkItem.modelData.active ? "link_off" : "link" + color: networkItem.modelData.active ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface + + opacity: networkItem.loading ? 0 : 1 + + Behavior on opacity { + Anim {} + } + } + } + } + } + + StyledRect { + visible: root.view === "wireless" + Layout.preferredHeight: visible ? implicitHeight : 0 + Layout.topMargin: visible ? Appearance.spacing.small : 0 + Layout.fillWidth: true + implicitHeight: rescanBtn.implicitHeight + Appearance.padding.small * 2 + + radius: Appearance.rounding.full + color: Colours.palette.m3primaryContainer + + StateLayer { + color: Colours.palette.m3onPrimaryContainer + disabled: Nmcli.scanning || !Nmcli.wifiEnabled + + function onClicked(): void { + Nmcli.rescanWifi(); + } + } + + RowLayout { + id: rescanBtn + + anchors.centerIn: parent + spacing: Appearance.spacing.small + opacity: Nmcli.scanning ? 0 : 1 + + MaterialIcon { + id: scanIcon + + Layout.topMargin: Math.round(fontInfo.pointSize * 0.0575) + animate: true + text: "wifi_find" + color: Colours.palette.m3onPrimaryContainer + } + + StyledText { + Layout.topMargin: -Math.round(scanIcon.fontInfo.pointSize * 0.0575) + text: qsTr("Rescan networks") + color: Colours.palette.m3onPrimaryContainer + } + + Behavior on opacity { + Anim {} + } + } + + CircularIndicator { + anchors.centerIn: parent + strokeWidth: Appearance.padding.small / 2 + bgColour: "transparent" + implicitSize: parent.implicitHeight - Appearance.padding.smaller * 2 + running: Nmcli.scanning + } + } + + // Ethernet section + StyledText { + visible: root.view === "ethernet" + Layout.preferredHeight: visible ? implicitHeight : 0 + Layout.topMargin: visible ? Appearance.padding.normal : 0 + Layout.rightMargin: Appearance.padding.small + text: qsTr("Ethernet") + font.weight: 500 + } + + StyledText { + visible: root.view === "ethernet" + Layout.preferredHeight: visible ? implicitHeight : 0 + Layout.topMargin: visible ? Appearance.spacing.small : 0 + Layout.rightMargin: Appearance.padding.small + text: qsTr("%1 devices available").arg(Nmcli.ethernetDevices.length) + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small + } + + Repeater { + visible: root.view === "ethernet" + model: ScriptModel { + values: [...Nmcli.ethernetDevices].sort((a, b) => { + if (a.connected !== b.connected) + return b.connected - a.connected; + return (a.interface || "").localeCompare(b.interface || ""); + }).slice(0, 8) + } + + RowLayout { + id: ethernetItem + + required property var modelData + readonly property bool loading: false + + visible: root.view === "ethernet" + Layout.preferredHeight: visible ? implicitHeight : 0 + Layout.fillWidth: true + Layout.rightMargin: Appearance.padding.small + spacing: Appearance.spacing.small + + opacity: 0 + scale: 0.7 + + Component.onCompleted: { + opacity = 1; + scale = 1; + } + + Behavior on opacity { + Anim {} + } + + Behavior on scale { + Anim {} + } + + MaterialIcon { + text: "cable" + color: ethernetItem.modelData.connected ? Colours.palette.m3primary : Colours.palette.m3onSurfaceVariant + } + + StyledText { + Layout.leftMargin: Appearance.spacing.small / 2 + Layout.rightMargin: Appearance.spacing.small / 2 + Layout.fillWidth: true + text: ethernetItem.modelData.interface || qsTr("Unknown") + elide: Text.ElideRight + font.weight: ethernetItem.modelData.connected ? 500 : 400 + color: ethernetItem.modelData.connected ? Colours.palette.m3primary : Colours.palette.m3onSurface + } + + StyledRect { + implicitWidth: implicitHeight + implicitHeight: connectIcon.implicitHeight + Appearance.padding.small + + radius: Appearance.rounding.full + color: Qt.alpha(Colours.palette.m3primary, ethernetItem.modelData.connected ? 1 : 0) + + CircularIndicator { + anchors.fill: parent + running: ethernetItem.loading + } + + StateLayer { + color: ethernetItem.modelData.connected ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface + disabled: ethernetItem.loading + + function onClicked(): void { + if (ethernetItem.modelData.connected && ethernetItem.modelData.connection) { + Nmcli.disconnectEthernet(ethernetItem.modelData.connection, () => {}); + } else { + Nmcli.connectEthernet(ethernetItem.modelData.connection || "", ethernetItem.modelData.interface || "", () => {}); + } + } + } + + MaterialIcon { + id: connectIcon + + anchors.centerIn: parent + animate: true + text: ethernetItem.modelData.connected ? "link_off" : "link" + color: ethernetItem.modelData.connected ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface + + opacity: ethernetItem.loading ? 0 : 1 + + Behavior on opacity { + Anim {} + } + } + } + } + } + + Connections { + target: Nmcli + + function onActiveChanged(): void { + if (Nmcli.active && root.connectingToSsid === Nmcli.active.ssid) { + root.connectingToSsid = ""; + // Close password dialog if we successfully connected + if (root.showPasswordDialog && root.passwordNetwork && Nmcli.active.ssid === root.passwordNetwork.ssid) { + root.showPasswordDialog = false; + root.passwordNetwork = null; + if (root.wrapper.currentName === "wirelesspassword") { + root.wrapper.currentName = "network"; + } + } + } + } + + function onScanningChanged(): void { + if (!Nmcli.scanning) + scanIcon.rotation = 0; + } + } + + Connections { + target: root.wrapper + function onCurrentNameChanged(): void { + // Clear password network when leaving password dialog + if (root.wrapper.currentName !== "wirelesspassword" && root.showPasswordDialog) { + root.showPasswordDialog = false; + root.passwordNetwork = null; + } + } + } + + component Toggle: RowLayout { + required property string label + property alias checked: toggle.checked + property alias toggle: toggle + + Layout.fillWidth: true + Layout.rightMargin: Appearance.padding.small + spacing: Appearance.spacing.normal + + StyledText { + Layout.fillWidth: true + text: parent.label + } + + StyledSwitch { + id: toggle + } + } +} diff --git a/.config/quickshell/caelestia/modules/bar/popouts/TrayMenu.qml b/.config/quickshell/caelestia/modules/bar/popouts/TrayMenu.qml new file mode 100644 index 0000000..9b743db --- /dev/null +++ b/.config/quickshell/caelestia/modules/bar/popouts/TrayMenu.qml @@ -0,0 +1,225 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.services +import qs.config +import Quickshell +import Quickshell.Widgets +import QtQuick +import QtQuick.Controls + +StackView { + id: root + + required property Item popouts + required property QsMenuHandle trayItem + + implicitWidth: currentItem.implicitWidth + implicitHeight: currentItem.implicitHeight + + initialItem: SubMenu { + handle: root.trayItem + } + + pushEnter: NoAnim {} + pushExit: NoAnim {} + popEnter: NoAnim {} + popExit: NoAnim {} + + component NoAnim: Transition { + NumberAnimation { + duration: 0 + } + } + + component SubMenu: Column { + id: menu + + required property QsMenuHandle handle + property bool isSubMenu + property bool shown + + padding: Appearance.padding.smaller + spacing: Appearance.spacing.small + + opacity: shown ? 1 : 0 + scale: shown ? 1 : 0.8 + + Component.onCompleted: shown = true + StackView.onActivating: shown = true + StackView.onDeactivating: shown = false + StackView.onRemoved: destroy() + + Behavior on opacity { + Anim {} + } + + Behavior on scale { + Anim {} + } + + QsMenuOpener { + id: menuOpener + + menu: menu.handle + } + + Repeater { + model: menuOpener.children + + StyledRect { + id: item + + required property QsMenuEntry modelData + + implicitWidth: Config.bar.sizes.trayMenuWidth + implicitHeight: modelData.isSeparator ? 1 : children.implicitHeight + + radius: Appearance.rounding.full + color: modelData.isSeparator ? Colours.palette.m3outlineVariant : "transparent" + + Loader { + id: children + + anchors.left: parent.left + anchors.right: parent.right + + active: !item.modelData.isSeparator + + sourceComponent: Item { + implicitHeight: label.implicitHeight + + StateLayer { + anchors.margins: -Appearance.padding.small / 2 + anchors.leftMargin: -Appearance.padding.smaller + anchors.rightMargin: -Appearance.padding.smaller + + radius: item.radius + disabled: !item.modelData.enabled + + function onClicked(): void { + const entry = item.modelData; + if (entry.hasChildren) + root.push(subMenuComp.createObject(null, { + handle: entry, + isSubMenu: true + })); + else { + item.modelData.triggered(); + root.popouts.hasCurrent = false; + } + } + } + + Loader { + id: icon + + anchors.left: parent.left + + active: item.modelData.icon !== "" + + sourceComponent: IconImage { + implicitSize: label.implicitHeight + + source: item.modelData.icon + } + } + + StyledText { + id: label + + anchors.left: icon.right + anchors.leftMargin: icon.active ? Appearance.spacing.smaller : 0 + + text: labelMetrics.elidedText + color: item.modelData.enabled ? Colours.palette.m3onSurface : Colours.palette.m3outline + } + + TextMetrics { + id: labelMetrics + + text: item.modelData.text + font.pointSize: label.font.pointSize + font.family: label.font.family + + elide: Text.ElideRight + elideWidth: Config.bar.sizes.trayMenuWidth - (icon.active ? icon.implicitWidth + label.anchors.leftMargin : 0) - (expand.active ? expand.implicitWidth + Appearance.spacing.normal : 0) + } + + Loader { + id: expand + + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + + active: item.modelData.hasChildren + + sourceComponent: MaterialIcon { + text: "chevron_right" + color: item.modelData.enabled ? Colours.palette.m3onSurface : Colours.palette.m3outline + } + } + } + } + } + } + + Loader { + active: menu.isSubMenu + + sourceComponent: Item { + implicitWidth: back.implicitWidth + implicitHeight: back.implicitHeight + Appearance.spacing.small / 2 + + Item { + anchors.bottom: parent.bottom + implicitWidth: back.implicitWidth + implicitHeight: back.implicitHeight + + StyledRect { + anchors.fill: parent + anchors.margins: -Appearance.padding.small / 2 + anchors.leftMargin: -Appearance.padding.smaller + anchors.rightMargin: -Appearance.padding.smaller * 2 + + radius: Appearance.rounding.full + color: Colours.palette.m3secondaryContainer + + StateLayer { + radius: parent.radius + color: Colours.palette.m3onSecondaryContainer + + function onClicked(): void { + root.pop(); + } + } + } + + Row { + id: back + + anchors.verticalCenter: parent.verticalCenter + + MaterialIcon { + anchors.verticalCenter: parent.verticalCenter + text: "chevron_left" + color: Colours.palette.m3onSecondaryContainer + } + + StyledText { + anchors.verticalCenter: parent.verticalCenter + text: qsTr("Back") + color: Colours.palette.m3onSecondaryContainer + } + } + } + } + } + } + + Component { + id: subMenuComp + + SubMenu {} + } +} diff --git a/.config/quickshell/caelestia/modules/bar/popouts/WirelessPassword.qml b/.config/quickshell/caelestia/modules/bar/popouts/WirelessPassword.qml new file mode 100644 index 0000000..96639e7 --- /dev/null +++ b/.config/quickshell/caelestia/modules/bar/popouts/WirelessPassword.qml @@ -0,0 +1,605 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.components.controls +import qs.services +import qs.config +import qs.utils +import Quickshell +import QtQuick +import QtQuick.Layouts + +ColumnLayout { + id: root + + required property Item wrapper + property var network: null + property bool isClosing: false + + readonly property bool shouldBeVisible: root.wrapper.currentName === "wirelesspassword" + + Connections { + target: root.wrapper + function onCurrentNameChanged() { + if (root.wrapper.currentName === "wirelesspassword") { + // Update network when popout becomes active + Qt.callLater(() => { + // Try to get network from parent Content's networkPopout + const content = root.parent?.parent?.parent; + if (content) { + const networkPopout = content.children.find(c => c.name === "network"); + if (networkPopout && networkPopout.item) { + root.network = networkPopout.item.passwordNetwork; + } + } + // Force focus to password container when popout becomes active + // Use Timer for actual delay to ensure dialog is fully rendered + focusTimer.start(); + }); + } + } + } + + Timer { + id: focusTimer + interval: 150 + onTriggered: { + root.forceActiveFocus(); + passwordContainer.forceActiveFocus(); + } + } + + spacing: Appearance.spacing.normal + + implicitWidth: 400 + implicitHeight: content.implicitHeight + Appearance.padding.large * 2 + + visible: shouldBeVisible || isClosing + enabled: shouldBeVisible && !isClosing + focus: enabled + + Component.onCompleted: { + if (shouldBeVisible) { + // Use Timer for actual delay to ensure dialog is fully rendered + focusTimer.start(); + } + } + + onShouldBeVisibleChanged: { + if (shouldBeVisible) { + // Use Timer for actual delay to ensure dialog is fully rendered + focusTimer.start(); + } + } + + Keys.onEscapePressed: closeDialog() + + StyledRect { + Layout.fillWidth: true + Layout.preferredWidth: 400 + implicitHeight: content.implicitHeight + Appearance.padding.large * 2 + + radius: Appearance.rounding.normal + color: Colours.tPalette.m3surfaceContainer + visible: root.shouldBeVisible || root.isClosing + opacity: root.shouldBeVisible && !root.isClosing ? 1 : 0 + scale: root.shouldBeVisible && !root.isClosing ? 1 : 0.7 + + Behavior on opacity { + Anim {} + } + + Behavior on scale { + Anim {} + } + + ParallelAnimation { + running: root.isClosing + onFinished: { + if (root.isClosing) { + root.isClosing = false; + } + } + + Anim { + target: parent + property: "opacity" + to: 0 + } + Anim { + target: parent + property: "scale" + to: 0.7 + } + } + + Keys.onEscapePressed: root.closeDialog() + + ColumnLayout { + id: content + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Appearance.padding.large + + spacing: Appearance.spacing.normal + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + text: "lock" + font.pointSize: Appearance.font.size.extraLarge * 2 + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: qsTr("Enter password") + font.pointSize: Appearance.font.size.large + font.weight: 500 + } + + StyledText { + id: networkNameText + Layout.alignment: Qt.AlignHCenter + text: { + if (root.network) { + const ssid = root.network.ssid; + if (ssid && ssid.length > 0) { + return qsTr("Network: %1").arg(ssid); + } + } + return qsTr("Network: Unknown"); + } + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.small + } + + Timer { + interval: 50 + running: root.shouldBeVisible && (!root.network || !root.network.ssid) + repeat: true + property int attempts: 0 + onTriggered: { + attempts++; + // Keep trying to get network from Network component + const content = root.parent?.parent?.parent; + if (content) { + const networkPopout = content.children.find(c => c.name === "network"); + if (networkPopout && networkPopout.item && networkPopout.item.passwordNetwork) { + root.network = networkPopout.item.passwordNetwork; + } + } + // Stop if we got it or after 20 attempts (1 second) + if ((root.network && root.network.ssid) || attempts >= 20) { + stop(); + attempts = 0; + } + } + onRunningChanged: { + if (!running) { + attempts = 0; + } + } + } + + StyledText { + id: statusText + + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: Appearance.spacing.small + visible: connectButton.connecting || connectButton.hasError + text: { + if (connectButton.hasError) { + return qsTr("Connection failed. Please check your password and try again."); + } + if (connectButton.connecting) { + return qsTr("Connecting..."); + } + return ""; + } + color: connectButton.hasError ? Colours.palette.m3error : Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small + font.weight: 400 + wrapMode: Text.WordWrap + Layout.maximumWidth: parent.width - Appearance.padding.large * 2 + } + + FocusScope { + id: passwordContainer + objectName: "passwordContainer" + Layout.topMargin: Appearance.spacing.large + Layout.fillWidth: true + implicitHeight: Math.max(48, charList.implicitHeight + Appearance.padding.normal * 2) + + focus: true + activeFocusOnTab: true + + property string passwordBuffer: "" + + Keys.onPressed: event => { + // Ensure we have focus when receiving keyboard input + if (!activeFocus) { + forceActiveFocus(); + } + + // Clear error when user starts typing + if (connectButton.hasError && event.text && event.text.length > 0) { + connectButton.hasError = false; + } + + if (event.key === Qt.Key_Enter || event.key === Qt.Key_Return) { + if (connectButton.enabled) { + connectButton.clicked(); + } + event.accepted = true; + } else if (event.key === Qt.Key_Backspace) { + if (event.modifiers & Qt.ControlModifier) { + passwordBuffer = ""; + } else { + passwordBuffer = passwordBuffer.slice(0, -1); + } + event.accepted = true; + } else if (event.text && event.text.length > 0) { + passwordBuffer += event.text; + event.accepted = true; + } + } + + Connections { + target: root + function onShouldBeVisibleChanged(): void { + if (root.shouldBeVisible) { + // Use Timer for actual delay to ensure focus works correctly + passwordFocusTimer.start(); + passwordContainer.passwordBuffer = ""; + connectButton.hasError = false; + } + } + } + + Timer { + id: passwordFocusTimer + interval: 50 + onTriggered: { + passwordContainer.forceActiveFocus(); + } + } + + Component.onCompleted: { + if (root.shouldBeVisible) { + // Use Timer for actual delay to ensure focus works correctly + passwordFocusTimer.start(); + } + } + + StyledRect { + anchors.fill: parent + radius: Appearance.rounding.normal + color: passwordContainer.activeFocus ? Qt.lighter(Colours.tPalette.m3surfaceContainer, 1.05) : Colours.tPalette.m3surfaceContainer + border.width: passwordContainer.activeFocus || connectButton.hasError ? 4 : (root.shouldBeVisible ? 1 : 0) + border.color: { + if (connectButton.hasError) { + return Colours.palette.m3error; + } + if (passwordContainer.activeFocus) { + return Colours.palette.m3primary; + } + return root.shouldBeVisible ? Colours.palette.m3outline : "transparent"; + } + + Behavior on border.color { + CAnim {} + } + + Behavior on border.width { + CAnim {} + } + + Behavior on color { + CAnim {} + } + } + + StateLayer { + hoverEnabled: false + cursorShape: Qt.IBeamCursor + radius: Appearance.rounding.normal + + function onClicked(): void { + passwordContainer.forceActiveFocus(); + } + } + + StyledText { + id: placeholder + + anchors.centerIn: parent + text: qsTr("Password") + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.normal + font.family: Appearance.font.family.mono + opacity: passwordContainer.passwordBuffer ? 0 : 1 + + Behavior on opacity { + Anim {} + } + } + + ListView { + id: charList + + readonly property int fullWidth: count * (implicitHeight + spacing) - spacing + + anchors.centerIn: parent + implicitWidth: fullWidth + implicitHeight: Appearance.font.size.normal + + orientation: Qt.Horizontal + spacing: Appearance.spacing.small / 2 + interactive: false + + model: ScriptModel { + values: passwordContainer.passwordBuffer.split("") + } + + delegate: StyledRect { + id: ch + + implicitWidth: implicitHeight + implicitHeight: charList.implicitHeight + + color: Colours.palette.m3onSurface + radius: Appearance.rounding.small / 2 + + opacity: 0 + scale: 0 + Component.onCompleted: { + opacity = 1; + scale = 1; + } + ListView.onRemove: removeAnim.start() + + SequentialAnimation { + id: removeAnim + + PropertyAction { + target: ch + property: "ListView.delayRemove" + value: true + } + ParallelAnimation { + Anim { + target: ch + property: "opacity" + to: 0 + } + Anim { + target: ch + property: "scale" + to: 0.5 + } + } + PropertyAction { + target: ch + property: "ListView.delayRemove" + value: false + } + } + + Behavior on opacity { + Anim {} + } + + Behavior on scale { + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + } + } + } + + Behavior on implicitWidth { + Anim {} + } + } + } + + RowLayout { + Layout.topMargin: Appearance.spacing.normal + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + TextButton { + id: cancelButton + + Layout.fillWidth: true + Layout.minimumHeight: Appearance.font.size.normal + Appearance.padding.normal * 2 + inactiveColour: Colours.palette.m3secondaryContainer + inactiveOnColour: Colours.palette.m3onSecondaryContainer + text: qsTr("Cancel") + + onClicked: root.closeDialog() + } + + TextButton { + id: connectButton + + property bool connecting: false + property bool hasError: false + + Layout.fillWidth: true + Layout.minimumHeight: Appearance.font.size.normal + Appearance.padding.normal * 2 + inactiveColour: Colours.palette.m3primary + inactiveOnColour: Colours.palette.m3onPrimary + text: qsTr("Connect") + enabled: passwordContainer.passwordBuffer.length > 0 && !connecting + + onClicked: { + if (!root.network || connecting) { + return; + } + + const password = passwordContainer.passwordBuffer; + if (!password || password.length === 0) { + return; + } + + // Clear any previous error + hasError = false; + + // Set connecting state + connecting = true; + enabled = false; + text = qsTr("Connecting..."); + + // Connect to network + NetworkConnection.connectWithPassword(root.network, password, result => { + if (result && result.success) + // Connection successful, monitor will handle the rest + {} else if (result && result.needsPassword) { + // Shouldn't happen since we provided password + connectionMonitor.stop(); + connecting = false; + hasError = true; + enabled = true; + text = qsTr("Connect"); + passwordContainer.passwordBuffer = ""; + // Delete the failed connection + if (root.network && root.network.ssid) { + Nmcli.forgetNetwork(root.network.ssid); + } + } else { + // Connection failed immediately - show error + connectionMonitor.stop(); + connecting = false; + hasError = true; + enabled = true; + text = qsTr("Connect"); + passwordContainer.passwordBuffer = ""; + // Delete the failed connection + if (root.network && root.network.ssid) { + Nmcli.forgetNetwork(root.network.ssid); + } + } + }); + + // Start monitoring connection + connectionMonitor.start(); + } + } + } + } + } + + function checkConnectionStatus(): void { + if (!root.shouldBeVisible || !connectButton.connecting) { + return; + } + + // Check if we're connected to the target network (case-insensitive SSID comparison) + const isConnected = root.network && Nmcli.active && Nmcli.active.ssid && Nmcli.active.ssid.toLowerCase().trim() === root.network.ssid.toLowerCase().trim(); + + if (isConnected) { + // Successfully connected - give it a moment for network list to update + // Use Timer for actual delay + connectionSuccessTimer.start(); + return; + } + + // Check for connection failures - if pending connection was cleared but we're not connected + if (Nmcli.pendingConnection === null && connectButton.connecting) { + // Wait a bit more before giving up (allow time for connection to establish) + if (connectionMonitor.repeatCount > 10) { + connectionMonitor.stop(); + connectButton.connecting = false; + connectButton.hasError = true; + connectButton.enabled = true; + connectButton.text = qsTr("Connect"); + passwordContainer.passwordBuffer = ""; + // Delete the failed connection + if (root.network && root.network.ssid) { + Nmcli.forgetNetwork(root.network.ssid); + } + } + } + } + + Timer { + id: connectionMonitor + interval: 1000 + repeat: true + triggeredOnStart: false + property int repeatCount: 0 + + onTriggered: { + repeatCount++; + root.checkConnectionStatus(); + } + + onRunningChanged: { + if (!running) { + repeatCount = 0; + } + } + } + + Timer { + id: connectionSuccessTimer + interval: 500 + onTriggered: { + // Double-check connection is still active + if (root.shouldBeVisible && Nmcli.active && Nmcli.active.ssid) { + const stillConnected = Nmcli.active.ssid.toLowerCase().trim() === root.network.ssid.toLowerCase().trim(); + if (stillConnected) { + connectionMonitor.stop(); + connectButton.connecting = false; + connectButton.text = qsTr("Connect"); + // Return to network popout on successful connection + if (root.wrapper.currentName === "wirelesspassword") { + root.wrapper.currentName = "network"; + } + closeDialog(); + } + } + } + } + + Connections { + target: Nmcli + function onActiveChanged() { + if (root.shouldBeVisible) { + root.checkConnectionStatus(); + } + } + function onConnectionFailed(ssid: string) { + if (root.shouldBeVisible && root.network && root.network.ssid === ssid && connectButton.connecting) { + connectionMonitor.stop(); + connectButton.connecting = false; + connectButton.hasError = true; + connectButton.enabled = true; + connectButton.text = qsTr("Connect"); + passwordContainer.passwordBuffer = ""; + // Delete the failed connection + Nmcli.forgetNetwork(ssid); + } + } + } + + function closeDialog(): void { + if (isClosing) { + return; + } + + isClosing = true; + passwordContainer.passwordBuffer = ""; + connectButton.connecting = false; + connectButton.hasError = false; + connectButton.text = qsTr("Connect"); + connectionMonitor.stop(); + + // Return to network popout + if (root.wrapper.currentName === "wirelesspassword") { + root.wrapper.currentName = "network"; + } + } +} diff --git a/.config/quickshell/caelestia/modules/bar/popouts/Wrapper.qml b/.config/quickshell/caelestia/modules/bar/popouts/Wrapper.qml new file mode 100644 index 0000000..05a1d3c --- /dev/null +++ b/.config/quickshell/caelestia/modules/bar/popouts/Wrapper.qml @@ -0,0 +1,215 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.services +import qs.config +import qs.modules.windowinfo +import qs.modules.controlcenter +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland +import QtQuick + +Item { + id: root + + required property ShellScreen screen + + readonly property real nonAnimWidth: x > 0 || hasCurrent ? children.find(c => c.shouldBeActive)?.implicitWidth ?? content.implicitWidth : 0 + readonly property real nonAnimHeight: children.find(c => c.shouldBeActive)?.implicitHeight ?? content.implicitHeight + readonly property Item current: content.item?.current ?? null + + property string currentName + property real currentCenter + property bool hasCurrent + + property string detachedMode + property string queuedMode + readonly property bool isDetached: detachedMode.length > 0 + + property int animLength: Appearance.anim.durations.normal + property list animCurve: Appearance.anim.curves.emphasized + + function detach(mode: string): void { + animLength = Appearance.anim.durations.large; + if (mode === "winfo") { + detachedMode = mode; + } else { + queuedMode = mode; + detachedMode = "any"; + } + focus = true; + } + + function close(): void { + hasCurrent = false; + animCurve = Appearance.anim.curves.emphasizedAccel; + animLength = Appearance.anim.durations.normal; + detachedMode = ""; + animCurve = Appearance.anim.curves.emphasized; + } + + visible: width > 0 && height > 0 + clip: true + + implicitWidth: nonAnimWidth + implicitHeight: nonAnimHeight + + focus: hasCurrent + Keys.onEscapePressed: { + // Forward escape to password popout if active, otherwise close + if (currentName === "wirelesspassword" && content.item) { + const passwordPopout = content.item.children.find(c => c.name === "wirelesspassword"); + if (passwordPopout && passwordPopout.item) { + passwordPopout.item.closeDialog(); + return; + } + } + close(); + } + + Keys.onPressed: event => { + // Don't intercept keys when password popout is active - let it handle them + if (currentName === "wirelesspassword") { + event.accepted = false; + } + } + + HyprlandFocusGrab { + active: root.isDetached + windows: [QsWindow.window] + onCleared: root.close() + } + + Binding { + when: root.isDetached + + target: QsWindow.window + property: "WlrLayershell.keyboardFocus" + value: WlrKeyboardFocus.OnDemand + } + + Binding { + when: root.hasCurrent && root.currentName === "wirelesspassword" + + target: QsWindow.window + property: "WlrLayershell.keyboardFocus" + value: WlrKeyboardFocus.OnDemand + } + + Comp { + id: content + + shouldBeActive: root.hasCurrent && !root.detachedMode + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + + sourceComponent: Content { + wrapper: root + } + } + + Comp { + shouldBeActive: root.detachedMode === "winfo" + anchors.centerIn: parent + + sourceComponent: WindowInfo { + screen: root.screen + client: Hypr.activeToplevel + } + } + + Comp { + shouldBeActive: root.detachedMode === "any" + anchors.centerIn: parent + + sourceComponent: ControlCenter { + screen: root.screen + active: root.queuedMode + + function close(): void { + root.close(); + } + } + } + + Behavior on x { + Anim { + duration: root.animLength + easing.bezierCurve: root.animCurve + } + } + + Behavior on y { + enabled: root.implicitWidth > 0 + + Anim { + duration: root.animLength + easing.bezierCurve: root.animCurve + } + } + + Behavior on implicitWidth { + Anim { + duration: root.animLength + easing.bezierCurve: root.animCurve + } + } + + Behavior on implicitHeight { + enabled: root.implicitWidth > 0 + + Anim { + duration: root.animLength + easing.bezierCurve: root.animCurve + } + } + + component Comp: Loader { + id: comp + + property bool shouldBeActive + + active: false + opacity: 0 + + states: State { + name: "active" + when: comp.shouldBeActive + + PropertyChanges { + comp.opacity: 1 + comp.active: true + } + } + + transitions: [ + Transition { + from: "" + to: "active" + + SequentialAnimation { + PropertyAction { + property: "active" + } + Anim { + property: "opacity" + } + } + }, + Transition { + from: "active" + to: "" + + SequentialAnimation { + Anim { + property: "opacity" + } + PropertyAction { + property: "active" + } + } + } + ] + } +} diff --git a/.config/quickshell/caelestia/modules/bar/popouts/kblayout/KbLayout.qml b/.config/quickshell/caelestia/modules/bar/popouts/kblayout/KbLayout.qml new file mode 100644 index 0000000..94b6f7e --- /dev/null +++ b/.config/quickshell/caelestia/modules/bar/popouts/kblayout/KbLayout.qml @@ -0,0 +1,211 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import qs.components +import qs.components.controls +import qs.services +import qs.config +import qs.utils + +import "." + +ColumnLayout { + id: root + + required property Item wrapper + + spacing: Appearance.spacing.small + width: Config.bar.sizes.kbLayoutWidth + + KbLayoutModel { + id: kb + } + + function refresh() { + kb.refresh(); + } + Component.onCompleted: kb.start() + + StyledText { + Layout.topMargin: Appearance.padding.normal + Layout.rightMargin: Appearance.padding.small + text: qsTr("Keyboard Layouts") + font.weight: 500 + } + + ListView { + id: list + model: kb.visibleModel + + Layout.fillWidth: true + Layout.rightMargin: Appearance.padding.small + Layout.topMargin: Appearance.spacing.small + + clip: true + interactive: true + implicitHeight: Math.min(contentHeight, 320) + visible: kb.visibleModel.count > 0 + spacing: Appearance.spacing.small + + add: Transition { + NumberAnimation { + properties: "opacity" + from: 0 + to: 1 + duration: 140 + } + NumberAnimation { + properties: "y" + duration: 180 + easing.type: Easing.OutCubic + } + } + remove: Transition { + NumberAnimation { + properties: "opacity" + to: 0 + duration: 100 + } + } + move: Transition { + NumberAnimation { + properties: "y" + duration: 180 + easing.type: Easing.OutCubic + } + } + displaced: Transition { + NumberAnimation { + properties: "y" + duration: 180 + easing.type: Easing.OutCubic + } + } + + delegate: Item { + required property int layoutIndex + required property string label + + width: list.width + height: Math.max(36, rowText.implicitHeight + Appearance.padding.small * 2) + + readonly property bool isDisabled: layoutIndex > 3 + + StateLayer { + id: layer + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + implicitHeight: parent.height - 4 + + radius: Appearance.rounding.full + enabled: !isDisabled + + function onClicked(): void { + if (!isDisabled) + kb.switchTo(layoutIndex); + } + } + + StyledText { + id: rowText + anchors.verticalCenter: layer.verticalCenter + anchors.left: layer.left + anchors.right: layer.right + anchors.leftMargin: Appearance.padding.small + anchors.rightMargin: Appearance.padding.small + text: label + elide: Text.ElideRight + opacity: isDisabled ? 0.4 : 1.0 + } + + ToolTip.visible: isDisabled && layer.containsMouse + ToolTip.text: "XKB limitation: maximum 4 layouts allowed" + } + } + + Rectangle { + visible: kb.activeLabel.length > 0 + Layout.fillWidth: true + Layout.rightMargin: Appearance.padding.small + Layout.topMargin: Appearance.spacing.small + + height: 1 + color: Colours.palette.m3onSurfaceVariant + opacity: 0.35 + } + + RowLayout { + id: activeRow + + visible: kb.activeLabel.length > 0 + Layout.fillWidth: true + Layout.rightMargin: Appearance.padding.small + Layout.topMargin: Appearance.spacing.small + spacing: Appearance.spacing.small + + opacity: 1 + scale: 1 + + MaterialIcon { + text: "keyboard" + color: Colours.palette.m3primary + } + + StyledText { + Layout.fillWidth: true + text: kb.activeLabel + elide: Text.ElideRight + font.weight: 500 + color: Colours.palette.m3primary + } + + Connections { + target: kb + function onActiveLabelChanged() { + if (!activeRow.visible) + return; + popIn.restart(); + } + } + + SequentialAnimation { + id: popIn + running: false + + ParallelAnimation { + NumberAnimation { + target: activeRow + property: "opacity" + to: 0.0 + duration: 70 + } + NumberAnimation { + target: activeRow + property: "scale" + to: 0.92 + duration: 70 + } + } + + ParallelAnimation { + NumberAnimation { + target: activeRow + property: "opacity" + to: 1.0 + duration: 160 + easing.type: Easing.OutCubic + } + NumberAnimation { + target: activeRow + property: "scale" + to: 1.0 + duration: 220 + easing.type: Easing.OutBack + } + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/bar/popouts/kblayout/KbLayoutModel.qml b/.config/quickshell/caelestia/modules/bar/popouts/kblayout/KbLayoutModel.qml new file mode 100644 index 0000000..4371095 --- /dev/null +++ b/.config/quickshell/caelestia/modules/bar/popouts/kblayout/KbLayoutModel.qml @@ -0,0 +1,216 @@ +pragma ComponentBehavior: Bound + +import QtQuick + +import Quickshell +import Quickshell.Io + +import qs.config +import Caelestia + +Item { + id: model + visible: false + + ListModel { + id: _visibleModel + } + property alias visibleModel: _visibleModel + + property string activeLabel: "" + property int activeIndex: -1 + + function start() { + _xkbXmlBase.running = true; + _getKbLayoutOpt.running = true; + } + + function refresh() { + _notifiedLimit = false; + _getKbLayoutOpt.running = true; + } + + function switchTo(idx) { + _switchProc.command = ["hyprctl", "switchxkblayout", "all", String(idx)]; + _switchProc.running = true; + } + + ListModel { + id: _layoutsModel + } + + property var _xkbMap: ({}) + property bool _notifiedLimit: false + + Process { + id: _xkbXmlBase + command: ["xmllint", "--xpath", "//layout/configItem[name and description]", "/usr/share/X11/xkb/rules/base.xml"] + stdout: StdioCollector { + onStreamFinished: _buildXmlMap(text) + } + onRunningChanged: if (!running && (typeof exitCode !== "undefined") && exitCode !== 0) + _xkbXmlEvdev.running = true + } + + Process { + id: _xkbXmlEvdev + command: ["xmllint", "--xpath", "//layout/configItem[name and description]", "/usr/share/X11/xkb/rules/evdev.xml"] + stdout: StdioCollector { + onStreamFinished: _buildXmlMap(text) + } + } + + function _buildXmlMap(xml) { + const map = {}; + + const re = /\s*([^<]+?)\s*<\/name>[\s\S]*?\s*([^<]+?)\s*<\/description>/g; + + let m; + while ((m = re.exec(xml)) !== null) { + const code = (m[1] || "").trim(); + const desc = (m[2] || "").trim(); + if (!code || !desc) + continue; + map[code] = _short(desc); + } + + if (Object.keys(map).length === 0) + return; + + _xkbMap = map; + + if (_layoutsModel.count > 0) { + const tmp = []; + for (let i = 0; i < _layoutsModel.count; i++) { + const it = _layoutsModel.get(i); + tmp.push({ + layoutIndex: it.layoutIndex, + token: it.token, + label: _pretty(it.token) + }); + } + _layoutsModel.clear(); + tmp.forEach(t => _layoutsModel.append(t)); + _fetchActiveLayouts.running = true; + } + } + + function _short(desc) { + const m = desc.match(/^(.*)\((.*)\)$/); + if (!m) + return desc; + const lang = m[1].trim(); + const region = m[2].trim(); + const code = (region.split(/[,\s-]/)[0] || region).slice(0, 2).toUpperCase(); + return `${lang} (${code})`; + } + + Process { + id: _getKbLayoutOpt + command: ["hyprctl", "-j", "getoption", "input:kb_layout"] + stdout: StdioCollector { + onStreamFinished: { + try { + const j = JSON.parse(text); + const raw = (j?.str || j?.value || "").toString().trim(); + if (raw.length) { + _setLayouts(raw); + _fetchActiveLayouts.running = true; + return; + } + } catch (e) {} + _fetchLayoutsFromDevices.running = true; + } + } + } + + Process { + id: _fetchLayoutsFromDevices + command: ["hyprctl", "-j", "devices"] + stdout: StdioCollector { + onStreamFinished: { + try { + const dev = JSON.parse(text); + const kb = dev?.keyboards?.find(k => k.main) || dev?.keyboards?.[0]; + const raw = (kb?.layout || "").trim(); + if (raw.length) + _setLayouts(raw); + } catch (e) {} + _fetchActiveLayouts.running = true; + } + } + } + + Process { + id: _fetchActiveLayouts + command: ["hyprctl", "-j", "devices"] + stdout: StdioCollector { + onStreamFinished: { + try { + const dev = JSON.parse(text); + const kb = dev?.keyboards?.find(k => k.main) || dev?.keyboards?.[0]; + const idx = kb?.active_layout_index ?? -1; + + activeIndex = idx >= 0 ? idx : -1; + activeLabel = (idx >= 0 && idx < _layoutsModel.count) ? _layoutsModel.get(idx).label : ""; + } catch (e) { + activeIndex = -1; + activeLabel = ""; + } + + _rebuildVisible(); + } + } + } + + Process { + id: _switchProc + onRunningChanged: if (!running) + _fetchActiveLayouts.running = true + } + + function _setLayouts(raw) { + const parts = raw.split(",").map(s => s.trim()).filter(Boolean); + _layoutsModel.clear(); + + const seen = new Set(); + let idx = 0; + + for (const p of parts) { + if (seen.has(p)) + continue; + seen.add(p); + _layoutsModel.append({ + layoutIndex: idx, + token: p, + label: _pretty(p) + }); + idx++; + } + } + + function _rebuildVisible() { + _visibleModel.clear(); + + let arr = []; + for (let i = 0; i < _layoutsModel.count; i++) + arr.push(_layoutsModel.get(i)); + + arr = arr.filter(i => i.layoutIndex !== activeIndex); + arr.forEach(i => _visibleModel.append(i)); + + if (!Config.utilities.toasts.kbLimit) + return; + + if (_layoutsModel.count > 4) { + Toaster.toast(qsTr("Keyboard layout limit"), qsTr("XKB supports only 4 layouts at a time"), "warning"); + } + } + + function _pretty(token) { + const code = token.replace(/\(.*\)$/, "").trim(); + if (_xkbMap[code]) + return code.toUpperCase() + " - " + _xkbMap[code]; + return code.toUpperCase() + " - " + code; + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/ControlCenter.qml b/.config/quickshell/caelestia/modules/controlcenter/ControlCenter.qml new file mode 100644 index 0000000..4aacfad --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/ControlCenter.qml @@ -0,0 +1,100 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.components.controls +import qs.services +import qs.config +import Quickshell +import QtQuick +import QtQuick.Layouts + +Item { + id: root + + required property ShellScreen screen + readonly property int rounding: floating ? 0 : Appearance.rounding.normal + + property alias floating: session.floating + property alias active: session.active + property alias navExpanded: session.navExpanded + + readonly property Session session: Session { + id: session + + root: root + } + + function close(): void { + } + + implicitWidth: implicitHeight * Config.controlCenter.sizes.ratio + implicitHeight: screen.height * Config.controlCenter.sizes.heightMult + + GridLayout { + anchors.fill: parent + + rowSpacing: 0 + columnSpacing: 0 + rows: root.floating ? 2 : 1 + columns: 2 + + Loader { + Layout.fillWidth: true + Layout.columnSpan: 2 + + active: root.floating + visible: active + + sourceComponent: WindowTitle { + screen: root.screen + session: root.session + } + } + + StyledRect { + Layout.fillHeight: true + + topLeftRadius: root.rounding + bottomLeftRadius: root.rounding + implicitWidth: navRail.implicitWidth + color: Colours.tPalette.m3surfaceContainer + + CustomMouseArea { + anchors.fill: parent + + function onWheel(event: WheelEvent): void { + // Prevent tab switching during initial opening animation to avoid blank pages + if (!panes.initialOpeningComplete) { + return; + } + + if (event.angleDelta.y < 0) + root.session.activeIndex = Math.min(root.session.activeIndex + 1, root.session.panes.length - 1); + else if (event.angleDelta.y > 0) + root.session.activeIndex = Math.max(root.session.activeIndex - 1, 0); + } + } + + NavRail { + id: navRail + + screen: root.screen + session: root.session + initialOpeningComplete: root.initialOpeningComplete + } + } + + Panes { + id: panes + + Layout.fillWidth: true + Layout.fillHeight: true + + topRightRadius: root.rounding + bottomRightRadius: root.rounding + session: root.session + } + } + + readonly property bool initialOpeningComplete: panes.initialOpeningComplete +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/NavRail.qml b/.config/quickshell/caelestia/modules/controlcenter/NavRail.qml new file mode 100644 index 0000000..e61a741 --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/NavRail.qml @@ -0,0 +1,231 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.services +import qs.config +import qs.modules.controlcenter +import Quickshell +import QtQuick +import QtQuick.Layouts + +Item { + id: root + + required property ShellScreen screen + required property Session session + required property bool initialOpeningComplete + + implicitWidth: layout.implicitWidth + Appearance.padding.larger * 4 + implicitHeight: layout.implicitHeight + Appearance.padding.large * 2 + + ColumnLayout { + id: layout + + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Appearance.padding.larger * 2 + spacing: Appearance.spacing.normal + + states: State { + name: "expanded" + when: root.session.navExpanded + + PropertyChanges { + layout.spacing: Appearance.spacing.small + } + } + + transitions: Transition { + Anim { + properties: "spacing" + } + } + + Loader { + Layout.topMargin: Appearance.spacing.large + active: !root.session.floating + visible: active + + sourceComponent: StyledRect { + readonly property int nonAnimWidth: normalWinIcon.implicitWidth + (root.session.navExpanded ? normalWinLabel.anchors.leftMargin + normalWinLabel.implicitWidth : 0) + normalWinIcon.anchors.leftMargin * 2 + + implicitWidth: nonAnimWidth + implicitHeight: root.session.navExpanded ? normalWinIcon.implicitHeight + Appearance.padding.normal * 2 : nonAnimWidth + + color: Colours.palette.m3primaryContainer + radius: Appearance.rounding.small + + StateLayer { + id: normalWinState + + color: Colours.palette.m3onPrimaryContainer + + function onClicked(): void { + root.session.root.close(); + WindowFactory.create(null, { + active: root.session.active, + navExpanded: root.session.navExpanded + }); + } + } + + MaterialIcon { + id: normalWinIcon + + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Appearance.padding.large + + text: "select_window" + color: Colours.palette.m3onPrimaryContainer + font.pointSize: Appearance.font.size.large + fill: 1 + } + + StyledText { + id: normalWinLabel + + anchors.left: normalWinIcon.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Appearance.spacing.normal + + text: qsTr("Float window") + color: Colours.palette.m3onPrimaryContainer + opacity: root.session.navExpanded ? 1 : 0 + + Behavior on opacity { + Anim { + duration: Appearance.anim.durations.small + } + } + } + + Behavior on implicitWidth { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + + Behavior on implicitHeight { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + } + } + + Repeater { + model: PaneRegistry.count + + NavItem { + required property int index + Layout.topMargin: index === 0 ? Appearance.spacing.large * 2 : 0 + icon: PaneRegistry.getByIndex(index).icon + label: PaneRegistry.getByIndex(index).label + } + } + } + + component NavItem: Item { + id: item + + required property string icon + required property string label + readonly property bool active: root.session.active === label + + implicitWidth: background.implicitWidth + implicitHeight: background.implicitHeight + smallLabel.implicitHeight + smallLabel.anchors.topMargin + + states: State { + name: "expanded" + when: root.session.navExpanded + + PropertyChanges { + expandedLabel.opacity: 1 + smallLabel.opacity: 0 + background.implicitWidth: icon.implicitWidth + icon.anchors.leftMargin * 2 + expandedLabel.anchors.leftMargin + expandedLabel.implicitWidth + background.implicitHeight: icon.implicitHeight + Appearance.padding.normal * 2 + item.implicitHeight: background.implicitHeight + } + } + + transitions: Transition { + Anim { + property: "opacity" + duration: Appearance.anim.durations.small + } + + Anim { + properties: "implicitWidth,implicitHeight" + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + + StyledRect { + id: background + + radius: Appearance.rounding.full + color: Qt.alpha(Colours.palette.m3secondaryContainer, item.active ? 1 : 0) + + implicitWidth: icon.implicitWidth + icon.anchors.leftMargin * 2 + implicitHeight: icon.implicitHeight + Appearance.padding.small + + StateLayer { + color: item.active ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface + + function onClicked(): void { + // Prevent tab switching during initial opening animation to avoid blank pages + if (!root.initialOpeningComplete) { + return; + } + root.session.active = item.label; + } + } + + MaterialIcon { + id: icon + + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Appearance.padding.large + + text: item.icon + color: item.active ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface + font.pointSize: Appearance.font.size.large + fill: item.active ? 1 : 0 + + Behavior on fill { + Anim {} + } + } + + StyledText { + id: expandedLabel + + anchors.left: icon.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Appearance.spacing.normal + + opacity: 0 + text: item.label + color: item.active ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface + font.capitalization: Font.Capitalize + } + + StyledText { + id: smallLabel + + anchors.horizontalCenter: icon.horizontalCenter + anchors.top: icon.bottom + anchors.topMargin: Appearance.spacing.small / 2 + + text: item.label + font.pointSize: Appearance.font.size.small + font.capitalization: Font.Capitalize + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/PaneRegistry.qml b/.config/quickshell/caelestia/modules/controlcenter/PaneRegistry.qml new file mode 100644 index 0000000..ca48551 --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/PaneRegistry.qml @@ -0,0 +1,92 @@ +pragma Singleton + +import QtQuick + +QtObject { + id: root + + readonly property list panes: [ + QtObject { + readonly property string id: "network" + readonly property string label: "network" + readonly property string icon: "router" + readonly property string component: "network/NetworkingPane.qml" + }, + QtObject { + readonly property string id: "bluetooth" + readonly property string label: "bluetooth" + readonly property string icon: "settings_bluetooth" + readonly property string component: "bluetooth/BtPane.qml" + }, + QtObject { + readonly property string id: "audio" + readonly property string label: "audio" + readonly property string icon: "volume_up" + readonly property string component: "audio/AudioPane.qml" + }, + QtObject { + readonly property string id: "appearance" + readonly property string label: "appearance" + readonly property string icon: "palette" + readonly property string component: "appearance/AppearancePane.qml" + }, + QtObject { + readonly property string id: "taskbar" + readonly property string label: "taskbar" + readonly property string icon: "task_alt" + readonly property string component: "taskbar/TaskbarPane.qml" + }, + QtObject { + readonly property string id: "launcher" + readonly property string label: "launcher" + readonly property string icon: "apps" + readonly property string component: "launcher/LauncherPane.qml" + }, + QtObject { + readonly property string id: "dashboard" + readonly property string label: "dashboard" + readonly property string icon: "dashboard" + readonly property string component: "dashboard/DashboardPane.qml" + } + ] + + readonly property int count: panes.length + + readonly property var labels: { + const result = []; + for (let i = 0; i < panes.length; i++) { + result.push(panes[i].label); + } + return result; + } + + function getByIndex(index: int): QtObject { + if (index >= 0 && index < panes.length) { + return panes[index]; + } + return null; + } + + function getIndexByLabel(label: string): int { + for (let i = 0; i < panes.length; i++) { + if (panes[i].label === label) { + return i; + } + } + return -1; + } + + function getByLabel(label: string): QtObject { + const index = getIndexByLabel(label); + return getByIndex(index); + } + + function getById(id: string): QtObject { + for (let i = 0; i < panes.length; i++) { + if (panes[i].id === id) { + return panes[i]; + } + } + return null; + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/Panes.qml b/.config/quickshell/caelestia/modules/controlcenter/Panes.qml new file mode 100644 index 0000000..ab2f808 --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/Panes.qml @@ -0,0 +1,175 @@ +pragma ComponentBehavior: Bound + +import "bluetooth" +import "network" +import "audio" +import "appearance" +import "taskbar" +import "launcher" +import "dashboard" +import qs.components +import qs.services +import qs.config +import qs.modules.controlcenter +import Quickshell.Widgets +import QtQuick +import QtQuick.Layouts + +ClippingRectangle { + id: root + + required property Session session + + readonly property bool initialOpeningComplete: layout.initialOpeningComplete + + color: "transparent" + clip: true + focus: false + activeFocusOnTab: false + + MouseArea { + anchors.fill: parent + z: -1 + onPressed: function (mouse) { + root.focus = true; + mouse.accepted = false; + } + } + + Connections { + target: root.session + + function onActiveIndexChanged(): void { + root.focus = true; + } + } + + ColumnLayout { + id: layout + + spacing: 0 + y: -root.session.activeIndex * root.height + clip: true + + property bool animationComplete: true + property bool initialOpeningComplete: false + + Timer { + id: animationDelayTimer + interval: Appearance.anim.durations.normal + onTriggered: { + layout.animationComplete = true; + } + } + + Timer { + id: initialOpeningTimer + interval: Appearance.anim.durations.large + running: true + onTriggered: { + layout.initialOpeningComplete = true; + } + } + + Repeater { + model: PaneRegistry.count + + Pane { + required property int index + paneIndex: index + componentPath: PaneRegistry.getByIndex(index).component + } + } + + Behavior on y { + Anim {} + } + + Connections { + target: root.session + function onActiveIndexChanged(): void { + layout.animationComplete = false; + animationDelayTimer.restart(); + } + } + } + + component Pane: Item { + id: pane + + required property int paneIndex + required property string componentPath + + implicitWidth: root.width + implicitHeight: root.height + + property bool hasBeenLoaded: false + + function updateActive(): void { + const diff = Math.abs(root.session.activeIndex - pane.paneIndex); + const isActivePane = diff === 0; + let shouldBeActive = false; + + if (!layout.initialOpeningComplete) { + shouldBeActive = isActivePane; + } else { + if (diff <= 1) { + shouldBeActive = true; + } else if (pane.hasBeenLoaded) { + shouldBeActive = true; + } else { + shouldBeActive = layout.animationComplete; + } + } + + loader.active = shouldBeActive; + } + + Loader { + id: loader + + anchors.fill: parent + clip: false + active: false + + Component.onCompleted: { + Qt.callLater(pane.updateActive); + } + + onActiveChanged: { + if (active && !pane.hasBeenLoaded) { + pane.hasBeenLoaded = true; + } + + if (active && !item) { + loader.setSource(pane.componentPath, { + "session": root.session + }); + } + } + + onItemChanged: { + if (item) { + pane.hasBeenLoaded = true; + } + } + } + + Connections { + target: root.session + function onActiveIndexChanged(): void { + pane.updateActive(); + } + } + + Connections { + target: layout + function onInitialOpeningCompleteChanged(): void { + pane.updateActive(); + } + function onAnimationCompleteChanged(): void { + pane.updateActive(); + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/Session.qml b/.config/quickshell/caelestia/modules/controlcenter/Session.qml new file mode 100644 index 0000000..8a8545f --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/Session.qml @@ -0,0 +1,23 @@ +import QtQuick +import "./state" +import qs.modules.controlcenter + +QtObject { + readonly property list panes: PaneRegistry.labels + + required property var root + property bool floating: false + property string active: "network" + property int activeIndex: 0 + property bool navExpanded: false + + readonly property BluetoothState bt: BluetoothState {} + readonly property NetworkState network: NetworkState {} + readonly property EthernetState ethernet: EthernetState {} + readonly property LauncherState launcher: LauncherState {} + readonly property VpnState vpn: VpnState {} + + onActiveChanged: activeIndex = Math.max(0, panes.indexOf(active)) + onActiveIndexChanged: if (panes[activeIndex]) + active = panes[activeIndex] +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/WindowFactory.qml b/.config/quickshell/caelestia/modules/controlcenter/WindowFactory.qml new file mode 100644 index 0000000..abcf5df --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/WindowFactory.qml @@ -0,0 +1,62 @@ +pragma Singleton + +import qs.components +import qs.services +import Quickshell +import QtQuick + +Singleton { + id: root + + function create(parent: Item, props: var): void { + controlCenter.createObject(parent ?? dummy, props); + } + + QtObject { + id: dummy + } + + Component { + id: controlCenter + + FloatingWindow { + id: win + + property alias active: cc.active + property alias navExpanded: cc.navExpanded + + color: Colours.tPalette.m3surface + + onVisibleChanged: { + if (!visible) + destroy(); + } + + implicitWidth: cc.implicitWidth + implicitHeight: cc.implicitHeight + + minimumSize.width: implicitWidth + minimumSize.height: implicitHeight + maximumSize.width: implicitWidth + maximumSize.height: implicitHeight + + title: qsTr("Caelestia Settings - %1").arg(cc.active.slice(0, 1).toUpperCase() + cc.active.slice(1)) + + ControlCenter { + id: cc + + anchors.fill: parent + screen: win.screen + floating: true + + function close(): void { + win.destroy(); + } + } + + Behavior on color { + CAnim {} + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/WindowTitle.qml b/.config/quickshell/caelestia/modules/controlcenter/WindowTitle.qml new file mode 100644 index 0000000..fb71608 --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/WindowTitle.qml @@ -0,0 +1,51 @@ +import qs.components +import qs.services +import qs.config +import Quickshell +import QtQuick + +StyledRect { + id: root + + required property ShellScreen screen + required property Session session + + implicitHeight: text.implicitHeight + Appearance.padding.normal + color: Colours.tPalette.m3surfaceContainer + + StyledText { + id: text + + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + + text: qsTr("Caelestia Settings - %1").arg(root.session.active) + font.capitalization: Font.Capitalize + font.pointSize: Appearance.font.size.larger + font.weight: 500 + } + + Item { + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Appearance.padding.normal + + implicitWidth: implicitHeight + implicitHeight: closeIcon.implicitHeight + Appearance.padding.small + + StateLayer { + radius: Appearance.rounding.full + + function onClicked(): void { + QsWindow.window.destroy(); + } + } + + MaterialIcon { + id: closeIcon + + anchors.centerIn: parent + text: "close" + } + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/appearance/AppearancePane.qml b/.config/quickshell/caelestia/modules/controlcenter/appearance/AppearancePane.qml new file mode 100644 index 0000000..f29f7ab --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/appearance/AppearancePane.qml @@ -0,0 +1,254 @@ +pragma ComponentBehavior: Bound + +import ".." +import "../components" +import "./sections" +import "../../launcher/services" +import qs.components +import qs.components.controls +import qs.components.effects +import qs.components.containers +import qs.components.images +import qs.services +import qs.config +import qs.utils +import Caelestia.Models +import Quickshell +import Quickshell.Widgets +import QtQuick +import QtQuick.Layouts + +Item { + id: root + + required property Session session + + property real animDurationsScale: Config.appearance.anim.durations.scale ?? 1 + property string fontFamilyMaterial: Config.appearance.font.family.material ?? "Material Symbols Rounded" + property string fontFamilyMono: Config.appearance.font.family.mono ?? "CaskaydiaCove NF" + property string fontFamilySans: Config.appearance.font.family.sans ?? "Rubik" + property real fontSizeScale: Config.appearance.font.size.scale ?? 1 + property real paddingScale: Config.appearance.padding.scale ?? 1 + property real roundingScale: Config.appearance.rounding.scale ?? 1 + property real spacingScale: Config.appearance.spacing.scale ?? 1 + property bool transparencyEnabled: Config.appearance.transparency.enabled ?? false + property real transparencyBase: Config.appearance.transparency.base ?? 0.85 + property real transparencyLayers: Config.appearance.transparency.layers ?? 0.4 + property real borderRounding: Config.border.rounding ?? 1 + property real borderThickness: Config.border.thickness ?? 1 + + property bool desktopClockEnabled: Config.background.desktopClock.enabled ?? false + property real desktopClockScale: Config.background.desktopClock.scale ?? 1 + property string desktopClockPosition: Config.background.desktopClock.position ?? "bottom-right" + property bool desktopClockShadowEnabled: Config.background.desktopClock.shadow.enabled ?? true + property real desktopClockShadowOpacity: Config.background.desktopClock.shadow.opacity ?? 0.7 + property real desktopClockShadowBlur: Config.background.desktopClock.shadow.blur ?? 0.4 + property bool desktopClockBackgroundEnabled: Config.background.desktopClock.background.enabled ?? false + property real desktopClockBackgroundOpacity: Config.background.desktopClock.background.opacity ?? 0.7 + property bool desktopClockBackgroundBlur: Config.background.desktopClock.background.blur ?? false + property bool desktopClockInvertColors: Config.background.desktopClock.invertColors ?? false + property bool backgroundEnabled: Config.background.enabled ?? true + property bool wallpaperEnabled: Config.background.wallpaperEnabled ?? true + property bool visualiserEnabled: Config.background.visualiser.enabled ?? false + property bool visualiserAutoHide: Config.background.visualiser.autoHide ?? true + property real visualiserRounding: Config.background.visualiser.rounding ?? 1 + property real visualiserSpacing: Config.background.visualiser.spacing ?? 1 + + anchors.fill: parent + + function saveConfig() { + Config.appearance.anim.durations.scale = root.animDurationsScale; + + Config.appearance.font.family.material = root.fontFamilyMaterial; + Config.appearance.font.family.mono = root.fontFamilyMono; + Config.appearance.font.family.sans = root.fontFamilySans; + Config.appearance.font.size.scale = root.fontSizeScale; + + Config.appearance.padding.scale = root.paddingScale; + Config.appearance.rounding.scale = root.roundingScale; + Config.appearance.spacing.scale = root.spacingScale; + + Config.appearance.transparency.enabled = root.transparencyEnabled; + Config.appearance.transparency.base = root.transparencyBase; + Config.appearance.transparency.layers = root.transparencyLayers; + + Config.background.desktopClock.enabled = root.desktopClockEnabled; + Config.background.enabled = root.backgroundEnabled; + Config.background.desktopClock.scale = root.desktopClockScale; + Config.background.desktopClock.position = root.desktopClockPosition; + Config.background.desktopClock.shadow.enabled = root.desktopClockShadowEnabled; + Config.background.desktopClock.shadow.opacity = root.desktopClockShadowOpacity; + Config.background.desktopClock.shadow.blur = root.desktopClockShadowBlur; + Config.background.desktopClock.background.enabled = root.desktopClockBackgroundEnabled; + Config.background.desktopClock.background.opacity = root.desktopClockBackgroundOpacity; + Config.background.desktopClock.background.blur = root.desktopClockBackgroundBlur; + Config.background.desktopClock.invertColors = root.desktopClockInvertColors; + + Config.background.wallpaperEnabled = root.wallpaperEnabled; + + Config.background.visualiser.enabled = root.visualiserEnabled; + Config.background.visualiser.autoHide = root.visualiserAutoHide; + Config.background.visualiser.rounding = root.visualiserRounding; + Config.background.visualiser.spacing = root.visualiserSpacing; + + Config.border.rounding = root.borderRounding; + Config.border.thickness = root.borderThickness; + + Config.save(); + } + + Component { + id: appearanceRightContentComponent + + Item { + id: rightAppearanceFlickable + + ColumnLayout { + id: contentLayout + + anchors.fill: parent + spacing: 0 + + StyledText { + Layout.alignment: Qt.AlignHCenter + Layout.bottomMargin: Appearance.spacing.normal + text: qsTr("Wallpaper") + font.pointSize: Appearance.font.size.extraLarge + font.weight: 600 + } + + Loader { + id: wallpaperLoader + + Layout.fillWidth: true + Layout.fillHeight: true + Layout.bottomMargin: -Appearance.padding.large * 2 + + active: { + const isActive = root.session.activeIndex === 3; + const isAdjacent = Math.abs(root.session.activeIndex - 3) === 1; + const splitLayout = root.children[0]; + const loader = splitLayout && splitLayout.rightLoader ? splitLayout.rightLoader : null; + const shouldActivate = loader && loader.item !== null && (isActive || isAdjacent); + return shouldActivate; + } + + onStatusChanged: { + if (status === Loader.Error) { + console.error("[AppearancePane] Wallpaper loader error!"); + } + } + + sourceComponent: WallpaperGrid { + session: root.session + } + } + } + } + } + + SplitPaneLayout { + anchors.fill: parent + + leftContent: Component { + + StyledFlickable { + id: sidebarFlickable + readonly property var rootPane: root + flickableDirection: Flickable.VerticalFlick + contentHeight: sidebarLayout.height + + StyledScrollBar.vertical: StyledScrollBar { + flickable: sidebarFlickable + } + + ColumnLayout { + id: sidebarLayout + anchors.left: parent.left + anchors.right: parent.right + spacing: Appearance.spacing.small + + readonly property var rootPane: sidebarFlickable.rootPane + + readonly property bool allSectionsExpanded: themeModeSection.expanded && colorVariantSection.expanded && colorSchemeSection.expanded && animationsSection.expanded && fontsSection.expanded && scalesSection.expanded && transparencySection.expanded && borderSection.expanded && backgroundSection.expanded + + RowLayout { + spacing: Appearance.spacing.smaller + + StyledText { + text: qsTr("Appearance") + font.pointSize: Appearance.font.size.large + font.weight: 500 + } + + Item { + Layout.fillWidth: true + } + + IconButton { + icon: sidebarLayout.allSectionsExpanded ? "unfold_less" : "unfold_more" + type: IconButton.Text + label.animate: true + onClicked: { + const shouldExpand = !sidebarLayout.allSectionsExpanded; + themeModeSection.expanded = shouldExpand; + colorVariantSection.expanded = shouldExpand; + colorSchemeSection.expanded = shouldExpand; + animationsSection.expanded = shouldExpand; + fontsSection.expanded = shouldExpand; + scalesSection.expanded = shouldExpand; + transparencySection.expanded = shouldExpand; + borderSection.expanded = shouldExpand; + backgroundSection.expanded = shouldExpand; + } + } + } + + ThemeModeSection { + id: themeModeSection + } + + ColorVariantSection { + id: colorVariantSection + } + + ColorSchemeSection { + id: colorSchemeSection + } + + AnimationsSection { + id: animationsSection + rootPane: sidebarFlickable.rootPane + } + + FontsSection { + id: fontsSection + rootPane: sidebarFlickable.rootPane + } + + ScalesSection { + id: scalesSection + rootPane: sidebarFlickable.rootPane + } + + TransparencySection { + id: transparencySection + rootPane: sidebarFlickable.rootPane + } + + BorderSection { + id: borderSection + rootPane: sidebarFlickable.rootPane + } + + BackgroundSection { + id: backgroundSection + rootPane: sidebarFlickable.rootPane + } + } + } + } + + rightContent: appearanceRightContentComponent + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/AnimationsSection.qml b/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/AnimationsSection.qml new file mode 100644 index 0000000..0cba5ce --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/AnimationsSection.qml @@ -0,0 +1,44 @@ +pragma ComponentBehavior: Bound + +import ".." +import "../../components" +import qs.components +import qs.components.controls +import qs.components.containers +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +CollapsibleSection { + id: root + + required property var rootPane + + title: qsTr("Animations") + showBackground: true + + SectionContainer { + contentSpacing: Appearance.spacing.normal + + SliderInput { + Layout.fillWidth: true + + label: qsTr("Animation duration scale") + value: rootPane.animDurationsScale + from: 0.1 + to: 5.0 + decimals: 1 + suffix: "×" + validator: DoubleValidator { + bottom: 0.1 + top: 5.0 + } + + onValueModified: newValue => { + rootPane.animDurationsScale = newValue; + rootPane.saveConfig(); + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/BackgroundSection.qml b/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/BackgroundSection.qml new file mode 100644 index 0000000..9d6bc6e --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/BackgroundSection.qml @@ -0,0 +1,345 @@ +pragma ComponentBehavior: Bound + +import ".." +import "../../components" +import qs.components +import qs.components.controls +import qs.components.containers +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +CollapsibleSection { + id: root + + required property var rootPane + + title: qsTr("Background") + showBackground: true + + SwitchRow { + label: qsTr("Background enabled") + checked: rootPane.backgroundEnabled + onToggled: checked => { + rootPane.backgroundEnabled = checked; + rootPane.saveConfig(); + } + } + + SwitchRow { + label: qsTr("Wallpaper enabled") + checked: rootPane.wallpaperEnabled + onToggled: checked => { + rootPane.wallpaperEnabled = checked; + rootPane.saveConfig(); + } + } + + StyledText { + Layout.topMargin: Appearance.spacing.normal + text: qsTr("Desktop Clock") + font.pointSize: Appearance.font.size.larger + font.weight: 500 + } + + SwitchRow { + label: qsTr("Desktop Clock enabled") + checked: rootPane.desktopClockEnabled + onToggled: checked => { + rootPane.desktopClockEnabled = checked; + rootPane.saveConfig(); + } + } + + SectionContainer { + id: posContainer + + contentSpacing: Appearance.spacing.small + z: 1 + + readonly property var pos: (rootPane.desktopClockPosition || "top-left").split('-') + readonly property string currentV: pos[0] + readonly property string currentH: pos[1] + + function updateClockPos(v, h) { + rootPane.desktopClockPosition = v + "-" + h; + rootPane.saveConfig(); + } + + StyledText { + text: qsTr("Positioning") + font.pointSize: Appearance.font.size.larger + font.weight: 500 + } + + SplitButtonRow { + label: qsTr("Vertical Position") + enabled: rootPane.desktopClockEnabled + + menuItems: [ + MenuItem { + text: qsTr("Top") + icon: "vertical_align_top" + property string val: "top" + }, + MenuItem { + text: qsTr("Middle") + icon: "vertical_align_center" + property string val: "middle" + }, + MenuItem { + text: qsTr("Bottom") + icon: "vertical_align_bottom" + property string val: "bottom" + } + ] + + Component.onCompleted: { + for (let i = 0; i < menuItems.length; i++) { + if (menuItems[i].val === posContainer.currentV) + active = menuItems[i]; + } + } + + // The signal from SplitButtonRow + onSelected: item => posContainer.updateClockPos(item.val, posContainer.currentH) + } + + SplitButtonRow { + label: qsTr("Horizontal Position") + enabled: rootPane.desktopClockEnabled + expandedZ: 99 + + menuItems: [ + MenuItem { + text: qsTr("Left") + icon: "align_horizontal_left" + property string val: "left" + }, + MenuItem { + text: qsTr("Center") + icon: "align_horizontal_center" + property string val: "center" + }, + MenuItem { + text: qsTr("Right") + icon: "align_horizontal_right" + property string val: "right" + } + ] + + Component.onCompleted: { + for (let i = 0; i < menuItems.length; i++) { + if (menuItems[i].val === posContainer.currentH) + active = menuItems[i]; + } + } + + onSelected: item => posContainer.updateClockPos(posContainer.currentV, item.val) + } + } + + SwitchRow { + label: qsTr("Invert colors") + checked: rootPane.desktopClockInvertColors + onToggled: checked => { + rootPane.desktopClockInvertColors = checked; + rootPane.saveConfig(); + } + } + + SectionContainer { + contentSpacing: Appearance.spacing.small + + StyledText { + text: qsTr("Shadow") + font.pointSize: Appearance.font.size.larger + font.weight: 500 + } + + SwitchRow { + label: qsTr("Enabled") + checked: rootPane.desktopClockShadowEnabled + onToggled: checked => { + rootPane.desktopClockShadowEnabled = checked; + rootPane.saveConfig(); + } + } + + SectionContainer { + contentSpacing: Appearance.spacing.normal + + SliderInput { + Layout.fillWidth: true + + label: qsTr("Opacity") + value: rootPane.desktopClockShadowOpacity * 100 + from: 0 + to: 100 + suffix: "%" + validator: IntValidator { + bottom: 0 + top: 100 + } + formatValueFunction: val => Math.round(val).toString() + parseValueFunction: text => parseInt(text) + + onValueModified: newValue => { + rootPane.desktopClockShadowOpacity = newValue / 100; + rootPane.saveConfig(); + } + } + } + + SectionContainer { + contentSpacing: Appearance.spacing.normal + + SliderInput { + Layout.fillWidth: true + + label: qsTr("Blur") + value: rootPane.desktopClockShadowBlur * 100 + from: 0 + to: 100 + suffix: "%" + validator: IntValidator { + bottom: 0 + top: 100 + } + formatValueFunction: val => Math.round(val).toString() + parseValueFunction: text => parseInt(text) + + onValueModified: newValue => { + rootPane.desktopClockShadowBlur = newValue / 100; + rootPane.saveConfig(); + } + } + } + } + + SectionContainer { + contentSpacing: Appearance.spacing.small + + StyledText { + text: qsTr("Background") + font.pointSize: Appearance.font.size.larger + font.weight: 500 + } + + SwitchRow { + label: qsTr("Enabled") + checked: rootPane.desktopClockBackgroundEnabled + onToggled: checked => { + rootPane.desktopClockBackgroundEnabled = checked; + rootPane.saveConfig(); + } + } + + SwitchRow { + label: qsTr("Blur enabled") + checked: rootPane.desktopClockBackgroundBlur + onToggled: checked => { + rootPane.desktopClockBackgroundBlur = checked; + rootPane.saveConfig(); + } + } + + SectionContainer { + contentSpacing: Appearance.spacing.normal + + SliderInput { + Layout.fillWidth: true + + label: qsTr("Opacity") + value: rootPane.desktopClockBackgroundOpacity * 100 + from: 0 + to: 100 + suffix: "%" + validator: IntValidator { + bottom: 0 + top: 100 + } + formatValueFunction: val => Math.round(val).toString() + parseValueFunction: text => parseInt(text) + + onValueModified: newValue => { + rootPane.desktopClockBackgroundOpacity = newValue / 100; + rootPane.saveConfig(); + } + } + } + } + + StyledText { + Layout.topMargin: Appearance.spacing.normal + text: qsTr("Visualiser") + font.pointSize: Appearance.font.size.larger + font.weight: 500 + } + + SwitchRow { + label: qsTr("Visualiser enabled") + checked: rootPane.visualiserEnabled + onToggled: checked => { + rootPane.visualiserEnabled = checked; + rootPane.saveConfig(); + } + } + + SwitchRow { + label: qsTr("Visualiser auto hide") + checked: rootPane.visualiserAutoHide + onToggled: checked => { + rootPane.visualiserAutoHide = checked; + rootPane.saveConfig(); + } + } + + SectionContainer { + contentSpacing: Appearance.spacing.normal + + SliderInput { + Layout.fillWidth: true + + label: qsTr("Visualiser rounding") + value: rootPane.visualiserRounding + from: 0 + to: 10 + stepSize: 1 + validator: IntValidator { + bottom: 0 + top: 10 + } + formatValueFunction: val => Math.round(val).toString() + parseValueFunction: text => parseInt(text) + + onValueModified: newValue => { + rootPane.visualiserRounding = Math.round(newValue); + rootPane.saveConfig(); + } + } + } + + SectionContainer { + contentSpacing: Appearance.spacing.normal + + SliderInput { + Layout.fillWidth: true + + label: qsTr("Visualiser spacing") + value: rootPane.visualiserSpacing + from: 0 + to: 2 + validator: DoubleValidator { + bottom: 0 + top: 2 + } + + onValueModified: newValue => { + rootPane.visualiserSpacing = newValue; + rootPane.saveConfig(); + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/BorderSection.qml b/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/BorderSection.qml new file mode 100644 index 0000000..9532d70 --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/BorderSection.qml @@ -0,0 +1,68 @@ +pragma ComponentBehavior: Bound + +import ".." +import "../../components" +import qs.components +import qs.components.controls +import qs.components.containers +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +CollapsibleSection { + id: root + + required property var rootPane + + title: qsTr("Border") + showBackground: true + + SectionContainer { + contentSpacing: Appearance.spacing.normal + + SliderInput { + Layout.fillWidth: true + + label: qsTr("Border rounding") + value: rootPane.borderRounding + from: 0.1 + to: 100 + decimals: 1 + suffix: "px" + validator: DoubleValidator { + bottom: 0.1 + top: 100 + } + + onValueModified: newValue => { + rootPane.borderRounding = newValue; + rootPane.saveConfig(); + } + } + } + + SectionContainer { + contentSpacing: Appearance.spacing.normal + + SliderInput { + Layout.fillWidth: true + + label: qsTr("Border thickness") + value: rootPane.borderThickness + from: 0.1 + to: 100 + decimals: 1 + suffix: "px" + validator: DoubleValidator { + bottom: 0.1 + top: 100 + } + + onValueModified: newValue => { + rootPane.borderThickness = newValue; + rootPane.saveConfig(); + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/ColorSchemeSection.qml b/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/ColorSchemeSection.qml new file mode 100644 index 0000000..95cb4b7 --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/ColorSchemeSection.qml @@ -0,0 +1,145 @@ +pragma ComponentBehavior: Bound + +import ".." +import "../../../launcher/services" +import qs.components +import qs.components.controls +import qs.components.containers +import qs.services +import qs.config +import Quickshell +import QtQuick +import QtQuick.Layouts + +CollapsibleSection { + title: qsTr("Color scheme") + description: qsTr("Available color schemes") + showBackground: true + + ColumnLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.small / 2 + + Repeater { + model: Schemes.list + + delegate: StyledRect { + required property var modelData + + Layout.fillWidth: true + + readonly property string schemeKey: `${modelData.name} ${modelData.flavour}` + readonly property bool isCurrent: schemeKey === Schemes.currentScheme + + color: Qt.alpha(Colours.tPalette.m3surfaceContainer, isCurrent ? Colours.tPalette.m3surfaceContainer.a : 0) + radius: Appearance.rounding.normal + border.width: isCurrent ? 1 : 0 + border.color: Colours.palette.m3primary + + StateLayer { + function onClicked(): void { + const name = modelData.name; + const flavour = modelData.flavour; + const schemeKey = `${name} ${flavour}`; + + Schemes.currentScheme = schemeKey; + Quickshell.execDetached(["caelestia", "scheme", "set", "-n", name, "-f", flavour]); + + Qt.callLater(() => { + reloadTimer.restart(); + }); + } + } + + Timer { + id: reloadTimer + interval: 300 + onTriggered: { + Schemes.reload(); + } + } + + RowLayout { + id: schemeRow + + anchors.fill: parent + anchors.margins: Appearance.padding.normal + + spacing: Appearance.spacing.normal + + StyledRect { + id: preview + + Layout.alignment: Qt.AlignVCenter + + border.width: 1 + border.color: Qt.alpha(`#${modelData.colours?.outline}`, 0.5) + + color: `#${modelData.colours?.surface}` + radius: Appearance.rounding.full + implicitWidth: iconPlaceholder.implicitWidth + implicitHeight: iconPlaceholder.implicitWidth + + MaterialIcon { + id: iconPlaceholder + visible: false + text: "circle" + font.pointSize: Appearance.font.size.large + } + + Item { + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.right: parent.right + + implicitWidth: parent.implicitWidth / 2 + clip: true + + StyledRect { + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.right: parent.right + + implicitWidth: preview.implicitWidth + color: `#${modelData.colours?.primary}` + radius: Appearance.rounding.full + } + } + } + + Column { + Layout.fillWidth: true + spacing: 0 + + StyledText { + text: modelData.flavour ?? "" + font.pointSize: Appearance.font.size.normal + } + + StyledText { + text: modelData.name ?? "" + font.pointSize: Appearance.font.size.small + color: Colours.palette.m3outline + + elide: Text.ElideRight + anchors.left: parent.left + anchors.right: parent.right + } + } + + Loader { + active: isCurrent + + sourceComponent: MaterialIcon { + text: "check" + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.large + } + } + } + + implicitHeight: schemeRow.implicitHeight + Appearance.padding.normal * 2 + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/ColorVariantSection.qml b/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/ColorVariantSection.qml new file mode 100644 index 0000000..3aa17dd --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/ColorVariantSection.qml @@ -0,0 +1,91 @@ +pragma ComponentBehavior: Bound + +import ".." +import "../../../launcher/services" +import qs.components +import qs.components.controls +import qs.components.containers +import qs.services +import qs.config +import Quickshell +import QtQuick +import QtQuick.Layouts + +CollapsibleSection { + title: qsTr("Color variant") + description: qsTr("Material theme variant") + showBackground: true + + ColumnLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.small / 2 + + Repeater { + model: M3Variants.list + + delegate: StyledRect { + required property var modelData + + Layout.fillWidth: true + + color: Qt.alpha(Colours.tPalette.m3surfaceContainer, modelData.variant === Schemes.currentVariant ? Colours.tPalette.m3surfaceContainer.a : 0) + radius: Appearance.rounding.normal + border.width: modelData.variant === Schemes.currentVariant ? 1 : 0 + border.color: Colours.palette.m3primary + + StateLayer { + function onClicked(): void { + const variant = modelData.variant; + + Schemes.currentVariant = variant; + Quickshell.execDetached(["caelestia", "scheme", "set", "-v", variant]); + + Qt.callLater(() => { + reloadTimer.restart(); + }); + } + } + + Timer { + id: reloadTimer + interval: 300 + onTriggered: { + Schemes.reload(); + } + } + + RowLayout { + id: variantRow + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Appearance.padding.normal + + spacing: Appearance.spacing.normal + + MaterialIcon { + text: modelData.icon + font.pointSize: Appearance.font.size.large + fill: modelData.variant === Schemes.currentVariant ? 1 : 0 + } + + StyledText { + Layout.fillWidth: true + text: modelData.name + font.weight: modelData.variant === Schemes.currentVariant ? 500 : 400 + } + + MaterialIcon { + visible: modelData.variant === Schemes.currentVariant + text: "check" + color: Colours.palette.m3primary + font.pointSize: Appearance.font.size.large + } + } + + implicitHeight: variantRow.implicitHeight + Appearance.padding.normal * 2 + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/FontsSection.qml b/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/FontsSection.qml new file mode 100644 index 0000000..3988863 --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/FontsSection.qml @@ -0,0 +1,282 @@ +pragma ComponentBehavior: Bound + +import ".." +import "../../components" +import qs.components +import qs.components.controls +import qs.components.containers +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +CollapsibleSection { + id: root + + required property var rootPane + + title: qsTr("Fonts") + showBackground: true + + CollapsibleSection { + id: materialFontSection + title: qsTr("Material font family") + expanded: true + showBackground: true + nested: true + + Loader { + id: materialFontLoader + Layout.fillWidth: true + Layout.preferredHeight: item ? Math.min(item.contentHeight, 300) : 0 + active: materialFontSection.expanded + + sourceComponent: StyledListView { + id: materialFontList + property alias contentHeight: materialFontList.contentHeight + + clip: true + spacing: Appearance.spacing.small / 2 + model: Qt.fontFamilies() + + StyledScrollBar.vertical: StyledScrollBar { + flickable: materialFontList + } + + delegate: StyledRect { + required property string modelData + required property int index + + width: ListView.view.width + + readonly property bool isCurrent: modelData === rootPane.fontFamilyMaterial + color: Qt.alpha(Colours.tPalette.m3surfaceContainer, isCurrent ? Colours.tPalette.m3surfaceContainer.a : 0) + radius: Appearance.rounding.normal + border.width: isCurrent ? 1 : 0 + border.color: Colours.palette.m3primary + + StateLayer { + function onClicked(): void { + rootPane.fontFamilyMaterial = modelData; + rootPane.saveConfig(); + } + } + + RowLayout { + id: fontFamilyMaterialRow + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Appearance.padding.normal + + spacing: Appearance.spacing.normal + + StyledText { + text: modelData + font.pointSize: Appearance.font.size.normal + } + + Item { + Layout.fillWidth: true + } + + Loader { + active: isCurrent + + sourceComponent: MaterialIcon { + text: "check" + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.large + } + } + } + + implicitHeight: fontFamilyMaterialRow.implicitHeight + Appearance.padding.normal * 2 + } + } + } + } + + CollapsibleSection { + id: monoFontSection + title: qsTr("Monospace font family") + expanded: false + showBackground: true + nested: true + + Loader { + Layout.fillWidth: true + Layout.preferredHeight: item ? Math.min(item.contentHeight, 300) : 0 + active: monoFontSection.expanded + + sourceComponent: StyledListView { + id: monoFontList + property alias contentHeight: monoFontList.contentHeight + + clip: true + spacing: Appearance.spacing.small / 2 + model: Qt.fontFamilies() + + StyledScrollBar.vertical: StyledScrollBar { + flickable: monoFontList + } + + delegate: StyledRect { + required property string modelData + required property int index + + width: ListView.view.width + + readonly property bool isCurrent: modelData === rootPane.fontFamilyMono + color: Qt.alpha(Colours.tPalette.m3surfaceContainer, isCurrent ? Colours.tPalette.m3surfaceContainer.a : 0) + radius: Appearance.rounding.normal + border.width: isCurrent ? 1 : 0 + border.color: Colours.palette.m3primary + + StateLayer { + function onClicked(): void { + rootPane.fontFamilyMono = modelData; + rootPane.saveConfig(); + } + } + + RowLayout { + id: fontFamilyMonoRow + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Appearance.padding.normal + + spacing: Appearance.spacing.normal + + StyledText { + text: modelData + font.pointSize: Appearance.font.size.normal + } + + Item { + Layout.fillWidth: true + } + + Loader { + active: isCurrent + + sourceComponent: MaterialIcon { + text: "check" + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.large + } + } + } + + implicitHeight: fontFamilyMonoRow.implicitHeight + Appearance.padding.normal * 2 + } + } + } + } + + CollapsibleSection { + id: sansFontSection + title: qsTr("Sans-serif font family") + expanded: false + showBackground: true + nested: true + + Loader { + Layout.fillWidth: true + Layout.preferredHeight: item ? Math.min(item.contentHeight, 300) : 0 + active: sansFontSection.expanded + + sourceComponent: StyledListView { + id: sansFontList + property alias contentHeight: sansFontList.contentHeight + + clip: true + spacing: Appearance.spacing.small / 2 + model: Qt.fontFamilies() + + StyledScrollBar.vertical: StyledScrollBar { + flickable: sansFontList + } + + delegate: StyledRect { + required property string modelData + required property int index + + width: ListView.view.width + + readonly property bool isCurrent: modelData === rootPane.fontFamilySans + color: Qt.alpha(Colours.tPalette.m3surfaceContainer, isCurrent ? Colours.tPalette.m3surfaceContainer.a : 0) + radius: Appearance.rounding.normal + border.width: isCurrent ? 1 : 0 + border.color: Colours.palette.m3primary + + StateLayer { + function onClicked(): void { + rootPane.fontFamilySans = modelData; + rootPane.saveConfig(); + } + } + + RowLayout { + id: fontFamilySansRow + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Appearance.padding.normal + + spacing: Appearance.spacing.normal + + StyledText { + text: modelData + font.pointSize: Appearance.font.size.normal + } + + Item { + Layout.fillWidth: true + } + + Loader { + active: isCurrent + + sourceComponent: MaterialIcon { + text: "check" + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.large + } + } + } + + implicitHeight: fontFamilySansRow.implicitHeight + Appearance.padding.normal * 2 + } + } + } + } + + SectionContainer { + contentSpacing: Appearance.spacing.normal + + SliderInput { + Layout.fillWidth: true + + label: qsTr("Font size scale") + value: rootPane.fontSizeScale + from: 0.7 + to: 1.5 + decimals: 2 + suffix: "×" + validator: DoubleValidator { + bottom: 0.7 + top: 1.5 + } + + onValueModified: newValue => { + rootPane.fontSizeScale = newValue; + rootPane.saveConfig(); + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/ScalesSection.qml b/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/ScalesSection.qml new file mode 100644 index 0000000..b0e6e38 --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/ScalesSection.qml @@ -0,0 +1,92 @@ +pragma ComponentBehavior: Bound + +import ".." +import "../../components" +import qs.components +import qs.components.controls +import qs.components.containers +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +CollapsibleSection { + id: root + + required property var rootPane + + title: qsTr("Scales") + showBackground: true + + SectionContainer { + contentSpacing: Appearance.spacing.normal + + SliderInput { + Layout.fillWidth: true + + label: qsTr("Padding scale") + value: rootPane.paddingScale + from: 0.5 + to: 2.0 + decimals: 1 + suffix: "×" + validator: DoubleValidator { + bottom: 0.5 + top: 2.0 + } + + onValueModified: newValue => { + rootPane.paddingScale = newValue; + rootPane.saveConfig(); + } + } + } + + SectionContainer { + contentSpacing: Appearance.spacing.normal + + SliderInput { + Layout.fillWidth: true + + label: qsTr("Rounding scale") + value: rootPane.roundingScale + from: 0.1 + to: 5.0 + decimals: 1 + suffix: "×" + validator: DoubleValidator { + bottom: 0.1 + top: 5.0 + } + + onValueModified: newValue => { + rootPane.roundingScale = newValue; + rootPane.saveConfig(); + } + } + } + + SectionContainer { + contentSpacing: Appearance.spacing.normal + + SliderInput { + Layout.fillWidth: true + + label: qsTr("Spacing scale") + value: rootPane.spacingScale + from: 0.1 + to: 2.0 + decimals: 1 + suffix: "×" + validator: DoubleValidator { + bottom: 0.1 + top: 2.0 + } + + onValueModified: newValue => { + rootPane.spacingScale = newValue; + rootPane.saveConfig(); + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/ThemeModeSection.qml b/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/ThemeModeSection.qml new file mode 100644 index 0000000..04eed91 --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/ThemeModeSection.qml @@ -0,0 +1,23 @@ +pragma ComponentBehavior: Bound + +import ".." +import qs.components +import qs.components.controls +import qs.components.containers +import qs.services +import qs.config +import QtQuick + +CollapsibleSection { + title: qsTr("Theme mode") + description: qsTr("Light or dark theme") + showBackground: true + + SwitchRow { + label: qsTr("Dark mode") + checked: !Colours.currentLight + onToggled: checked => { + Colours.setMode(checked ? "dark" : "light"); + } + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/TransparencySection.qml b/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/TransparencySection.qml new file mode 100644 index 0000000..9a48629 --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/appearance/sections/TransparencySection.qml @@ -0,0 +1,79 @@ +pragma ComponentBehavior: Bound + +import ".." +import "../../components" +import qs.components +import qs.components.controls +import qs.components.containers +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +CollapsibleSection { + id: root + + required property var rootPane + + title: qsTr("Transparency") + showBackground: true + + SwitchRow { + label: qsTr("Transparency enabled") + checked: rootPane.transparencyEnabled + onToggled: checked => { + rootPane.transparencyEnabled = checked; + rootPane.saveConfig(); + } + } + + SectionContainer { + contentSpacing: Appearance.spacing.normal + + SliderInput { + Layout.fillWidth: true + + label: qsTr("Transparency base") + value: rootPane.transparencyBase * 100 + from: 0 + to: 100 + suffix: "%" + validator: IntValidator { + bottom: 0 + top: 100 + } + formatValueFunction: val => Math.round(val).toString() + parseValueFunction: text => parseInt(text) + + onValueModified: newValue => { + rootPane.transparencyBase = newValue / 100; + rootPane.saveConfig(); + } + } + } + + SectionContainer { + contentSpacing: Appearance.spacing.normal + + SliderInput { + Layout.fillWidth: true + + label: qsTr("Transparency layers") + value: rootPane.transparencyLayers * 100 + from: 0 + to: 100 + suffix: "%" + validator: IntValidator { + bottom: 0 + top: 100 + } + formatValueFunction: val => Math.round(val).toString() + parseValueFunction: text => parseInt(text) + + onValueModified: newValue => { + rootPane.transparencyLayers = newValue / 100; + rootPane.saveConfig(); + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/audio/AudioPane.qml b/.config/quickshell/caelestia/modules/controlcenter/audio/AudioPane.qml new file mode 100644 index 0000000..01d90be --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/audio/AudioPane.qml @@ -0,0 +1,621 @@ +pragma ComponentBehavior: Bound + +import ".." +import "../components" +import qs.components +import qs.components.controls +import qs.components.effects +import qs.components.containers +import qs.services +import qs.config +import Quickshell.Widgets +import QtQuick +import QtQuick.Layouts + +Item { + id: root + + required property Session session + + anchors.fill: parent + + SplitPaneLayout { + anchors.fill: parent + + leftContent: Component { + + StyledFlickable { + id: leftAudioFlickable + flickableDirection: Flickable.VerticalFlick + contentHeight: leftContent.height + + StyledScrollBar.vertical: StyledScrollBar { + flickable: leftAudioFlickable + } + + ColumnLayout { + id: leftContent + + anchors.left: parent.left + anchors.right: parent.right + spacing: Appearance.spacing.normal + + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.smaller + + StyledText { + text: qsTr("Audio") + font.pointSize: Appearance.font.size.large + font.weight: 500 + } + + Item { + Layout.fillWidth: true + } + } + + CollapsibleSection { + id: outputDevicesSection + + Layout.fillWidth: true + title: qsTr("Output devices") + expanded: true + + ColumnLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.small + + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.small + + StyledText { + text: qsTr("Devices (%1)").arg(Audio.sinks.length) + font.pointSize: Appearance.font.size.normal + font.weight: 500 + } + } + + StyledText { + Layout.fillWidth: true + text: qsTr("All available output devices") + color: Colours.palette.m3outline + } + + Repeater { + Layout.fillWidth: true + model: Audio.sinks + + delegate: StyledRect { + required property var modelData + + Layout.fillWidth: true + + color: Audio.sink?.id === modelData.id ? Colours.layer(Colours.palette.m3surfaceContainer, 2) : "transparent" + radius: Appearance.rounding.normal + + StateLayer { + function onClicked(): void { + Audio.setAudioSink(modelData); + } + } + + RowLayout { + id: outputRowLayout + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Appearance.padding.normal + + spacing: Appearance.spacing.normal + + MaterialIcon { + text: Audio.sink?.id === modelData.id ? "speaker" : "speaker_group" + font.pointSize: Appearance.font.size.large + fill: Audio.sink?.id === modelData.id ? 1 : 0 + } + + StyledText { + Layout.fillWidth: true + elide: Text.ElideRight + maximumLineCount: 1 + + text: modelData.description || qsTr("Unknown") + font.weight: Audio.sink?.id === modelData.id ? 500 : 400 + } + } + + implicitHeight: outputRowLayout.implicitHeight + Appearance.padding.normal * 2 + } + } + } + } + + CollapsibleSection { + id: inputDevicesSection + + Layout.fillWidth: true + title: qsTr("Input devices") + expanded: true + + ColumnLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.small + + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.small + + StyledText { + text: qsTr("Devices (%1)").arg(Audio.sources.length) + font.pointSize: Appearance.font.size.normal + font.weight: 500 + } + } + + StyledText { + Layout.fillWidth: true + text: qsTr("All available input devices") + color: Colours.palette.m3outline + } + + Repeater { + Layout.fillWidth: true + model: Audio.sources + + delegate: StyledRect { + required property var modelData + + Layout.fillWidth: true + + color: Audio.source?.id === modelData.id ? Colours.layer(Colours.palette.m3surfaceContainer, 2) : "transparent" + radius: Appearance.rounding.normal + + StateLayer { + function onClicked(): void { + Audio.setAudioSource(modelData); + } + } + + RowLayout { + id: inputRowLayout + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Appearance.padding.normal + + spacing: Appearance.spacing.normal + + MaterialIcon { + text: "mic" + font.pointSize: Appearance.font.size.large + fill: Audio.source?.id === modelData.id ? 1 : 0 + } + + StyledText { + Layout.fillWidth: true + elide: Text.ElideRight + maximumLineCount: 1 + + text: modelData.description || qsTr("Unknown") + font.weight: Audio.source?.id === modelData.id ? 500 : 400 + } + } + + implicitHeight: inputRowLayout.implicitHeight + Appearance.padding.normal * 2 + } + } + } + } + } + } + } + + rightContent: Component { + StyledFlickable { + id: rightAudioFlickable + flickableDirection: Flickable.VerticalFlick + contentHeight: contentLayout.height + + StyledScrollBar.vertical: StyledScrollBar { + flickable: rightAudioFlickable + } + + ColumnLayout { + id: contentLayout + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + spacing: Appearance.spacing.normal + + SettingsHeader { + icon: "volume_up" + title: qsTr("Audio Settings") + } + + SectionHeader { + title: qsTr("Output volume") + description: qsTr("Control the volume of your output device") + } + + SectionContainer { + contentSpacing: Appearance.spacing.normal + + ColumnLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.small + + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + StyledText { + text: qsTr("Volume") + font.pointSize: Appearance.font.size.normal + font.weight: 500 + } + + Item { + Layout.fillWidth: true + } + + StyledInputField { + id: outputVolumeInput + Layout.preferredWidth: 70 + validator: IntValidator { + bottom: 0 + top: 100 + } + enabled: !Audio.muted + + Component.onCompleted: { + text = Math.round(Audio.volume * 100).toString(); + } + + Connections { + target: Audio + function onVolumeChanged() { + if (!outputVolumeInput.hasFocus) { + outputVolumeInput.text = Math.round(Audio.volume * 100).toString(); + } + } + } + + onTextEdited: text => { + if (hasFocus) { + const val = parseInt(text); + if (!isNaN(val) && val >= 0 && val <= 100) { + Audio.setVolume(val / 100); + } + } + } + + onEditingFinished: { + const val = parseInt(text); + if (isNaN(val) || val < 0 || val > 100) { + text = Math.round(Audio.volume * 100).toString(); + } + } + } + + StyledText { + text: "%" + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.normal + opacity: Audio.muted ? 0.5 : 1 + } + + StyledRect { + implicitWidth: implicitHeight + implicitHeight: muteIcon.implicitHeight + Appearance.padding.normal * 2 + + radius: Appearance.rounding.normal + color: Audio.muted ? Colours.palette.m3secondary : Colours.palette.m3secondaryContainer + + StateLayer { + function onClicked(): void { + if (Audio.sink?.audio) { + Audio.sink.audio.muted = !Audio.sink.audio.muted; + } + } + } + + MaterialIcon { + id: muteIcon + + anchors.centerIn: parent + text: Audio.muted ? "volume_off" : "volume_up" + color: Audio.muted ? Colours.palette.m3onSecondary : Colours.palette.m3onSecondaryContainer + } + } + } + + StyledSlider { + id: outputVolumeSlider + Layout.fillWidth: true + implicitHeight: Appearance.padding.normal * 3 + + value: Audio.volume + enabled: !Audio.muted + opacity: enabled ? 1 : 0.5 + onMoved: { + Audio.setVolume(value); + if (!outputVolumeInput.hasFocus) { + outputVolumeInput.text = Math.round(value * 100).toString(); + } + } + } + } + } + + SectionHeader { + title: qsTr("Input volume") + description: qsTr("Control the volume of your input device") + } + + SectionContainer { + contentSpacing: Appearance.spacing.normal + + ColumnLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.small + + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + StyledText { + text: qsTr("Volume") + font.pointSize: Appearance.font.size.normal + font.weight: 500 + } + + Item { + Layout.fillWidth: true + } + + StyledInputField { + id: inputVolumeInput + Layout.preferredWidth: 70 + validator: IntValidator { + bottom: 0 + top: 100 + } + enabled: !Audio.sourceMuted + + Component.onCompleted: { + text = Math.round(Audio.sourceVolume * 100).toString(); + } + + Connections { + target: Audio + function onSourceVolumeChanged() { + if (!inputVolumeInput.hasFocus) { + inputVolumeInput.text = Math.round(Audio.sourceVolume * 100).toString(); + } + } + } + + onTextEdited: text => { + if (hasFocus) { + const val = parseInt(text); + if (!isNaN(val) && val >= 0 && val <= 100) { + Audio.setSourceVolume(val / 100); + } + } + } + + onEditingFinished: { + const val = parseInt(text); + if (isNaN(val) || val < 0 || val > 100) { + text = Math.round(Audio.sourceVolume * 100).toString(); + } + } + } + + StyledText { + text: "%" + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.normal + opacity: Audio.sourceMuted ? 0.5 : 1 + } + + StyledRect { + implicitWidth: implicitHeight + implicitHeight: muteInputIcon.implicitHeight + Appearance.padding.normal * 2 + + radius: Appearance.rounding.normal + color: Audio.sourceMuted ? Colours.palette.m3secondary : Colours.palette.m3secondaryContainer + + StateLayer { + function onClicked(): void { + if (Audio.source?.audio) { + Audio.source.audio.muted = !Audio.source.audio.muted; + } + } + } + + MaterialIcon { + id: muteInputIcon + + anchors.centerIn: parent + text: "mic_off" + color: Audio.sourceMuted ? Colours.palette.m3onSecondary : Colours.palette.m3onSecondaryContainer + } + } + } + + StyledSlider { + id: inputVolumeSlider + Layout.fillWidth: true + implicitHeight: Appearance.padding.normal * 3 + + value: Audio.sourceVolume + enabled: !Audio.sourceMuted + opacity: enabled ? 1 : 0.5 + onMoved: { + Audio.setSourceVolume(value); + if (!inputVolumeInput.hasFocus) { + inputVolumeInput.text = Math.round(value * 100).toString(); + } + } + } + } + } + + SectionHeader { + title: qsTr("Applications") + description: qsTr("Control volume for individual applications") + } + + SectionContainer { + contentSpacing: Appearance.spacing.normal + + ColumnLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.small + + Repeater { + model: Audio.streams + Layout.fillWidth: true + + delegate: ColumnLayout { + required property var modelData + required property int index + + Layout.fillWidth: true + spacing: Appearance.spacing.smaller + + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + MaterialIcon { + text: "apps" + font.pointSize: Appearance.font.size.normal + fill: 0 + } + + StyledText { + Layout.fillWidth: true + elide: Text.ElideRight + maximumLineCount: 1 + text: Audio.getStreamName(modelData) + font.pointSize: Appearance.font.size.normal + font.weight: 500 + } + + StyledInputField { + id: streamVolumeInput + Layout.preferredWidth: 70 + validator: IntValidator { + bottom: 0 + top: 100 + } + enabled: !Audio.getStreamMuted(modelData) + + Component.onCompleted: { + text = Math.round(Audio.getStreamVolume(modelData) * 100).toString(); + } + + Connections { + target: modelData + function onAudioChanged() { + if (!streamVolumeInput.hasFocus && modelData?.audio) { + streamVolumeInput.text = Math.round(modelData.audio.volume * 100).toString(); + } + } + } + + onTextEdited: text => { + if (hasFocus) { + const val = parseInt(text); + if (!isNaN(val) && val >= 0 && val <= 100) { + Audio.setStreamVolume(modelData, val / 100); + } + } + } + + onEditingFinished: { + const val = parseInt(text); + if (isNaN(val) || val < 0 || val > 100) { + text = Math.round(Audio.getStreamVolume(modelData) * 100).toString(); + } + } + } + + StyledText { + text: "%" + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.normal + opacity: Audio.getStreamMuted(modelData) ? 0.5 : 1 + } + + StyledRect { + implicitWidth: implicitHeight + implicitHeight: streamMuteIcon.implicitHeight + Appearance.padding.normal * 2 + + radius: Appearance.rounding.normal + color: Audio.getStreamMuted(modelData) ? Colours.palette.m3secondary : Colours.palette.m3secondaryContainer + + StateLayer { + function onClicked(): void { + Audio.setStreamMuted(modelData, !Audio.getStreamMuted(modelData)); + } + } + + MaterialIcon { + id: streamMuteIcon + + anchors.centerIn: parent + text: Audio.getStreamMuted(modelData) ? "volume_off" : "volume_up" + color: Audio.getStreamMuted(modelData) ? Colours.palette.m3onSecondary : Colours.palette.m3onSecondaryContainer + } + } + } + + StyledSlider { + Layout.fillWidth: true + implicitHeight: Appearance.padding.normal * 3 + + value: Audio.getStreamVolume(modelData) + enabled: !Audio.getStreamMuted(modelData) + opacity: enabled ? 1 : 0.5 + onMoved: { + Audio.setStreamVolume(modelData, value); + if (!streamVolumeInput.hasFocus) { + streamVolumeInput.text = Math.round(value * 100).toString(); + } + } + + Connections { + target: modelData + function onAudioChanged() { + if (modelData?.audio) { + value = modelData.audio.volume; + } + } + } + } + } + } + + StyledText { + Layout.fillWidth: true + visible: Audio.streams.length === 0 + text: qsTr("No applications currently playing audio") + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.small + horizontalAlignment: Text.AlignHCenter + } + } + } + } + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/bluetooth/BtPane.qml b/.config/quickshell/caelestia/modules/controlcenter/bluetooth/BtPane.qml new file mode 100644 index 0000000..7d3b9ca --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/bluetooth/BtPane.qml @@ -0,0 +1,73 @@ +pragma ComponentBehavior: Bound + +import ".." +import "../components" +import "." +import qs.components +import qs.components.controls +import qs.components.containers +import qs.config +import Quickshell.Widgets +import Quickshell.Bluetooth +import QtQuick + +SplitPaneWithDetails { + id: root + + required property Session session + + anchors.fill: parent + + activeItem: session.bt.active + paneIdGenerator: function (item) { + return item ? (item.address || "") : ""; + } + + leftContent: Component { + StyledFlickable { + id: leftFlickable + + flickableDirection: Flickable.VerticalFlick + contentHeight: deviceList.height + + StyledScrollBar.vertical: StyledScrollBar { + flickable: leftFlickable + } + + DeviceList { + id: deviceList + + anchors.left: parent.left + anchors.right: parent.right + session: root.session + } + } + } + + rightDetailsComponent: Component { + Details { + session: root.session + } + } + + rightSettingsComponent: Component { + StyledFlickable { + id: settingsFlickable + flickableDirection: Flickable.VerticalFlick + contentHeight: settingsInner.height + + StyledScrollBar.vertical: StyledScrollBar { + flickable: settingsFlickable + } + + Settings { + id: settingsInner + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + session: root.session + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/bluetooth/Details.qml b/.config/quickshell/caelestia/modules/controlcenter/bluetooth/Details.qml new file mode 100644 index 0000000..bc276e0 --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/bluetooth/Details.qml @@ -0,0 +1,671 @@ +pragma ComponentBehavior: Bound + +import ".." +import "../components" +import qs.components +import qs.components.controls +import qs.components.effects +import qs.components.containers +import qs.services +import qs.config +import qs.utils +import Quickshell.Bluetooth +import QtQuick +import QtQuick.Layouts + +StyledFlickable { + id: root + + required property Session session + readonly property BluetoothDevice device: session.bt.active + + flickableDirection: Flickable.VerticalFlick + contentHeight: detailsWrapper.height + + StyledScrollBar.vertical: StyledScrollBar { + flickable: root + } + + Item { + id: detailsWrapper + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + implicitHeight: details.implicitHeight + + DeviceDetails { + id: details + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + + session: root.session + device: root.device + + headerComponent: Component { + SettingsHeader { + icon: Icons.getBluetoothIcon(root.device?.icon ?? "") + title: root.device?.name ?? "" + } + } + + sections: [ + Component { + ColumnLayout { + spacing: Appearance.spacing.normal + + StyledText { + Layout.topMargin: Appearance.spacing.large + text: qsTr("Connection status") + font.pointSize: Appearance.font.size.larger + font.weight: 500 + } + + StyledText { + text: qsTr("Connection settings for this device") + color: Colours.palette.m3outline + } + + StyledRect { + Layout.fillWidth: true + implicitHeight: deviceStatus.implicitHeight + Appearance.padding.large * 2 + + radius: Appearance.rounding.normal + color: Colours.tPalette.m3surfaceContainer + + ColumnLayout { + id: deviceStatus + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Appearance.padding.large + + spacing: Appearance.spacing.larger + + Toggle { + label: qsTr("Connected") + checked: root.device?.connected ?? false + toggle.onToggled: root.device.connected = checked + } + + Toggle { + label: qsTr("Paired") + checked: root.device?.paired ?? false + toggle.onToggled: { + if (root.device.paired) + root.device.forget(); + else + root.device.pair(); + } + } + + Toggle { + label: qsTr("Blocked") + checked: root.device?.blocked ?? false + toggle.onToggled: root.device.blocked = checked + } + } + } + } + }, + Component { + ColumnLayout { + spacing: Appearance.spacing.normal + + StyledText { + Layout.topMargin: Appearance.spacing.large + text: qsTr("Device properties") + font.pointSize: Appearance.font.size.larger + font.weight: 500 + } + + StyledText { + text: qsTr("Additional settings") + color: Colours.palette.m3outline + } + + StyledRect { + Layout.fillWidth: true + implicitHeight: deviceProps.implicitHeight + Appearance.padding.large * 2 + + radius: Appearance.rounding.normal + color: Colours.tPalette.m3surfaceContainer + + ColumnLayout { + id: deviceProps + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Appearance.padding.large + + spacing: Appearance.spacing.larger + + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.small + + Item { + id: renameDevice + + Layout.fillWidth: true + Layout.rightMargin: Appearance.spacing.small + + implicitHeight: renameLabel.implicitHeight + deviceNameEdit.implicitHeight + + states: State { + name: "editingDeviceName" + when: root.session.bt.editingDeviceName + + AnchorChanges { + target: deviceNameEdit + anchors.top: renameDevice.top + } + PropertyChanges { + renameDevice.implicitHeight: deviceNameEdit.implicitHeight + renameLabel.opacity: 0 + deviceNameEdit.padding: Appearance.padding.normal + } + } + + transitions: Transition { + AnchorAnimation { + duration: Appearance.anim.durations.normal + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.anim.curves.standard + } + Anim { + properties: "implicitHeight,opacity,padding" + } + } + + StyledText { + id: renameLabel + + anchors.left: parent.left + + text: qsTr("Device name") + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.small + } + + StyledTextField { + id: deviceNameEdit + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: renameLabel.bottom + anchors.leftMargin: root.session.bt.editingDeviceName ? 0 : -Appearance.padding.normal + + text: root.device?.name ?? "" + readOnly: !root.session.bt.editingDeviceName + onAccepted: { + root.session.bt.editingDeviceName = false; + root.device.name = text; + } + + leftPadding: Appearance.padding.normal + rightPadding: Appearance.padding.normal + + background: StyledRect { + radius: Appearance.rounding.small + border.width: 2 + border.color: Colours.palette.m3primary + opacity: root.session.bt.editingDeviceName ? 1 : 0 + + Behavior on border.color { + CAnim {} + } + + Behavior on opacity { + Anim {} + } + } + + Behavior on anchors.leftMargin { + Anim {} + } + } + } + + StyledRect { + implicitWidth: implicitHeight + implicitHeight: cancelEditIcon.implicitHeight + Appearance.padding.smaller * 2 + + radius: Appearance.rounding.small + color: Colours.palette.m3secondaryContainer + opacity: root.session.bt.editingDeviceName ? 1 : 0 + scale: root.session.bt.editingDeviceName ? 1 : 0.5 + + StateLayer { + color: Colours.palette.m3onSecondaryContainer + disabled: !root.session.bt.editingDeviceName + + function onClicked(): void { + root.session.bt.editingDeviceName = false; + deviceNameEdit.text = Qt.binding(() => root.device?.name ?? ""); + } + } + + MaterialIcon { + id: cancelEditIcon + + anchors.centerIn: parent + animate: true + text: "cancel" + color: Colours.palette.m3onSecondaryContainer + } + + Behavior on opacity { + Anim {} + } + + Behavior on scale { + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + } + } + } + + StyledRect { + implicitWidth: implicitHeight + implicitHeight: editIcon.implicitHeight + Appearance.padding.smaller * 2 + + radius: root.session.bt.editingDeviceName ? Appearance.rounding.small : implicitHeight / 2 * Math.min(1, Appearance.rounding.scale) + color: Qt.alpha(Colours.palette.m3primary, root.session.bt.editingDeviceName ? 1 : 0) + + StateLayer { + color: root.session.bt.editingDeviceName ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface + + function onClicked(): void { + root.session.bt.editingDeviceName = !root.session.bt.editingDeviceName; + if (root.session.bt.editingDeviceName) + deviceNameEdit.forceActiveFocus(); + else + deviceNameEdit.accepted(); + } + } + + MaterialIcon { + id: editIcon + + anchors.centerIn: parent + animate: true + text: root.session.bt.editingDeviceName ? "check_circle" : "edit" + color: root.session.bt.editingDeviceName ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface + } + + Behavior on radius { + Anim {} + } + } + } + + Toggle { + label: qsTr("Trusted") + checked: root.device?.trusted ?? false + toggle.onToggled: root.device.trusted = checked + } + + Toggle { + label: qsTr("Wake allowed") + checked: root.device?.wakeAllowed ?? false + toggle.onToggled: root.device.wakeAllowed = checked + } + } + } + } + }, + Component { + ColumnLayout { + spacing: Appearance.spacing.normal + + StyledText { + Layout.topMargin: Appearance.spacing.large + text: qsTr("Device information") + font.pointSize: Appearance.font.size.larger + font.weight: 500 + } + + StyledText { + text: qsTr("Information about this device") + color: Colours.palette.m3outline + } + + StyledRect { + Layout.fillWidth: true + implicitHeight: deviceInfo.implicitHeight + Appearance.padding.large * 2 + + radius: Appearance.rounding.normal + color: Colours.tPalette.m3surfaceContainer + + ColumnLayout { + id: deviceInfo + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Appearance.padding.large + + spacing: Appearance.spacing.small / 2 + + StyledText { + text: root.device?.batteryAvailable ? qsTr("Device battery (%1%)").arg(root.device.battery * 100) : qsTr("Battery unavailable") + } + + RowLayout { + id: batteryPercent + Layout.topMargin: Appearance.spacing.small / 2 + Layout.fillWidth: true + Layout.preferredHeight: Appearance.padding.smaller + spacing: Appearance.spacing.small / 2 + + StyledRect { + Layout.fillWidth: true + Layout.fillHeight: true + radius: Appearance.rounding.full + color: Colours.palette.m3secondaryContainer + + StyledRect { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.margins: parent.height * 0.25 + + implicitWidth: root.device?.batteryAvailable ? batteryPercent.width * root.device.battery : 0 + radius: Appearance.rounding.full + color: Colours.palette.m3primary + } + } + } + + StyledText { + Layout.topMargin: Appearance.spacing.normal + text: qsTr("Dbus path") + } + + StyledText { + text: root.device?.dbusPath ?? "" + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.small + } + + StyledText { + Layout.topMargin: Appearance.spacing.normal + text: qsTr("MAC address") + } + + StyledText { + text: root.device?.address ?? "" + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.small + } + + StyledText { + Layout.topMargin: Appearance.spacing.normal + text: qsTr("Bonded") + } + + StyledText { + text: root.device?.bonded ? qsTr("Yes") : qsTr("No") + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.small + } + + StyledText { + Layout.topMargin: Appearance.spacing.normal + text: qsTr("System name") + } + + StyledText { + text: root.device?.deviceName ?? "" + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.small + } + } + } + } + } + ] + } + } + + ColumnLayout { + anchors.right: fabRoot.right + anchors.bottom: fabRoot.top + anchors.bottomMargin: Appearance.padding.normal + + Repeater { + id: fabMenu + + model: ListModel { + ListElement { + name: "trust" + icon: "handshake" + } + ListElement { + name: "block" + icon: "block" + } + ListElement { + name: "pair" + icon: "missing_controller" + } + ListElement { + name: "connect" + icon: "bluetooth_connected" + } + } + + StyledClippingRect { + id: fabMenuItem + + required property var modelData + required property int index + + Layout.alignment: Qt.AlignRight + + implicitHeight: fabMenuItemInner.implicitHeight + Appearance.padding.larger * 2 + + radius: Appearance.rounding.full + color: Colours.palette.m3primaryContainer + + opacity: 0 + + states: State { + name: "visible" + when: root.session.bt.fabMenuOpen + + PropertyChanges { + fabMenuItem.implicitWidth: fabMenuItemInner.implicitWidth + Appearance.padding.large * 2 + fabMenuItem.opacity: 1 + fabMenuItemInner.opacity: 1 + } + } + + transitions: [ + Transition { + to: "visible" + + SequentialAnimation { + PauseAnimation { + duration: (fabMenu.count - 1 - fabMenuItem.index) * Appearance.anim.durations.small / 8 + } + ParallelAnimation { + Anim { + property: "implicitWidth" + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + } + Anim { + property: "opacity" + duration: Appearance.anim.durations.small + } + } + } + }, + Transition { + from: "visible" + + SequentialAnimation { + PauseAnimation { + duration: fabMenuItem.index * Appearance.anim.durations.small / 8 + } + ParallelAnimation { + Anim { + property: "implicitWidth" + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + } + Anim { + property: "opacity" + duration: Appearance.anim.durations.small + } + } + } + } + ] + + StateLayer { + function onClicked(): void { + root.session.bt.fabMenuOpen = false; + + const name = fabMenuItem.modelData.name; + if (fabMenuItem.modelData.name !== "pair") + root.device[`${name}ed`] = !root.device[`${name}ed`]; + else if (root.device.paired) + root.device.forget(); + else + root.device.pair(); + } + } + + RowLayout { + id: fabMenuItemInner + + anchors.centerIn: parent + spacing: Appearance.spacing.normal + opacity: 0 + + MaterialIcon { + text: fabMenuItem.modelData.icon + color: Colours.palette.m3onPrimaryContainer + fill: 1 + } + + StyledText { + animate: true + text: (root.device && root.device[`${fabMenuItem.modelData.name}ed`] ? fabMenuItem.modelData.name === "connect" ? "dis" : "un" : "") + fabMenuItem.modelData.name + color: Colours.palette.m3onPrimaryContainer + font.capitalization: Font.Capitalize + Layout.preferredWidth: implicitWidth + + Behavior on Layout.preferredWidth { + Anim { + duration: Appearance.anim.durations.small + } + } + } + } + } + } + } + + Item { + id: fabRoot + + x: root.contentX + root.width - width + y: root.contentY + root.height - height + width: 64 + height: 64 + z: 10000 + + StyledRect { + id: fabBg + + anchors.right: parent.right + anchors.top: parent.top + + implicitWidth: 64 + implicitHeight: 64 + + radius: Appearance.rounding.normal + color: root.session.bt.fabMenuOpen ? Colours.palette.m3primary : Colours.palette.m3primaryContainer + + states: State { + name: "expanded" + when: root.session.bt.fabMenuOpen + + PropertyChanges { + fabBg.implicitWidth: 48 + fabBg.implicitHeight: 48 + fabBg.radius: 48 / 2 + fab.font.pointSize: Appearance.font.size.larger + } + } + + transitions: Transition { + Anim { + properties: "implicitWidth,implicitHeight" + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + } + Anim { + properties: "radius,font.pointSize" + } + } + + Elevation { + anchors.fill: parent + radius: parent.radius + z: -1 + level: fabState.containsMouse && !fabState.pressed ? 4 : 3 + } + + StateLayer { + id: fabState + + color: root.session.bt.fabMenuOpen ? Colours.palette.m3onPrimary : Colours.palette.m3onPrimaryContainer + + function onClicked(): void { + root.session.bt.fabMenuOpen = !root.session.bt.fabMenuOpen; + } + } + + MaterialIcon { + id: fab + + anchors.centerIn: parent + animate: true + text: root.session.bt.fabMenuOpen ? "close" : "settings" + color: root.session.bt.fabMenuOpen ? Colours.palette.m3onPrimary : Colours.palette.m3onPrimaryContainer + font.pointSize: Appearance.font.size.large + fill: 1 + } + } + } + + component Toggle: RowLayout { + required property string label + property alias checked: toggle.checked + property alias toggle: toggle + + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + StyledText { + Layout.fillWidth: true + text: parent.label + } + + StyledSwitch { + id: toggle + + cLayer: 2 + } + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/bluetooth/DeviceList.qml b/.config/quickshell/caelestia/modules/controlcenter/bluetooth/DeviceList.qml new file mode 100644 index 0000000..2a2bde9 --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/bluetooth/DeviceList.qml @@ -0,0 +1,264 @@ +pragma ComponentBehavior: Bound + +import ".." +import "../components" +import qs.components +import qs.components.controls +import qs.components.containers +import qs.services +import qs.config +import qs.utils +import Quickshell +import Quickshell.Bluetooth +import QtQuick +import QtQuick.Layouts + +DeviceList { + id: root + + required property Session session + readonly property bool smallDiscoverable: width <= 540 + readonly property bool smallPairable: width <= 480 + + title: qsTr("Devices (%1)").arg(Bluetooth.devices.values.length) + description: qsTr("All available bluetooth devices") + activeItem: session.bt.active + + model: ScriptModel { + id: deviceModel + + values: [...Bluetooth.devices.values].sort((a, b) => (b.connected - a.connected) || (b.paired - a.paired) || a.name.localeCompare(b.name)) + } + + headerComponent: Component { + RowLayout { + spacing: Appearance.spacing.smaller + + StyledText { + text: qsTr("Bluetooth") + font.pointSize: Appearance.font.size.large + font.weight: 500 + } + + Item { + Layout.fillWidth: true + } + + ToggleButton { + toggled: Bluetooth.defaultAdapter?.enabled ?? false + icon: "power" + accent: "Tertiary" + iconSize: Appearance.font.size.normal + horizontalPadding: Appearance.padding.normal + verticalPadding: Appearance.padding.smaller + tooltip: qsTr("Toggle Bluetooth") + + onClicked: { + const adapter = Bluetooth.defaultAdapter; + if (adapter) + adapter.enabled = !adapter.enabled; + } + } + + ToggleButton { + toggled: Bluetooth.defaultAdapter?.discoverable ?? false + icon: root.smallDiscoverable ? "group_search" : "" + label: root.smallDiscoverable ? "" : qsTr("Discoverable") + iconSize: Appearance.font.size.normal + horizontalPadding: Appearance.padding.normal + verticalPadding: Appearance.padding.smaller + tooltip: qsTr("Make discoverable") + + onClicked: { + const adapter = Bluetooth.defaultAdapter; + if (adapter) + adapter.discoverable = !adapter.discoverable; + } + } + + ToggleButton { + toggled: Bluetooth.defaultAdapter?.pairable ?? false + icon: "missing_controller" + label: root.smallPairable ? "" : qsTr("Pairable") + iconSize: Appearance.font.size.normal + horizontalPadding: Appearance.padding.normal + verticalPadding: Appearance.padding.smaller + tooltip: qsTr("Make pairable") + + onClicked: { + const adapter = Bluetooth.defaultAdapter; + if (adapter) + adapter.pairable = !adapter.pairable; + } + } + + ToggleButton { + toggled: Bluetooth.defaultAdapter?.discovering ?? false + icon: "bluetooth_searching" + accent: "Secondary" + iconSize: Appearance.font.size.normal + horizontalPadding: Appearance.padding.normal + verticalPadding: Appearance.padding.smaller + tooltip: qsTr("Scan for devices") + + onClicked: { + const adapter = Bluetooth.defaultAdapter; + if (adapter) + adapter.discovering = !adapter.discovering; + } + } + + ToggleButton { + toggled: !root.session.bt.active + icon: "settings" + accent: "Primary" + iconSize: Appearance.font.size.normal + horizontalPadding: Appearance.padding.normal + verticalPadding: Appearance.padding.smaller + tooltip: qsTr("Bluetooth settings") + + onClicked: { + if (root.session.bt.active) + root.session.bt.active = null; + else { + root.session.bt.active = root.model.values[0] ?? null; + } + } + } + } + } + + delegate: Component { + StyledRect { + id: device + + required property BluetoothDevice modelData + readonly property bool loading: modelData && (modelData.state === BluetoothDeviceState.Connecting || modelData.state === BluetoothDeviceState.Disconnecting) + readonly property bool connected: modelData && modelData.state === BluetoothDeviceState.Connected + + width: ListView.view ? ListView.view.width : undefined + implicitHeight: deviceInner.implicitHeight + Appearance.padding.normal * 2 + + color: Qt.alpha(Colours.tPalette.m3surfaceContainer, root.activeItem === modelData ? Colours.tPalette.m3surfaceContainer.a : 0) + radius: Appearance.rounding.normal + + StateLayer { + id: stateLayer + + function onClicked(): void { + if (device.modelData) + root.session.bt.active = device.modelData; + } + } + + RowLayout { + id: deviceInner + + anchors.fill: parent + anchors.margins: Appearance.padding.normal + + spacing: Appearance.spacing.normal + + StyledRect { + implicitWidth: implicitHeight + implicitHeight: icon.implicitHeight + Appearance.padding.normal * 2 + + radius: Appearance.rounding.normal + color: device.connected ? Colours.palette.m3primaryContainer : (device.modelData && device.modelData.bonded) ? Colours.palette.m3secondaryContainer : Colours.tPalette.m3surfaceContainerHigh + + StyledRect { + anchors.fill: parent + radius: parent.radius + color: Qt.alpha(device.connected ? Colours.palette.m3onPrimaryContainer : (device.modelData && device.modelData.bonded) ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface, stateLayer.pressed ? 0.1 : stateLayer.containsMouse ? 0.08 : 0) + } + + MaterialIcon { + id: icon + + anchors.centerIn: parent + text: Icons.getBluetoothIcon(device.modelData ? device.modelData.icon : "") + color: device.connected ? Colours.palette.m3onPrimaryContainer : (device.modelData && device.modelData.bonded) ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface + font.pointSize: Appearance.font.size.large + fill: device.connected ? 1 : 0 + + Behavior on fill { + Anim {} + } + } + } + + ColumnLayout { + Layout.fillWidth: true + + spacing: 0 + + StyledText { + Layout.fillWidth: true + text: device.modelData ? device.modelData.name : qsTr("Unknown") + elide: Text.ElideRight + } + + StyledText { + Layout.fillWidth: true + text: (device.modelData ? device.modelData.address : "") + (device.connected ? qsTr(" (Connected)") : (device.modelData && device.modelData.bonded) ? qsTr(" (Paired)") : "") + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.small + elide: Text.ElideRight + } + } + + StyledRect { + id: connectBtn + + implicitWidth: implicitHeight + implicitHeight: connectIcon.implicitHeight + Appearance.padding.smaller * 2 + + radius: Appearance.rounding.full + color: Qt.alpha(Colours.palette.m3primaryContainer, device.connected ? 1 : 0) + + CircularIndicator { + anchors.fill: parent + running: device.loading + } + + StateLayer { + color: device.connected ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface + disabled: device.loading + + function onClicked(): void { + if (device.loading) + return; + + if (device.connected) { + device.modelData.connected = false; + } else { + if (device.modelData.bonded) { + device.modelData.connected = true; + } else { + device.modelData.pair(); + } + } + } + } + + MaterialIcon { + id: connectIcon + + anchors.centerIn: parent + animate: true + text: device.connected ? "link_off" : "link" + color: device.connected ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface + + opacity: device.loading ? 0 : 1 + + Behavior on opacity { + Anim {} + } + } + } + } + } + } + + onItemSelected: item => session.bt.active = item +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/bluetooth/Settings.qml b/.config/quickshell/caelestia/modules/controlcenter/bluetooth/Settings.qml new file mode 100644 index 0000000..c547240 --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/bluetooth/Settings.qml @@ -0,0 +1,532 @@ +pragma ComponentBehavior: Bound + +import ".." +import "../components" +import qs.components +import qs.components.controls +import qs.components.effects +import qs.services +import qs.config +import Quickshell.Bluetooth +import QtQuick +import QtQuick.Layouts + +ColumnLayout { + id: root + + required property Session session + + spacing: Appearance.spacing.normal + + SettingsHeader { + icon: "bluetooth" + title: qsTr("Bluetooth Settings") + } + + StyledText { + Layout.topMargin: Appearance.spacing.large + text: qsTr("Adapter status") + font.pointSize: Appearance.font.size.larger + font.weight: 500 + } + + StyledText { + text: qsTr("General adapter settings") + color: Colours.palette.m3outline + } + + StyledRect { + Layout.fillWidth: true + implicitHeight: adapterStatus.implicitHeight + Appearance.padding.large * 2 + + radius: Appearance.rounding.normal + color: Colours.tPalette.m3surfaceContainer + + ColumnLayout { + id: adapterStatus + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Appearance.padding.large + + spacing: Appearance.spacing.larger + + Toggle { + label: qsTr("Powered") + checked: Bluetooth.defaultAdapter?.enabled ?? false + toggle.onToggled: { + const adapter = Bluetooth.defaultAdapter; + if (adapter) + adapter.enabled = checked; + } + } + + Toggle { + label: qsTr("Discoverable") + checked: Bluetooth.defaultAdapter?.discoverable ?? false + toggle.onToggled: { + const adapter = Bluetooth.defaultAdapter; + if (adapter) + adapter.discoverable = checked; + } + } + + Toggle { + label: qsTr("Pairable") + checked: Bluetooth.defaultAdapter?.pairable ?? false + toggle.onToggled: { + const adapter = Bluetooth.defaultAdapter; + if (adapter) + adapter.pairable = checked; + } + } + } + } + + StyledText { + Layout.topMargin: Appearance.spacing.large + text: qsTr("Adapter properties") + font.pointSize: Appearance.font.size.larger + font.weight: 500 + } + + StyledText { + text: qsTr("Per-adapter settings") + color: Colours.palette.m3outline + } + + StyledRect { + Layout.fillWidth: true + implicitHeight: adapterSettings.implicitHeight + Appearance.padding.large * 2 + + radius: Appearance.rounding.normal + color: Colours.tPalette.m3surfaceContainer + + ColumnLayout { + id: adapterSettings + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Appearance.padding.large + + spacing: Appearance.spacing.larger + + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + StyledText { + Layout.fillWidth: true + text: qsTr("Current adapter") + } + + Item { + id: adapterPickerButton + + property bool expanded + + implicitWidth: adapterPicker.implicitWidth + Appearance.padding.normal * 2 + implicitHeight: adapterPicker.implicitHeight + Appearance.padding.smaller * 2 + + StateLayer { + radius: Appearance.rounding.small + + function onClicked(): void { + adapterPickerButton.expanded = !adapterPickerButton.expanded; + } + } + + RowLayout { + id: adapterPicker + + anchors.fill: parent + anchors.margins: Appearance.padding.normal + anchors.topMargin: Appearance.padding.smaller + anchors.bottomMargin: Appearance.padding.smaller + spacing: Appearance.spacing.normal + + StyledText { + Layout.leftMargin: Appearance.padding.small + text: Bluetooth.defaultAdapter?.name ?? qsTr("None") + } + + MaterialIcon { + text: "expand_more" + } + } + + Elevation { + anchors.fill: adapterListBg + radius: adapterListBg.radius + opacity: adapterPickerButton.expanded ? 1 : 0 + scale: adapterPickerButton.expanded ? 1 : 0.7 + level: 2 + + Behavior on opacity { + Anim {} + } + + Behavior on scale { + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + } + } + } + + StyledClippingRect { + id: adapterListBg + + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + implicitHeight: adapterPickerButton.expanded ? adapterList.implicitHeight : adapterPickerButton.implicitHeight + + color: Colours.palette.m3secondaryContainer + radius: Appearance.rounding.small + opacity: adapterPickerButton.expanded ? 1 : 0 + scale: adapterPickerButton.expanded ? 1 : 0.7 + + ColumnLayout { + id: adapterList + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + + spacing: 0 + + Repeater { + model: Bluetooth.adapters + + Item { + id: adapter + + required property BluetoothAdapter modelData + + Layout.fillWidth: true + implicitHeight: adapterInner.implicitHeight + Appearance.padding.normal * 2 + + StateLayer { + disabled: !adapterPickerButton.expanded + + function onClicked(): void { + adapterPickerButton.expanded = false; + root.session.bt.currentAdapter = adapter.modelData; + } + } + + RowLayout { + id: adapterInner + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Appearance.padding.normal + spacing: Appearance.spacing.normal + + StyledText { + Layout.fillWidth: true + Layout.leftMargin: Appearance.padding.small + text: adapter.modelData.name + color: Colours.palette.m3onSecondaryContainer + } + + MaterialIcon { + text: "check" + color: Colours.palette.m3onSecondaryContainer + visible: adapter.modelData === root.session.bt.currentAdapter + } + } + } + } + } + + Behavior on opacity { + Anim {} + } + + Behavior on scale { + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + } + } + + Behavior on implicitHeight { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + StyledText { + Layout.fillWidth: true + text: qsTr("Discoverable timeout") + } + + CustomSpinBox { + min: 0 + value: root.session.bt.currentAdapter?.discoverableTimeout ?? 0 + onValueModified: value => { + if (root.session.bt.currentAdapter) { + root.session.bt.currentAdapter.discoverableTimeout = value; + } + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.small + + Item { + id: renameAdapter + + Layout.fillWidth: true + Layout.rightMargin: Appearance.spacing.small + + implicitHeight: renameLabel.implicitHeight + adapterNameEdit.implicitHeight + + states: State { + name: "editingAdapterName" + when: root.session.bt.editingAdapterName + + AnchorChanges { + target: adapterNameEdit + anchors.top: renameAdapter.top + } + PropertyChanges { + renameAdapter.implicitHeight: adapterNameEdit.implicitHeight + renameLabel.opacity: 0 + adapterNameEdit.padding: Appearance.padding.normal + } + } + + transitions: Transition { + AnchorAnimation { + duration: Appearance.anim.durations.normal + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.anim.curves.standard + } + Anim { + properties: "implicitHeight,opacity,padding" + } + } + + StyledText { + id: renameLabel + + anchors.left: parent.left + + text: qsTr("Rename adapter (currently does not work)") + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.small + } + + StyledTextField { + id: adapterNameEdit + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: renameLabel.bottom + anchors.leftMargin: root.session.bt.editingAdapterName ? 0 : -Appearance.padding.normal + + text: root.session.bt.currentAdapter?.name ?? "" + readOnly: !root.session.bt.editingAdapterName + onAccepted: { + root.session.bt.editingAdapterName = false; + } + + leftPadding: Appearance.padding.normal + rightPadding: Appearance.padding.normal + + background: StyledRect { + radius: Appearance.rounding.small + border.width: 2 + border.color: Colours.palette.m3primary + opacity: root.session.bt.editingAdapterName ? 1 : 0 + + Behavior on border.color { + CAnim {} + } + + Behavior on opacity { + Anim {} + } + } + + Behavior on anchors.leftMargin { + Anim {} + } + } + } + + StyledRect { + implicitWidth: implicitHeight + implicitHeight: cancelEditIcon.implicitHeight + Appearance.padding.smaller * 2 + + radius: Appearance.rounding.small + color: Colours.palette.m3secondaryContainer + opacity: root.session.bt.editingAdapterName ? 1 : 0 + scale: root.session.bt.editingAdapterName ? 1 : 0.5 + + StateLayer { + color: Colours.palette.m3onSecondaryContainer + disabled: !root.session.bt.editingAdapterName + + function onClicked(): void { + root.session.bt.editingAdapterName = false; + adapterNameEdit.text = Qt.binding(() => root.session.bt.currentAdapter?.name ?? ""); + } + } + + MaterialIcon { + id: cancelEditIcon + + anchors.centerIn: parent + animate: true + text: "cancel" + color: Colours.palette.m3onSecondaryContainer + } + + Behavior on opacity { + Anim {} + } + + Behavior on scale { + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + } + } + } + + StyledRect { + implicitWidth: implicitHeight + implicitHeight: editIcon.implicitHeight + Appearance.padding.smaller * 2 + + radius: root.session.bt.editingAdapterName ? Appearance.rounding.small : implicitHeight / 2 * Math.min(1, Appearance.rounding.scale) + color: Qt.alpha(Colours.palette.m3primary, root.session.bt.editingAdapterName ? 1 : 0) + + StateLayer { + color: root.session.bt.editingAdapterName ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface + + function onClicked(): void { + root.session.bt.editingAdapterName = !root.session.bt.editingAdapterName; + if (root.session.bt.editingAdapterName) + adapterNameEdit.forceActiveFocus(); + else + adapterNameEdit.accepted(); + } + } + + MaterialIcon { + id: editIcon + + anchors.centerIn: parent + animate: true + text: root.session.bt.editingAdapterName ? "check_circle" : "edit" + color: root.session.bt.editingAdapterName ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface + } + + Behavior on radius { + Anim {} + } + } + } + } + } + + StyledText { + Layout.topMargin: Appearance.spacing.large + text: qsTr("Adapter information") + font.pointSize: Appearance.font.size.larger + font.weight: 500 + } + + StyledText { + text: qsTr("Information about the default adapter") + color: Colours.palette.m3outline + } + + StyledRect { + Layout.fillWidth: true + implicitHeight: adapterInfo.implicitHeight + Appearance.padding.large * 2 + + radius: Appearance.rounding.normal + color: Colours.tPalette.m3surfaceContainer + + ColumnLayout { + id: adapterInfo + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Appearance.padding.large + + spacing: Appearance.spacing.small / 2 + + StyledText { + text: qsTr("Adapter state") + } + + StyledText { + text: Bluetooth.defaultAdapter ? BluetoothAdapterState.toString(Bluetooth.defaultAdapter.state) : qsTr("Unknown") + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.small + } + + StyledText { + Layout.topMargin: Appearance.spacing.normal + text: qsTr("Dbus path") + } + + StyledText { + text: Bluetooth.defaultAdapter?.dbusPath ?? "" + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.small + } + + StyledText { + Layout.topMargin: Appearance.spacing.normal + text: qsTr("Adapter id") + } + + StyledText { + text: Bluetooth.defaultAdapter?.adapterId ?? "" + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.small + } + } + } + + component Toggle: RowLayout { + required property string label + property alias checked: toggle.checked + property alias toggle: toggle + + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + StyledText { + Layout.fillWidth: true + text: parent.label + } + + StyledSwitch { + id: toggle + + cLayer: 2 + } + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/components/ConnectedButtonGroup.qml b/.config/quickshell/caelestia/modules/controlcenter/components/ConnectedButtonGroup.qml new file mode 100644 index 0000000..01cd612 --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/components/ConnectedButtonGroup.qml @@ -0,0 +1,108 @@ +import ".." +import qs.components +import qs.components.controls +import qs.components.effects +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +StyledRect { + id: root + + property var options: [] // Array of {label: string, propertyName: string, onToggled: function} + property var rootItem: null // The root item that contains the properties we want to bind to + property string title: "" // Optional title text + + Layout.fillWidth: true + implicitHeight: layout.implicitHeight + Appearance.padding.large * 2 + radius: Appearance.rounding.normal + color: Colours.layer(Colours.palette.m3surfaceContainer, 2) + clip: true + + Behavior on implicitHeight { + Anim {} + } + + ColumnLayout { + id: layout + + anchors.fill: parent + anchors.margins: Appearance.padding.large + spacing: Appearance.spacing.normal + + StyledText { + visible: root.title !== "" + text: root.title + font.pointSize: Appearance.font.size.normal + } + + RowLayout { + id: buttonRow + Layout.alignment: Qt.AlignHCenter + spacing: Appearance.spacing.small + + Repeater { + id: repeater + model: root.options + + delegate: TextButton { + id: button + required property int index + required property var modelData + + Layout.fillWidth: true + text: modelData.label + + property bool _checked: false + + checked: _checked + toggle: false + type: TextButton.Tonal + + // Create binding in Component.onCompleted + Component.onCompleted: { + if (root.rootItem && modelData.propertyName) { + const propName = modelData.propertyName; + const rootItem = root.rootItem; + _checked = Qt.binding(function () { + return rootItem[propName] ?? false; + }); + } + } + + // Match utilities Toggles radius styling + // Each button has full rounding (not connected) since they have spacing + radius: stateLayer.pressed ? Appearance.rounding.small / 2 : internalChecked ? Appearance.rounding.small : Appearance.rounding.normal + + // Match utilities Toggles inactive color + inactiveColour: Colours.layer(Colours.palette.m3surfaceContainerHighest, 2) + + // Adjust width similar to utilities toggles + Layout.preferredWidth: implicitWidth + (stateLayer.pressed ? Appearance.padding.large : internalChecked ? Appearance.padding.smaller : 0) + + onClicked: { + if (modelData.onToggled && root.rootItem && modelData.propertyName) { + const currentValue = root.rootItem[modelData.propertyName] ?? false; + modelData.onToggled(!currentValue); + } + } + + Behavior on Layout.preferredWidth { + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + } + } + + Behavior on radius { + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + } + } + } + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/components/DeviceDetails.qml b/.config/quickshell/caelestia/modules/controlcenter/components/DeviceDetails.qml new file mode 100644 index 0000000..a5d0647 --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/components/DeviceDetails.qml @@ -0,0 +1,70 @@ +pragma ComponentBehavior: Bound + +import ".." +import qs.components +import qs.components.controls +import qs.components.effects +import qs.components.containers +import qs.config +import QtQuick +import QtQuick.Layouts + +Item { + id: root + + property Session session + property var device: null + + property Component headerComponent: null + property list sections: [] + + property Component topContent: null + property Component bottomContent: null + + implicitWidth: layout.implicitWidth + implicitHeight: layout.implicitHeight + + ColumnLayout { + id: layout + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + spacing: Appearance.spacing.normal + + Loader { + id: headerLoader + + Layout.fillWidth: true + sourceComponent: root.headerComponent + visible: root.headerComponent !== null + } + + Loader { + id: topContentLoader + + Layout.fillWidth: true + sourceComponent: root.topContent + visible: root.topContent !== null + } + + Repeater { + model: root.sections + + Loader { + required property Component modelData + + Layout.fillWidth: true + sourceComponent: modelData + } + } + + Loader { + id: bottomContentLoader + + Layout.fillWidth: true + sourceComponent: root.bottomContent + visible: root.bottomContent !== null + } + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/components/DeviceList.qml b/.config/quickshell/caelestia/modules/controlcenter/components/DeviceList.qml new file mode 100644 index 0000000..722f9a1 --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/components/DeviceList.qml @@ -0,0 +1,84 @@ +pragma ComponentBehavior: Bound + +import ".." +import qs.components +import qs.components.controls +import qs.components.containers +import qs.services +import qs.config +import Quickshell +import QtQuick +import QtQuick.Layouts + +ColumnLayout { + id: root + + property Session session: null + property var model: null + property Component delegate: null + + property string title: "" + property string description: "" + property var activeItem: null + property Component headerComponent: null + property Component titleSuffix: null + property bool showHeader: true + + signal itemSelected(var item) + + spacing: Appearance.spacing.small + + Loader { + id: headerLoader + + Layout.fillWidth: true + sourceComponent: root.headerComponent + visible: root.headerComponent !== null && root.showHeader + } + + RowLayout { + Layout.fillWidth: true + Layout.topMargin: root.headerComponent ? 0 : 0 + spacing: Appearance.spacing.small + visible: root.title !== "" || root.description !== "" + + StyledText { + visible: root.title !== "" + text: root.title + font.pointSize: Appearance.font.size.large + font.weight: 500 + } + + Loader { + sourceComponent: root.titleSuffix + visible: root.titleSuffix !== null + } + + Item { + Layout.fillWidth: true + } + } + + property alias view: view + + StyledText { + visible: root.description !== "" + Layout.fillWidth: true + text: root.description + color: Colours.palette.m3outline + } + + StyledListView { + id: view + + Layout.fillWidth: true + implicitHeight: contentHeight + + model: root.model + delegate: root.delegate + + spacing: Appearance.spacing.small / 2 + interactive: false + clip: false + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/components/PaneTransition.qml b/.config/quickshell/caelestia/modules/controlcenter/components/PaneTransition.qml new file mode 100644 index 0000000..5d80dbe --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/components/PaneTransition.qml @@ -0,0 +1,71 @@ +pragma ComponentBehavior: Bound + +import qs.config +import QtQuick + +SequentialAnimation { + id: root + + required property Item target + property list propertyActions + + property real scaleFrom: 1.0 + property real scaleTo: 0.8 + property real opacityFrom: 1.0 + property real opacityTo: 0.0 + + ParallelAnimation { + NumberAnimation { + target: root.target + property: "opacity" + from: root.opacityFrom + to: root.opacityTo + duration: Appearance.anim.durations.normal / 2 + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.anim.curves.standardAccel + } + + NumberAnimation { + target: root.target + property: "scale" + from: root.scaleFrom + to: root.scaleTo + duration: Appearance.anim.durations.normal / 2 + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.anim.curves.standardAccel + } + } + + ScriptAction { + script: { + for (let i = 0; i < root.propertyActions.length; i++) { + const action = root.propertyActions[i]; + if (action.target && action.property !== undefined) { + action.target[action.property] = action.value; + } + } + } + } + + ParallelAnimation { + NumberAnimation { + target: root.target + property: "opacity" + from: root.opacityTo + to: root.opacityFrom + duration: Appearance.anim.durations.normal / 2 + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.anim.curves.standardDecel + } + + NumberAnimation { + target: root.target + property: "scale" + from: root.scaleTo + to: root.scaleFrom + duration: Appearance.anim.durations.normal / 2 + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.anim.curves.standardDecel + } + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/components/ReadonlySlider.qml b/.config/quickshell/caelestia/modules/controlcenter/components/ReadonlySlider.qml new file mode 100644 index 0000000..169d636 --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/components/ReadonlySlider.qml @@ -0,0 +1,67 @@ +import ".." +import "../components" +import qs.components +import qs.components.controls +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +ColumnLayout { + id: root + + property string label: "" + property real value: 0 + property real from: 0 + property real to: 100 + property string suffix: "" + property bool readonly: false + + spacing: Appearance.spacing.small + + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + StyledText { + visible: root.label !== "" + text: root.label + font.pointSize: Appearance.font.size.normal + color: root.readonly ? Colours.palette.m3outline : Colours.palette.m3onSurface + } + + Item { + Layout.fillWidth: true + } + + MaterialIcon { + visible: root.readonly + text: "lock" + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.small + } + + StyledText { + text: Math.round(root.value) + (root.suffix !== "" ? " " + root.suffix : "") + font.pointSize: Appearance.font.size.normal + color: root.readonly ? Colours.palette.m3outline : Colours.palette.m3onSurface + } + } + + StyledRect { + Layout.fillWidth: true + implicitHeight: Appearance.padding.normal + radius: Appearance.rounding.full + color: Colours.layer(Colours.palette.m3surfaceContainerHighest, 1) + opacity: root.readonly ? 0.5 : 1.0 + + StyledRect { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: parent.width * ((root.value - root.from) / (root.to - root.from)) + radius: parent.radius + color: root.readonly ? Colours.palette.m3outline : Colours.palette.m3primary + } + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/components/SettingsHeader.qml b/.config/quickshell/caelestia/modules/controlcenter/components/SettingsHeader.qml new file mode 100644 index 0000000..0dc190c --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/components/SettingsHeader.qml @@ -0,0 +1,37 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.config +import QtQuick +import QtQuick.Layouts + +Item { + id: root + + required property string icon + required property string title + + Layout.fillWidth: true + implicitHeight: column.implicitHeight + + ColumnLayout { + id: column + + anchors.centerIn: parent + spacing: Appearance.spacing.normal + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + text: root.icon + font.pointSize: Appearance.font.size.extraLarge * 3 + font.bold: true + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: root.title + font.pointSize: Appearance.font.size.large + font.bold: true + } + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/components/SliderInput.qml b/.config/quickshell/caelestia/modules/controlcenter/components/SliderInput.qml new file mode 100644 index 0000000..11b3f70 --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/components/SliderInput.qml @@ -0,0 +1,180 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.components.controls +import qs.components.effects +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +ColumnLayout { + id: root + + property string label: "" + property real value: 0 + property real from: 0 + property real to: 100 + property real stepSize: 0 + property var validator: null + property string suffix: "" // Optional suffix text (e.g., "×", "px") + property int decimals: 1 // Number of decimal places to show (default: 1) + property var formatValueFunction: null // Optional custom format function + property var parseValueFunction: null // Optional custom parse function + + function formatValue(val: real): string { + if (formatValueFunction) { + return formatValueFunction(val); + } + // Default format function + // Check if it's an IntValidator (IntValidator doesn't have a 'decimals' property) + if (validator && validator.bottom !== undefined && validator.decimals === undefined) { + return Math.round(val).toString(); + } + // For DoubleValidator or no validator, use the decimals property + return val.toFixed(root.decimals); + } + + function parseValue(text: string): real { + if (parseValueFunction) { + return parseValueFunction(text); + } + // Default parse function + if (validator && validator.bottom !== undefined) { + // Check if it's an integer validator + if (validator.top !== undefined && validator.top === Math.floor(validator.top)) { + return parseInt(text); + } + } + return parseFloat(text); + } + + signal valueModified(real newValue) + + property bool _initialized: false + + spacing: Appearance.spacing.small + + Component.onCompleted: { + // Set initialized flag after a brief delay to allow component to fully load + Qt.callLater(() => { + _initialized = true; + }); + } + + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + StyledText { + visible: root.label !== "" + text: root.label + font.pointSize: Appearance.font.size.normal + } + + Item { + Layout.fillWidth: true + } + + StyledInputField { + id: inputField + Layout.preferredWidth: 70 + validator: root.validator + + Component.onCompleted: { + // Initialize text without triggering valueModified signal + text = root.formatValue(root.value); + } + + onTextEdited: text => { + if (hasFocus) { + const val = root.parseValue(text); + if (!isNaN(val)) { + // Validate against validator bounds if available + let isValid = true; + if (root.validator) { + if (root.validator.bottom !== undefined && val < root.validator.bottom) { + isValid = false; + } + if (root.validator.top !== undefined && val > root.validator.top) { + isValid = false; + } + } + + if (isValid) { + root.valueModified(val); + } + } + } + } + + onEditingFinished: { + const val = root.parseValue(text); + let isValid = true; + if (root.validator) { + if (root.validator.bottom !== undefined && val < root.validator.bottom) { + isValid = false; + } + if (root.validator.top !== undefined && val > root.validator.top) { + isValid = false; + } + } + + if (isNaN(val) || !isValid) { + text = root.formatValue(root.value); + } + } + } + + StyledText { + visible: root.suffix !== "" + text: root.suffix + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.normal + } + } + + StyledSlider { + id: slider + + Layout.fillWidth: true + implicitHeight: Appearance.padding.normal * 3 + + from: root.from + to: root.to + stepSize: root.stepSize + + // Use Binding to allow slider to move freely during dragging + Binding { + target: slider + property: "value" + value: root.value + when: !slider.pressed + } + + onValueChanged: { + // Update input field text in real-time as slider moves during dragging + // Always update when slider value changes (during dragging or external updates) + if (!inputField.hasFocus) { + const newValue = root.stepSize > 0 ? Math.round(value / root.stepSize) * root.stepSize : value; + inputField.text = root.formatValue(newValue); + } + } + + onMoved: { + const newValue = root.stepSize > 0 ? Math.round(value / root.stepSize) * root.stepSize : value; + root.valueModified(newValue); + if (!inputField.hasFocus) { + inputField.text = root.formatValue(newValue); + } + } + } + + // Update input field when value changes externally (slider is already bound) + onValueChanged: { + // Only update if component is initialized to avoid issues during creation + if (root._initialized && !inputField.hasFocus) { + inputField.text = root.formatValue(root.value); + } + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/components/SplitPaneLayout.qml b/.config/quickshell/caelestia/modules/controlcenter/components/SplitPaneLayout.qml new file mode 100644 index 0000000..89504a0 --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/components/SplitPaneLayout.qml @@ -0,0 +1,109 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.components.effects +import qs.config +import Quickshell.Widgets +import QtQuick +import QtQuick.Layouts + +RowLayout { + id: root + + spacing: 0 + + property Component leftContent: null + property Component rightContent: null + + property real leftWidthRatio: 0.4 + property int leftMinimumWidth: 420 + property var leftLoaderProperties: ({}) + property var rightLoaderProperties: ({}) + + property alias leftLoader: leftLoader + property alias rightLoader: rightLoader + + Item { + id: leftPane + + Layout.preferredWidth: Math.floor(parent.width * root.leftWidthRatio) + Layout.minimumWidth: root.leftMinimumWidth + Layout.fillHeight: true + + ClippingRectangle { + id: leftClippingRect + + anchors.fill: parent + anchors.margins: Appearance.padding.normal + anchors.leftMargin: 0 + anchors.rightMargin: Appearance.padding.normal / 2 + + radius: leftBorder.innerRadius + color: "transparent" + + Loader { + id: leftLoader + + anchors.fill: parent + anchors.margins: Appearance.padding.large + Appearance.padding.normal + anchors.leftMargin: Appearance.padding.large + anchors.rightMargin: Appearance.padding.large + Appearance.padding.normal / 2 + + sourceComponent: root.leftContent + + Component.onCompleted: { + for (const key in root.leftLoaderProperties) { + leftLoader[key] = root.leftLoaderProperties[key]; + } + } + } + } + + InnerBorder { + id: leftBorder + + leftThickness: 0 + rightThickness: Appearance.padding.normal / 2 + } + } + + Item { + id: rightPane + + Layout.fillWidth: true + Layout.fillHeight: true + + ClippingRectangle { + id: rightClippingRect + + anchors.fill: parent + anchors.margins: Appearance.padding.normal + anchors.leftMargin: 0 + anchors.rightMargin: Appearance.padding.normal / 2 + + radius: rightBorder.innerRadius + color: "transparent" + + Loader { + id: rightLoader + + anchors.fill: parent + anchors.margins: Appearance.padding.large * 2 + + sourceComponent: root.rightContent + + Component.onCompleted: { + for (const key in root.rightLoaderProperties) { + rightLoader[key] = root.rightLoaderProperties[key]; + } + } + } + } + + InnerBorder { + id: rightBorder + + leftThickness: Appearance.padding.normal / 2 + } + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/components/SplitPaneWithDetails.qml b/.config/quickshell/caelestia/modules/controlcenter/components/SplitPaneWithDetails.qml new file mode 100644 index 0000000..ce8c9d0 --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/components/SplitPaneWithDetails.qml @@ -0,0 +1,93 @@ +pragma ComponentBehavior: Bound + +import ".." +import qs.components +import qs.components.effects +import qs.components.containers +import qs.config +import Quickshell.Widgets +import QtQuick +import QtQuick.Layouts + +Item { + id: root + + required property Component leftContent + required property Component rightDetailsComponent + required property Component rightSettingsComponent + + property var activeItem: null + property var paneIdGenerator: function (item) { + return item ? String(item) : ""; + } + + property Component overlayComponent: null + + SplitPaneLayout { + id: splitLayout + + anchors.fill: parent + + leftContent: root.leftContent + + rightContent: Component { + Item { + id: rightPaneItem + + property var pane: root.activeItem + property string paneId: root.paneIdGenerator(pane) + property Component targetComponent: root.rightSettingsComponent + property Component nextComponent: root.rightSettingsComponent + + function getComponentForPane() { + return pane ? root.rightDetailsComponent : root.rightSettingsComponent; + } + + Component.onCompleted: { + targetComponent = getComponentForPane(); + nextComponent = targetComponent; + } + + Loader { + id: rightLoader + + anchors.fill: parent + + opacity: 1 + scale: 1 + transformOrigin: Item.Center + + clip: false + sourceComponent: rightPaneItem.targetComponent + } + + Behavior on paneId { + PaneTransition { + target: rightLoader + propertyActions: [ + PropertyAction { + target: rightPaneItem + property: "targetComponent" + value: rightPaneItem.nextComponent + } + ] + } + } + + onPaneChanged: { + nextComponent = getComponentForPane(); + paneId = root.paneIdGenerator(pane); + } + } + } + } + + Loader { + id: overlayLoader + + anchors.fill: parent + z: 1000 + sourceComponent: root.overlayComponent + active: root.overlayComponent !== null + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/components/WallpaperGrid.qml b/.config/quickshell/caelestia/modules/controlcenter/components/WallpaperGrid.qml new file mode 100644 index 0000000..ed6bb40 --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/components/WallpaperGrid.qml @@ -0,0 +1,233 @@ +pragma ComponentBehavior: Bound + +import ".." +import qs.components +import qs.components.controls +import qs.components.effects +import qs.components.images +import qs.services +import qs.config +import Caelestia.Models +import QtQuick + +GridView { + id: root + + required property Session session + + readonly property int minCellWidth: 200 + Appearance.spacing.normal + readonly property int columnsCount: Math.max(1, Math.floor(width / minCellWidth)) + + cellWidth: width / columnsCount + cellHeight: 140 + Appearance.spacing.normal + + model: Wallpapers.list + + clip: true + + StyledScrollBar.vertical: StyledScrollBar { + flickable: root + } + + delegate: Item { + required property var modelData + required property int index + + width: root.cellWidth + height: root.cellHeight + + readonly property bool isCurrent: modelData && modelData.path === Wallpapers.actualCurrent + readonly property real itemMargin: Appearance.spacing.normal / 2 + readonly property real itemRadius: Appearance.rounding.normal + + StateLayer { + anchors.fill: parent + anchors.leftMargin: itemMargin + anchors.rightMargin: itemMargin + anchors.topMargin: itemMargin + anchors.bottomMargin: itemMargin + radius: itemRadius + + function onClicked(): void { + Wallpapers.setWallpaper(modelData.path); + } + } + + StyledClippingRect { + id: image + + anchors.fill: parent + anchors.leftMargin: itemMargin + anchors.rightMargin: itemMargin + anchors.topMargin: itemMargin + anchors.bottomMargin: itemMargin + color: Colours.tPalette.m3surfaceContainer + radius: itemRadius + antialiasing: true + layer.enabled: true + layer.smooth: true + + CachingImage { + id: cachingImage + + path: modelData.path + anchors.fill: parent + fillMode: Image.PreserveAspectCrop + cache: true + visible: opacity > 0 + antialiasing: true + smooth: true + sourceSize: Qt.size(width, height) + + opacity: status === Image.Ready ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: 1000 + easing.type: Easing.OutQuad + } + } + } + + // Fallback if CachingImage fails to load + Image { + id: fallbackImage + + anchors.fill: parent + source: fallbackTimer.triggered && cachingImage.status !== Image.Ready ? modelData.path : "" + asynchronous: true + fillMode: Image.PreserveAspectCrop + cache: true + visible: opacity > 0 + antialiasing: true + smooth: true + sourceSize: Qt.size(width, height) + + opacity: status === Image.Ready && cachingImage.status !== Image.Ready ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: 1000 + easing.type: Easing.OutQuad + } + } + } + + Timer { + id: fallbackTimer + + property bool triggered: false + interval: 800 + running: cachingImage.status === Image.Loading || cachingImage.status === Image.Null + onTriggered: triggered = true + } + + // Gradient overlay for filename + Rectangle { + id: filenameOverlay + + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + + implicitHeight: filenameText.implicitHeight + Appearance.padding.normal * 1.5 + radius: 0 + + gradient: Gradient { + GradientStop { + position: 0.0 + color: Qt.rgba(Colours.palette.m3surface.r, Colours.palette.m3surface.g, Colours.palette.m3surface.b, 0) + } + GradientStop { + position: 0.3 + color: Qt.rgba(Colours.palette.m3surface.r, Colours.palette.m3surface.g, Colours.palette.m3surface.b, 0.7) + } + GradientStop { + position: 0.6 + color: Qt.rgba(Colours.palette.m3surface.r, Colours.palette.m3surface.g, Colours.palette.m3surface.b, 0.9) + } + GradientStop { + position: 1.0 + color: Qt.rgba(Colours.palette.m3surface.r, Colours.palette.m3surface.g, Colours.palette.m3surface.b, 0.95) + } + } + + opacity: 0 + + Behavior on opacity { + NumberAnimation { + duration: 1000 + easing.type: Easing.OutCubic + } + } + + Component.onCompleted: { + opacity = 1; + } + } + } + + Rectangle { + anchors.fill: parent + anchors.leftMargin: itemMargin + anchors.rightMargin: itemMargin + anchors.topMargin: itemMargin + anchors.bottomMargin: itemMargin + color: "transparent" + radius: itemRadius + border.width + border.width: isCurrent ? 2 : 0 + border.color: Colours.palette.m3primary + antialiasing: true + smooth: true + + Behavior on border.width { + NumberAnimation { + duration: 150 + easing.type: Easing.OutQuad + } + } + + MaterialIcon { + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Appearance.padding.small + + visible: isCurrent + text: "check_circle" + color: Colours.palette.m3primary + font.pointSize: Appearance.font.size.large + } + } + + StyledText { + id: filenameText + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.leftMargin: Appearance.padding.normal + Appearance.spacing.normal / 2 + anchors.rightMargin: Appearance.padding.normal + Appearance.spacing.normal / 2 + anchors.bottomMargin: Appearance.padding.normal + + text: modelData.name + font.pointSize: Appearance.font.size.smaller + font.weight: 500 + color: isCurrent ? Colours.palette.m3primary : Colours.palette.m3onSurface + elide: Text.ElideMiddle + maximumLineCount: 1 + horizontalAlignment: Text.AlignHCenter + + opacity: 0 + + Behavior on opacity { + NumberAnimation { + duration: 1000 + easing.type: Easing.OutCubic + } + } + + Component.onCompleted: { + opacity = 1; + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/dashboard/DashboardPane.qml b/.config/quickshell/caelestia/modules/controlcenter/dashboard/DashboardPane.qml new file mode 100644 index 0000000..72e3e6e --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/dashboard/DashboardPane.qml @@ -0,0 +1,123 @@ +pragma ComponentBehavior: Bound + +import ".." +import "../components" +import qs.components +import qs.components.controls +import qs.components.effects +import qs.components.containers +import qs.services +import qs.config +import qs.utils +import Quickshell +import Quickshell.Widgets +import QtQuick +import QtQuick.Layouts + +Item { + id: root + + required property Session session + + // General Settings + property bool enabled: Config.dashboard.enabled ?? true + property bool showOnHover: Config.dashboard.showOnHover ?? true + property int updateInterval: Config.dashboard.updateInterval ?? 1000 + property int dragThreshold: Config.dashboard.dragThreshold ?? 50 + + // Performance Resources + property bool showBattery: Config.dashboard.performance.showBattery ?? false + property bool showGpu: Config.dashboard.performance.showGpu ?? true + property bool showCpu: Config.dashboard.performance.showCpu ?? true + property bool showMemory: Config.dashboard.performance.showMemory ?? true + property bool showStorage: Config.dashboard.performance.showStorage ?? true + property bool showNetwork: Config.dashboard.performance.showNetwork ?? true + + anchors.fill: parent + + function saveConfig() { + Config.dashboard.enabled = root.enabled; + Config.dashboard.showOnHover = root.showOnHover; + Config.dashboard.updateInterval = root.updateInterval; + Config.dashboard.dragThreshold = root.dragThreshold; + Config.dashboard.performance.showBattery = root.showBattery; + Config.dashboard.performance.showGpu = root.showGpu; + Config.dashboard.performance.showCpu = root.showCpu; + Config.dashboard.performance.showMemory = root.showMemory; + Config.dashboard.performance.showStorage = root.showStorage; + Config.dashboard.performance.showNetwork = root.showNetwork; + // Note: sizes properties are readonly and cannot be modified + Config.save(); + } + + ClippingRectangle { + id: dashboardClippingRect + anchors.fill: parent + anchors.margins: Appearance.padding.normal + anchors.leftMargin: 0 + anchors.rightMargin: Appearance.padding.normal + + radius: dashboardBorder.innerRadius + color: "transparent" + + Loader { + id: dashboardLoader + + anchors.fill: parent + anchors.margins: Appearance.padding.large + Appearance.padding.normal + anchors.leftMargin: Appearance.padding.large + anchors.rightMargin: Appearance.padding.large + + sourceComponent: dashboardContentComponent + } + } + + InnerBorder { + id: dashboardBorder + leftThickness: 0 + rightThickness: Appearance.padding.normal + } + + Component { + id: dashboardContentComponent + + StyledFlickable { + id: dashboardFlickable + flickableDirection: Flickable.VerticalFlick + contentHeight: dashboardLayout.height + + StyledScrollBar.vertical: StyledScrollBar { + flickable: dashboardFlickable + } + + ColumnLayout { + id: dashboardLayout + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + + spacing: Appearance.spacing.normal + + RowLayout { + spacing: Appearance.spacing.smaller + + StyledText { + text: qsTr("Dashboard") + font.pointSize: Appearance.font.size.large + font.weight: 500 + } + } + + // General Settings Section + GeneralSection { + rootItem: root + } + + // Performance Resources Section + PerformanceSection { + rootItem: root + } + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/dashboard/GeneralSection.qml b/.config/quickshell/caelestia/modules/controlcenter/dashboard/GeneralSection.qml new file mode 100644 index 0000000..bf54e97 --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/dashboard/GeneralSection.qml @@ -0,0 +1,81 @@ +import ".." +import "../components" +import qs.components +import qs.components.controls +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +SectionContainer { + id: root + + required property var rootItem + + Layout.fillWidth: true + alignTop: true + + StyledText { + text: qsTr("General Settings") + font.pointSize: Appearance.font.size.normal + } + + SwitchRow { + label: qsTr("Enabled") + checked: root.rootItem.enabled + onToggled: checked => { + root.rootItem.enabled = checked; + root.rootItem.saveConfig(); + } + } + + SwitchRow { + label: qsTr("Show on hover") + checked: root.rootItem.showOnHover + onToggled: checked => { + root.rootItem.showOnHover = checked; + root.rootItem.saveConfig(); + } + } + + SectionContainer { + contentSpacing: Appearance.spacing.normal + + SliderInput { + Layout.fillWidth: true + + label: qsTr("Update interval") + value: root.rootItem.updateInterval + from: 100 + to: 10000 + stepSize: 100 + suffix: "ms" + validator: IntValidator { bottom: 100; top: 10000 } + formatValueFunction: (val) => Math.round(val).toString() + parseValueFunction: (text) => parseInt(text) + + onValueModified: (newValue) => { + root.rootItem.updateInterval = Math.round(newValue); + root.rootItem.saveConfig(); + } + } + + SliderInput { + Layout.fillWidth: true + + label: qsTr("Drag threshold") + value: root.rootItem.dragThreshold + from: 0 + to: 100 + suffix: "px" + validator: IntValidator { bottom: 0; top: 100 } + formatValueFunction: (val) => Math.round(val).toString() + parseValueFunction: (text) => parseInt(text) + + onValueModified: (newValue) => { + root.rootItem.dragThreshold = Math.round(newValue); + root.rootItem.saveConfig(); + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/dashboard/PerformanceSection.qml b/.config/quickshell/caelestia/modules/controlcenter/dashboard/PerformanceSection.qml new file mode 100644 index 0000000..7e72782 --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/dashboard/PerformanceSection.qml @@ -0,0 +1,85 @@ +import ".." +import "../components" +import QtQuick +import QtQuick.Layouts +import Quickshell.Services.UPower +import qs.components +import qs.components.controls +import qs.config +import qs.services + +SectionContainer { + id: root + + required property var rootItem + // GPU toggle is hidden when gpuType is "NONE" (no GPU data available) + readonly property bool gpuAvailable: SystemUsage.gpuType !== "NONE" + // Battery toggle is hidden when no laptop battery is present + readonly property bool batteryAvailable: UPower.displayDevice.isLaptopBattery + + Layout.fillWidth: true + alignTop: true + + StyledText { + text: qsTr("Performance Resources") + font.pointSize: Appearance.font.size.normal + } + + ConnectedButtonGroup { + rootItem: root.rootItem + options: { + let opts = []; + if (root.batteryAvailable) + opts.push({ + "label": qsTr("Battery"), + "propertyName": "showBattery", + "onToggled": function(checked) { + root.rootItem.showBattery = checked; + root.rootItem.saveConfig(); + } + }); + + if (root.gpuAvailable) + opts.push({ + "label": qsTr("GPU"), + "propertyName": "showGpu", + "onToggled": function(checked) { + root.rootItem.showGpu = checked; + root.rootItem.saveConfig(); + } + }); + + opts.push({ + "label": qsTr("CPU"), + "propertyName": "showCpu", + "onToggled": function(checked) { + root.rootItem.showCpu = checked; + root.rootItem.saveConfig(); + } + }, { + "label": qsTr("Memory"), + "propertyName": "showMemory", + "onToggled": function(checked) { + root.rootItem.showMemory = checked; + root.rootItem.saveConfig(); + } + }, { + "label": qsTr("Storage"), + "propertyName": "showStorage", + "onToggled": function(checked) { + root.rootItem.showStorage = checked; + root.rootItem.saveConfig(); + } + }, { + "label": qsTr("Network"), + "propertyName": "showNetwork", + "onToggled": function(checked) { + root.rootItem.showNetwork = checked; + root.rootItem.saveConfig(); + } + }); + return opts; + } + } + +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/launcher/LauncherPane.qml b/.config/quickshell/caelestia/modules/controlcenter/launcher/LauncherPane.qml new file mode 100644 index 0000000..b236cf9 --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/launcher/LauncherPane.qml @@ -0,0 +1,658 @@ +pragma ComponentBehavior: Bound + +import ".." +import "../components" +import "../../launcher/services" +import qs.components +import qs.components.controls +import qs.components.effects +import qs.components.containers +import qs.services +import qs.config +import qs.utils +import Caelestia +import Quickshell +import Quickshell.Widgets +import QtQuick +import QtQuick.Layouts +import "../../../utils/scripts/fuzzysort.js" as Fuzzy + +Item { + id: root + + required property Session session + + property var selectedApp: root.session.launcher.active + property bool hideFromLauncherChecked: false + property bool favouriteChecked: false + + anchors.fill: parent + + onSelectedAppChanged: { + root.session.launcher.active = root.selectedApp; + updateToggleState(); + } + + Connections { + target: root.session.launcher + function onActiveChanged() { + root.selectedApp = root.session.launcher.active; + updateToggleState(); + } + } + + function updateToggleState() { + if (!root.selectedApp) { + root.hideFromLauncherChecked = false; + root.favouriteChecked = false; + return; + } + + const appId = root.selectedApp.id || root.selectedApp.entry?.id; + + root.hideFromLauncherChecked = Config.launcher.hiddenApps && Config.launcher.hiddenApps.length > 0 && Strings.testRegexList(Config.launcher.hiddenApps, appId); + root.favouriteChecked = Config.launcher.favouriteApps && Config.launcher.favouriteApps.length > 0 && Strings.testRegexList(Config.launcher.favouriteApps, appId); + } + + function saveHiddenApps(isHidden) { + if (!root.selectedApp) { + return; + } + + const appId = root.selectedApp.id || root.selectedApp.entry?.id; + + const hiddenApps = Config.launcher.hiddenApps ? [...Config.launcher.hiddenApps] : []; + + if (isHidden) { + if (!hiddenApps.includes(appId)) { + hiddenApps.push(appId); + } + } else { + const index = hiddenApps.indexOf(appId); + if (index !== -1) { + hiddenApps.splice(index, 1); + } + } + + Config.launcher.hiddenApps = hiddenApps; + Config.save(); + } + + AppDb { + id: allAppsDb + + path: `${Paths.state}/apps.sqlite` + favouriteApps: Config.launcher.favouriteApps + entries: DesktopEntries.applications.values + } + + property string searchText: "" + + function filterApps(search: string): list { + if (!search || search.trim() === "") { + const apps = []; + for (let i = 0; i < allAppsDb.apps.length; i++) { + apps.push(allAppsDb.apps[i]); + } + return apps; + } + + if (!allAppsDb.apps || allAppsDb.apps.length === 0) { + return []; + } + + const preparedApps = []; + for (let i = 0; i < allAppsDb.apps.length; i++) { + const app = allAppsDb.apps[i]; + const name = app.name || app.entry?.name || ""; + preparedApps.push({ + _item: app, + name: Fuzzy.prepare(name) + }); + } + + const results = Fuzzy.go(search, preparedApps, { + all: true, + keys: ["name"], + scoreFn: r => r[0].score + }); + + return results.sort((a, b) => b._score - a._score).map(r => r.obj._item); + } + + property list filteredApps: [] + + function updateFilteredApps() { + filteredApps = filterApps(searchText); + } + + onSearchTextChanged: { + updateFilteredApps(); + } + + Component.onCompleted: { + updateFilteredApps(); + } + + Connections { + target: allAppsDb + function onAppsChanged() { + updateFilteredApps(); + } + } + + SplitPaneLayout { + anchors.fill: parent + + leftContent: Component { + + ColumnLayout { + id: leftLauncherLayout + anchors.fill: parent + + spacing: Appearance.spacing.small + + RowLayout { + spacing: Appearance.spacing.smaller + + StyledText { + text: qsTr("Launcher") + font.pointSize: Appearance.font.size.large + font.weight: 500 + } + + Item { + Layout.fillWidth: true + } + + ToggleButton { + toggled: !root.session.launcher.active + icon: "settings" + accent: "Primary" + iconSize: Appearance.font.size.normal + horizontalPadding: Appearance.padding.normal + verticalPadding: Appearance.padding.smaller + tooltip: qsTr("Launcher settings") + + onClicked: { + if (root.session.launcher.active) { + root.session.launcher.active = null; + } else { + if (root.filteredApps.length > 0) { + root.session.launcher.active = root.filteredApps[0]; + } + } + } + } + } + + StyledText { + Layout.topMargin: Appearance.spacing.large + text: qsTr("Applications (%1)").arg(root.searchText ? root.filteredApps.length : allAppsDb.apps.length) + font.pointSize: Appearance.font.size.normal + font.weight: 500 + } + + StyledText { + text: qsTr("All applications available in the launcher") + color: Colours.palette.m3outline + } + + StyledRect { + Layout.fillWidth: true + Layout.topMargin: Appearance.spacing.normal + Layout.bottomMargin: Appearance.spacing.small + + color: Colours.layer(Colours.palette.m3surfaceContainer, 2) + radius: Appearance.rounding.full + + implicitHeight: Math.max(searchIcon.implicitHeight, searchField.implicitHeight, clearIcon.implicitHeight) + + MaterialIcon { + id: searchIcon + + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: Appearance.padding.normal + + text: "search" + color: Colours.palette.m3onSurfaceVariant + } + + StyledTextField { + id: searchField + + anchors.left: searchIcon.right + anchors.right: clearIcon.left + anchors.leftMargin: Appearance.spacing.small + anchors.rightMargin: Appearance.spacing.small + + topPadding: Appearance.padding.normal + bottomPadding: Appearance.padding.normal + + placeholderText: qsTr("Search applications...") + + onTextChanged: { + root.searchText = text; + } + } + + MaterialIcon { + id: clearIcon + + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + anchors.rightMargin: Appearance.padding.normal + + width: searchField.text ? implicitWidth : implicitWidth / 2 + opacity: { + if (!searchField.text) + return 0; + if (clearMouse.pressed) + return 0.7; + if (clearMouse.containsMouse) + return 0.8; + return 1; + } + + text: "close" + color: Colours.palette.m3onSurfaceVariant + + MouseArea { + id: clearMouse + + anchors.fill: parent + hoverEnabled: true + cursorShape: searchField.text ? Qt.PointingHandCursor : undefined + + onClicked: searchField.text = "" + } + + Behavior on width { + Anim { + duration: Appearance.anim.durations.small + } + } + + Behavior on opacity { + Anim { + duration: Appearance.anim.durations.small + } + } + } + } + + Loader { + id: appsListLoader + Layout.fillWidth: true + Layout.fillHeight: true + asynchronous: true + active: true + + sourceComponent: StyledListView { + id: appsListView + + Layout.fillWidth: true + Layout.fillHeight: true + + model: root.filteredApps + spacing: Appearance.spacing.small / 2 + clip: true + + StyledScrollBar.vertical: StyledScrollBar { + flickable: parent + } + + delegate: StyledRect { + required property var modelData + + width: parent ? parent.width : 0 + implicitHeight: 40 + + readonly property bool isSelected: root.selectedApp === modelData + + color: isSelected ? Colours.layer(Colours.palette.m3surfaceContainer, 2) : "transparent" + radius: Appearance.rounding.normal + + opacity: 0 + + Behavior on opacity { + NumberAnimation { + duration: 1000 + easing.type: Easing.OutCubic + } + } + + Component.onCompleted: { + opacity = 1; + } + + StateLayer { + function onClicked(): void { + root.session.launcher.active = modelData; + } + } + + RowLayout { + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Appearance.padding.normal + + spacing: Appearance.spacing.normal + + IconImage { + Layout.alignment: Qt.AlignVCenter + implicitSize: 32 + source: { + const entry = modelData.entry; + return entry ? Quickshell.iconPath(entry.icon, "image-missing") : "image-missing"; + } + } + + StyledText { + Layout.fillWidth: true + text: modelData.name || modelData.entry?.name || qsTr("Unknown") + font.pointSize: Appearance.font.size.normal + } + + Loader { + Layout.alignment: Qt.AlignVCenter + readonly property bool isHidden: modelData ? Strings.testRegexList(Config.launcher.hiddenApps, modelData.id) : false + readonly property bool isFav: modelData ? Strings.testRegexList(Config.launcher.favouriteApps, modelData.id) : false + active: isHidden || isFav + + sourceComponent: isHidden ? hiddenIcon : (isFav ? favouriteIcon : null) + } + + Component { + id: hiddenIcon + MaterialIcon { + text: "visibility_off" + fill: 1 + color: Colours.palette.m3primary + } + } + + Component { + id: favouriteIcon + MaterialIcon { + text: "favorite" + fill: 1 + color: Colours.palette.m3primary + } + } + } + } + } + } + } + } + + rightContent: Component { + Item { + id: rightLauncherPane + + property var pane: root.session.launcher.active + property string paneId: pane ? (pane.id || pane.entry?.id || "") : "" + property Component targetComponent: settings + property Component nextComponent: settings + property var displayedApp: null + + function getComponentForPane() { + return pane ? appDetails : settings; + } + + Component.onCompleted: { + displayedApp = pane; + targetComponent = getComponentForPane(); + nextComponent = targetComponent; + } + + Loader { + id: rightLauncherLoader + + anchors.fill: parent + + opacity: 1 + scale: 1 + transformOrigin: Item.Center + clip: false + + sourceComponent: rightLauncherPane.targetComponent + active: true + + property var displayedApp: rightLauncherPane.displayedApp + + onItemChanged: { + if (item && rightLauncherPane.pane && rightLauncherPane.displayedApp !== rightLauncherPane.pane) { + rightLauncherPane.displayedApp = rightLauncherPane.pane; + } + } + } + + Behavior on paneId { + PaneTransition { + target: rightLauncherLoader + propertyActions: [ + PropertyAction { + target: rightLauncherPane + property: "displayedApp" + value: rightLauncherPane.pane + }, + PropertyAction { + target: rightLauncherLoader + property: "active" + value: false + }, + PropertyAction { + target: rightLauncherPane + property: "targetComponent" + value: rightLauncherPane.nextComponent + }, + PropertyAction { + target: rightLauncherLoader + property: "active" + value: true + } + ] + } + } + + onPaneChanged: { + nextComponent = getComponentForPane(); + paneId = pane ? (pane.id || pane.entry?.id || "") : ""; + } + + onDisplayedAppChanged: { + if (displayedApp) { + const appId = displayedApp.id || displayedApp.entry?.id; + root.hideFromLauncherChecked = Config.launcher.hiddenApps && Config.launcher.hiddenApps.length > 0 && Strings.testRegexList(Config.launcher.hiddenApps, appId); + root.favouriteChecked = Config.launcher.favouriteApps && Config.launcher.favouriteApps.length > 0 && Strings.testRegexList(Config.launcher.favouriteApps, appId); + } else { + root.hideFromLauncherChecked = false; + root.favouriteChecked = false; + } + } + } + } + } + + Component { + id: settings + + StyledFlickable { + id: settingsFlickable + flickableDirection: Flickable.VerticalFlick + contentHeight: settingsInner.height + + StyledScrollBar.vertical: StyledScrollBar { + flickable: settingsFlickable + } + + Settings { + id: settingsInner + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + session: root.session + } + } + } + + Component { + id: appDetails + + ColumnLayout { + id: appDetailsLayout + anchors.fill: parent + + readonly property var displayedApp: parent && parent.displayedApp !== undefined ? parent.displayedApp : null + + spacing: Appearance.spacing.normal + + SettingsHeader { + Layout.leftMargin: Appearance.padding.large * 2 + Layout.rightMargin: Appearance.padding.large * 2 + Layout.topMargin: Appearance.padding.large * 2 + visible: displayedApp === null + icon: "apps" + title: qsTr("Launcher Applications") + } + + Item { + Layout.alignment: Qt.AlignHCenter + Layout.leftMargin: Appearance.padding.large * 2 + Layout.rightMargin: Appearance.padding.large * 2 + Layout.topMargin: Appearance.padding.large * 2 + visible: displayedApp !== null + implicitWidth: Math.max(appIconImage.implicitWidth, appTitleText.implicitWidth) + implicitHeight: appIconImage.implicitHeight + Appearance.spacing.normal + appTitleText.implicitHeight + + ColumnLayout { + anchors.centerIn: parent + spacing: Appearance.spacing.normal + + IconImage { + id: appIconImage + Layout.alignment: Qt.AlignHCenter + implicitSize: Appearance.font.size.extraLarge * 3 * 2 + source: { + const app = appDetailsLayout.displayedApp; + if (!app) + return "image-missing"; + const entry = app.entry; + if (entry && entry.icon) { + return Quickshell.iconPath(entry.icon, "image-missing"); + } + return "image-missing"; + } + } + + StyledText { + id: appTitleText + Layout.alignment: Qt.AlignHCenter + text: displayedApp ? (displayedApp.name || displayedApp.entry?.name || qsTr("Application Details")) : "" + font.pointSize: Appearance.font.size.large + font.bold: true + } + } + } + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.topMargin: Appearance.spacing.large + Layout.leftMargin: Appearance.padding.large * 2 + Layout.rightMargin: Appearance.padding.large * 2 + + StyledFlickable { + id: detailsFlickable + anchors.fill: parent + flickableDirection: Flickable.VerticalFlick + contentHeight: debugLayout.height + + StyledScrollBar.vertical: StyledScrollBar { + flickable: parent + } + + ColumnLayout { + id: debugLayout + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + spacing: Appearance.spacing.normal + + SwitchRow { + Layout.topMargin: Appearance.spacing.normal + visible: appDetailsLayout.displayedApp !== null + label: qsTr("Mark as favourite") + checked: root.favouriteChecked + // disabled if: + // * app is hidden + // * app isn't in favouriteApps array but marked as favourite anyway + // ^^^ This means that this app is favourited because of a regex check + // this button can not toggle regexed apps + enabled: appDetailsLayout.displayedApp !== null && !root.hideFromLauncherChecked && (Config.launcher.favouriteApps.indexOf(appDetailsLayout.displayedApp.id || appDetailsLayout.displayedApp.entry?.id) !== -1 || !root.favouriteChecked) + opacity: enabled ? 1 : 0.6 + onToggled: checked => { + root.favouriteChecked = checked; + const app = appDetailsLayout.displayedApp; + if (app) { + const appId = app.id || app.entry?.id; + const favouriteApps = Config.launcher.favouriteApps ? [...Config.launcher.favouriteApps] : []; + if (checked) { + if (!favouriteApps.includes(appId)) { + favouriteApps.push(appId); + } + } else { + const index = favouriteApps.indexOf(appId); + if (index !== -1) { + favouriteApps.splice(index, 1); + } + } + Config.launcher.favouriteApps = favouriteApps; + Config.save(); + } + } + } + SwitchRow { + Layout.topMargin: Appearance.spacing.normal + visible: appDetailsLayout.displayedApp !== null + label: qsTr("Hide from launcher") + checked: root.hideFromLauncherChecked + // disabled if: + // * app is favourited + // * app isn't in hiddenApps array but marked as hidden anyway + // ^^^ This means that this app is hidden because of a regex check + // this button can not toggle regexed apps + enabled: appDetailsLayout.displayedApp !== null && !root.favouriteChecked && (Config.launcher.hiddenApps.indexOf(appDetailsLayout.displayedApp.id || appDetailsLayout.displayedApp.entry?.id) !== -1 || !root.hideFromLauncherChecked) + opacity: enabled ? 1 : 0.6 + onToggled: checked => { + root.hideFromLauncherChecked = checked; + const app = appDetailsLayout.displayedApp; + if (app) { + const appId = app.id || app.entry?.id; + const hiddenApps = Config.launcher.hiddenApps ? [...Config.launcher.hiddenApps] : []; + if (checked) { + if (!hiddenApps.includes(appId)) { + hiddenApps.push(appId); + } + } else { + const index = hiddenApps.indexOf(appId); + if (index !== -1) { + hiddenApps.splice(index, 1); + } + } + Config.launcher.hiddenApps = hiddenApps; + Config.save(); + } + } + } + } + } + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/launcher/Settings.qml b/.config/quickshell/caelestia/modules/controlcenter/launcher/Settings.qml new file mode 100644 index 0000000..5eaf6e0 --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/launcher/Settings.qml @@ -0,0 +1,217 @@ +pragma ComponentBehavior: Bound + +import ".." +import "../components" +import qs.components +import qs.components.controls +import qs.components.effects +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +ColumnLayout { + id: root + + required property Session session + + spacing: Appearance.spacing.normal + + SettingsHeader { + icon: "apps" + title: qsTr("Launcher Settings") + } + + SectionHeader { + Layout.topMargin: Appearance.spacing.large + title: qsTr("General") + description: qsTr("General launcher settings") + } + + SectionContainer { + ToggleRow { + label: qsTr("Enabled") + checked: Config.launcher.enabled + toggle.onToggled: { + Config.launcher.enabled = checked; + Config.save(); + } + } + + ToggleRow { + label: qsTr("Show on hover") + checked: Config.launcher.showOnHover + toggle.onToggled: { + Config.launcher.showOnHover = checked; + Config.save(); + } + } + + ToggleRow { + label: qsTr("Vim keybinds") + checked: Config.launcher.vimKeybinds + toggle.onToggled: { + Config.launcher.vimKeybinds = checked; + Config.save(); + } + } + + ToggleRow { + label: qsTr("Enable dangerous actions") + checked: Config.launcher.enableDangerousActions + toggle.onToggled: { + Config.launcher.enableDangerousActions = checked; + Config.save(); + } + } + } + + SectionHeader { + Layout.topMargin: Appearance.spacing.large + title: qsTr("Display") + description: qsTr("Display and appearance settings") + } + + SectionContainer { + contentSpacing: Appearance.spacing.small / 2 + + PropertyRow { + label: qsTr("Max shown items") + value: qsTr("%1").arg(Config.launcher.maxShown) + } + + PropertyRow { + showTopMargin: true + label: qsTr("Max wallpapers") + value: qsTr("%1").arg(Config.launcher.maxWallpapers) + } + + PropertyRow { + showTopMargin: true + label: qsTr("Drag threshold") + value: qsTr("%1 px").arg(Config.launcher.dragThreshold) + } + } + + SectionHeader { + Layout.topMargin: Appearance.spacing.large + title: qsTr("Prefixes") + description: qsTr("Command prefix settings") + } + + SectionContainer { + contentSpacing: Appearance.spacing.small / 2 + + PropertyRow { + label: qsTr("Special prefix") + value: Config.launcher.specialPrefix || qsTr("None") + } + + PropertyRow { + showTopMargin: true + label: qsTr("Action prefix") + value: Config.launcher.actionPrefix || qsTr("None") + } + } + + SectionHeader { + Layout.topMargin: Appearance.spacing.large + title: qsTr("Fuzzy search") + description: qsTr("Fuzzy search settings") + } + + SectionContainer { + ToggleRow { + label: qsTr("Apps") + checked: Config.launcher.useFuzzy.apps + toggle.onToggled: { + Config.launcher.useFuzzy.apps = checked; + Config.save(); + } + } + + ToggleRow { + label: qsTr("Actions") + checked: Config.launcher.useFuzzy.actions + toggle.onToggled: { + Config.launcher.useFuzzy.actions = checked; + Config.save(); + } + } + + ToggleRow { + label: qsTr("Schemes") + checked: Config.launcher.useFuzzy.schemes + toggle.onToggled: { + Config.launcher.useFuzzy.schemes = checked; + Config.save(); + } + } + + ToggleRow { + label: qsTr("Variants") + checked: Config.launcher.useFuzzy.variants + toggle.onToggled: { + Config.launcher.useFuzzy.variants = checked; + Config.save(); + } + } + + ToggleRow { + label: qsTr("Wallpapers") + checked: Config.launcher.useFuzzy.wallpapers + toggle.onToggled: { + Config.launcher.useFuzzy.wallpapers = checked; + Config.save(); + } + } + } + + SectionHeader { + Layout.topMargin: Appearance.spacing.large + title: qsTr("Sizes") + description: qsTr("Size settings for launcher items") + } + + SectionContainer { + contentSpacing: Appearance.spacing.small / 2 + + PropertyRow { + label: qsTr("Item width") + value: qsTr("%1 px").arg(Config.launcher.sizes.itemWidth) + } + + PropertyRow { + showTopMargin: true + label: qsTr("Item height") + value: qsTr("%1 px").arg(Config.launcher.sizes.itemHeight) + } + + PropertyRow { + showTopMargin: true + label: qsTr("Wallpaper width") + value: qsTr("%1 px").arg(Config.launcher.sizes.wallpaperWidth) + } + + PropertyRow { + showTopMargin: true + label: qsTr("Wallpaper height") + value: qsTr("%1 px").arg(Config.launcher.sizes.wallpaperHeight) + } + } + + SectionHeader { + Layout.topMargin: Appearance.spacing.large + title: qsTr("Hidden apps") + description: qsTr("Applications hidden from launcher") + } + + SectionContainer { + contentSpacing: Appearance.spacing.small / 2 + + PropertyRow { + label: qsTr("Total hidden") + value: qsTr("%1").arg(Config.launcher.hiddenApps ? Config.launcher.hiddenApps.length : 0) + } + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/network/EthernetDetails.qml b/.config/quickshell/caelestia/modules/controlcenter/network/EthernetDetails.qml new file mode 100644 index 0000000..4e60b3d --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/network/EthernetDetails.qml @@ -0,0 +1,118 @@ +pragma ComponentBehavior: Bound + +import ".." +import "../components" +import qs.components +import qs.components.controls +import qs.components.effects +import qs.components.containers +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +DeviceDetails { + id: root + + required property Session session + readonly property var ethernetDevice: root.session.ethernet.active + + device: ethernetDevice + + Component.onCompleted: { + if (ethernetDevice && ethernetDevice.interface) { + Nmcli.getEthernetDeviceDetails(ethernetDevice.interface, () => {}); + } + } + + onEthernetDeviceChanged: { + if (ethernetDevice && ethernetDevice.interface) { + Nmcli.getEthernetDeviceDetails(ethernetDevice.interface, () => {}); + } else { + Nmcli.ethernetDeviceDetails = null; + } + } + + headerComponent: Component { + ConnectionHeader { + icon: "cable" + title: root.ethernetDevice?.interface ?? qsTr("Unknown") + } + } + + sections: [ + Component { + ColumnLayout { + spacing: Appearance.spacing.normal + + SectionHeader { + title: qsTr("Connection status") + description: qsTr("Connection settings for this device") + } + + SectionContainer { + ToggleRow { + label: qsTr("Connected") + checked: root.ethernetDevice?.connected ?? false + toggle.onToggled: { + if (checked) { + Nmcli.connectEthernet(root.ethernetDevice?.connection || "", root.ethernetDevice?.interface || "", () => {}); + } else { + if (root.ethernetDevice?.connection) { + Nmcli.disconnectEthernet(root.ethernetDevice.connection, () => {}); + } + } + } + } + } + } + }, + Component { + ColumnLayout { + spacing: Appearance.spacing.normal + + SectionHeader { + title: qsTr("Device properties") + description: qsTr("Additional information") + } + + SectionContainer { + contentSpacing: Appearance.spacing.small / 2 + + PropertyRow { + label: qsTr("Interface") + value: root.ethernetDevice?.interface ?? qsTr("Unknown") + } + + PropertyRow { + showTopMargin: true + label: qsTr("Connection") + value: root.ethernetDevice?.connection || qsTr("Not connected") + } + + PropertyRow { + showTopMargin: true + label: qsTr("State") + value: root.ethernetDevice?.state ?? qsTr("Unknown") + } + } + } + }, + Component { + ColumnLayout { + spacing: Appearance.spacing.normal + + SectionHeader { + title: qsTr("Connection information") + description: qsTr("Network connection details") + } + + SectionContainer { + ConnectionInfoSection { + deviceDetails: Nmcli.ethernetDeviceDetails + } + } + } + } + ] +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/network/EthernetList.qml b/.config/quickshell/caelestia/modules/controlcenter/network/EthernetList.qml new file mode 100644 index 0000000..d1eb957 --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/network/EthernetList.qml @@ -0,0 +1,177 @@ +pragma ComponentBehavior: Bound + +import ".." +import "../components" +import qs.components +import qs.components.controls +import qs.components.containers +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +DeviceList { + id: root + + required property Session session + + title: qsTr("Devices (%1)").arg(Nmcli.ethernetDevices.length) + description: qsTr("All available ethernet devices") + activeItem: session.ethernet.active + + model: Nmcli.ethernetDevices + + headerComponent: Component { + RowLayout { + spacing: Appearance.spacing.smaller + + StyledText { + text: qsTr("Settings") + font.pointSize: Appearance.font.size.large + font.weight: 500 + } + + Item { + Layout.fillWidth: true + } + + ToggleButton { + toggled: !root.session.ethernet.active + icon: "settings" + accent: "Primary" + iconSize: Appearance.font.size.normal + horizontalPadding: Appearance.padding.normal + verticalPadding: Appearance.padding.smaller + + onClicked: { + if (root.session.ethernet.active) + root.session.ethernet.active = null; + else { + root.session.ethernet.active = root.view.model.get(0)?.modelData ?? null; + } + } + } + } + } + + delegate: Component { + StyledRect { + id: ethernetItem + + required property var modelData + readonly property bool isActive: root.activeItem && modelData && root.activeItem.interface === modelData.interface + + width: ListView.view ? ListView.view.width : undefined + implicitHeight: rowLayout.implicitHeight + Appearance.padding.normal * 2 + + color: Qt.alpha(Colours.tPalette.m3surfaceContainer, ethernetItem.isActive ? Colours.tPalette.m3surfaceContainer.a : 0) + radius: Appearance.rounding.normal + + StateLayer { + id: stateLayer + + function onClicked(): void { + root.session.ethernet.active = modelData; + } + } + + RowLayout { + id: rowLayout + + anchors.fill: parent + anchors.margins: Appearance.padding.normal + + spacing: Appearance.spacing.normal + + StyledRect { + implicitWidth: implicitHeight + implicitHeight: icon.implicitHeight + Appearance.padding.normal * 2 + + radius: Appearance.rounding.normal + color: modelData.connected ? Colours.palette.m3primaryContainer : Colours.tPalette.m3surfaceContainerHigh + + StyledRect { + anchors.fill: parent + radius: parent.radius + color: Qt.alpha(modelData.connected ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface, stateLayer.pressed ? 0.1 : stateLayer.containsMouse ? 0.08 : 0) + } + + MaterialIcon { + id: icon + + anchors.centerIn: parent + text: "cable" + font.pointSize: Appearance.font.size.large + fill: modelData.connected ? 1 : 0 + color: modelData.connected ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface + + Behavior on fill { + Anim {} + } + } + } + + ColumnLayout { + Layout.fillWidth: true + + spacing: 0 + + StyledText { + Layout.fillWidth: true + text: modelData.interface || qsTr("Unknown") + elide: Text.ElideRight + } + + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.smaller + + StyledText { + Layout.fillWidth: true + text: modelData.connected ? qsTr("Connected") : qsTr("Disconnected") + color: modelData.connected ? Colours.palette.m3primary : Colours.palette.m3outline + font.pointSize: Appearance.font.size.small + font.weight: modelData.connected ? 500 : 400 + elide: Text.ElideRight + } + } + } + + StyledRect { + id: connectBtn + + implicitWidth: implicitHeight + implicitHeight: connectIcon.implicitHeight + Appearance.padding.smaller * 2 + + radius: Appearance.rounding.full + color: Qt.alpha(Colours.palette.m3primaryContainer, modelData.connected ? 1 : 0) + + StateLayer { + color: modelData.connected ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface + + function onClicked(): void { + if (modelData.connected && modelData.connection) { + Nmcli.disconnectEthernet(modelData.connection, () => {}); + } else { + Nmcli.connectEthernet(modelData.connection || "", modelData.interface || "", () => {}); + } + } + } + + MaterialIcon { + id: connectIcon + + anchors.centerIn: parent + animate: true + text: modelData.connected ? "link_off" : "link" + color: modelData.connected ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface + } + } + } + } + } + + onItemSelected: function (item) { + session.ethernet.active = item; + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/network/EthernetPane.qml b/.config/quickshell/caelestia/modules/controlcenter/network/EthernetPane.qml new file mode 100644 index 0000000..59d82bb --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/network/EthernetPane.qml @@ -0,0 +1,50 @@ +pragma ComponentBehavior: Bound + +import ".." +import "../components" +import qs.components +import qs.components.containers +import qs.config +import Quickshell.Widgets +import QtQuick + +SplitPaneWithDetails { + id: root + + required property Session session + + anchors.fill: parent + + activeItem: session.ethernet.active + paneIdGenerator: function (item) { + return item ? (item.interface || "") : ""; + } + + leftContent: Component { + EthernetList { + session: root.session + } + } + + rightDetailsComponent: Component { + EthernetDetails { + session: root.session + } + } + + rightSettingsComponent: Component { + StyledFlickable { + flickableDirection: Flickable.VerticalFlick + contentHeight: settingsInner.height + clip: true + + EthernetSettings { + id: settingsInner + + anchors.left: parent.left + anchors.right: parent.right + session: root.session + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/network/EthernetSettings.qml b/.config/quickshell/caelestia/modules/controlcenter/network/EthernetSettings.qml new file mode 100644 index 0000000..90bfcf4 --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/network/EthernetSettings.qml @@ -0,0 +1,76 @@ +pragma ComponentBehavior: Bound + +import ".." +import "../components" +import qs.components +import qs.components.controls +import qs.components.effects +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +ColumnLayout { + id: root + + required property Session session + + spacing: Appearance.spacing.normal + + SettingsHeader { + icon: "cable" + title: qsTr("Ethernet settings") + } + + StyledText { + Layout.topMargin: Appearance.spacing.large + text: qsTr("Ethernet devices") + font.pointSize: Appearance.font.size.larger + font.weight: 500 + } + + StyledText { + text: qsTr("Available ethernet devices") + color: Colours.palette.m3outline + } + + StyledRect { + Layout.fillWidth: true + implicitHeight: ethernetInfo.implicitHeight + Appearance.padding.large * 2 + + radius: Appearance.rounding.normal + color: Colours.tPalette.m3surfaceContainer + + ColumnLayout { + id: ethernetInfo + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Appearance.padding.large + + spacing: Appearance.spacing.small / 2 + + StyledText { + text: qsTr("Total devices") + } + + StyledText { + text: qsTr("%1").arg(Nmcli.ethernetDevices.length) + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.small + } + + StyledText { + Layout.topMargin: Appearance.spacing.normal + text: qsTr("Connected devices") + } + + StyledText { + text: qsTr("%1").arg(Nmcli.ethernetDevices.filter(d => d.connected).length) + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.small + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/network/NetworkSettings.qml b/.config/quickshell/caelestia/modules/controlcenter/network/NetworkSettings.qml new file mode 100644 index 0000000..bda7cb1 --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/network/NetworkSettings.qml @@ -0,0 +1,171 @@ +pragma ComponentBehavior: Bound + +import ".." +import "../components" +import qs.components +import qs.components.controls +import qs.components.containers +import qs.components.effects +import qs.services +import qs.config +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +ColumnLayout { + id: root + + required property Session session + + spacing: Appearance.spacing.normal + + SettingsHeader { + icon: "router" + title: qsTr("Network Settings") + } + + SectionHeader { + Layout.topMargin: Appearance.spacing.large + title: qsTr("Ethernet") + description: qsTr("Ethernet device information") + } + + SectionContainer { + contentSpacing: Appearance.spacing.small / 2 + + PropertyRow { + label: qsTr("Total devices") + value: qsTr("%1").arg(Nmcli.ethernetDevices.length) + } + + PropertyRow { + showTopMargin: true + label: qsTr("Connected devices") + value: qsTr("%1").arg(Nmcli.ethernetDevices.filter(d => d.connected).length) + } + } + + SectionHeader { + Layout.topMargin: Appearance.spacing.large + title: qsTr("Wireless") + description: qsTr("WiFi network settings") + } + + SectionContainer { + ToggleRow { + label: qsTr("WiFi enabled") + checked: Nmcli.wifiEnabled + toggle.onToggled: { + Nmcli.enableWifi(checked); + } + } + } + + SectionHeader { + Layout.topMargin: Appearance.spacing.large + title: qsTr("VPN") + description: qsTr("VPN provider settings") + visible: Config.utilities.vpn.enabled || Config.utilities.vpn.provider.length > 0 + } + + SectionContainer { + visible: Config.utilities.vpn.enabled || Config.utilities.vpn.provider.length > 0 + + ToggleRow { + label: qsTr("VPN enabled") + checked: Config.utilities.vpn.enabled + toggle.onToggled: { + Config.utilities.vpn.enabled = checked; + Config.save(); + } + } + + PropertyRow { + showTopMargin: true + label: qsTr("Providers") + value: qsTr("%1").arg(Config.utilities.vpn.provider.length) + } + + TextButton { + Layout.fillWidth: true + Layout.topMargin: Appearance.spacing.normal + Layout.minimumHeight: Appearance.font.size.normal + Appearance.padding.normal * 2 + text: qsTr("⚙ Manage VPN Providers") + inactiveColour: Colours.palette.m3secondaryContainer + inactiveOnColour: Colours.palette.m3onSecondaryContainer + + onClicked: { + vpnSettingsDialog.open(); + } + } + } + + SectionHeader { + Layout.topMargin: Appearance.spacing.large + title: qsTr("Current connection") + description: qsTr("Active network connection information") + } + + SectionContainer { + contentSpacing: Appearance.spacing.small / 2 + + PropertyRow { + label: qsTr("Network") + value: Nmcli.active ? Nmcli.active.ssid : (Nmcli.activeEthernet ? Nmcli.activeEthernet.interface : qsTr("Not connected")) + } + + PropertyRow { + showTopMargin: true + visible: Nmcli.active !== null + label: qsTr("Signal strength") + value: Nmcli.active ? qsTr("%1%").arg(Nmcli.active.strength) : qsTr("N/A") + } + + PropertyRow { + showTopMargin: true + visible: Nmcli.active !== null + label: qsTr("Security") + value: Nmcli.active ? (Nmcli.active.isSecure ? qsTr("Secured") : qsTr("Open")) : qsTr("N/A") + } + + PropertyRow { + showTopMargin: true + visible: Nmcli.active !== null + label: qsTr("Frequency") + value: Nmcli.active ? qsTr("%1 MHz").arg(Nmcli.active.frequency) : qsTr("N/A") + } + } + + Popup { + id: vpnSettingsDialog + + parent: Overlay.overlay + anchors.centerIn: parent + width: Math.min(600, parent.width - Appearance.padding.large * 2) + height: Math.min(700, parent.height - Appearance.padding.large * 2) + + modal: true + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + background: StyledRect { + color: Colours.palette.m3surface + radius: Appearance.rounding.large + } + + StyledFlickable { + anchors.fill: parent + anchors.margins: Appearance.padding.large * 1.5 + flickableDirection: Flickable.VerticalFlick + contentHeight: vpnSettingsContent.height + clip: true + + VpnSettings { + id: vpnSettingsContent + + anchors.left: parent.left + anchors.right: parent.right + session: root.session + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/network/NetworkingPane.qml b/.config/quickshell/caelestia/modules/controlcenter/network/NetworkingPane.qml new file mode 100644 index 0000000..26cdbfa --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/network/NetworkingPane.qml @@ -0,0 +1,373 @@ +pragma ComponentBehavior: Bound + +import ".." +import "../components" +import "." +import qs.components +import qs.components.controls +import qs.components.effects +import qs.components.containers +import qs.services +import qs.config +import qs.utils +import Quickshell +import Quickshell.Widgets +import QtQuick +import QtQuick.Layouts + +Item { + id: root + + required property Session session + + anchors.fill: parent + + SplitPaneLayout { + id: splitLayout + + anchors.fill: parent + + leftContent: Component { + StyledFlickable { + id: leftFlickable + + flickableDirection: Flickable.VerticalFlick + contentHeight: leftContent.height + + StyledScrollBar.vertical: StyledScrollBar { + flickable: leftFlickable + } + + ColumnLayout { + id: leftContent + + anchors.left: parent.left + anchors.right: parent.right + spacing: Appearance.spacing.normal + + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.smaller + + StyledText { + text: qsTr("Network") + font.pointSize: Appearance.font.size.large + font.weight: 500 + } + + Item { + Layout.fillWidth: true + } + + ToggleButton { + toggled: Nmcli.wifiEnabled + icon: "wifi" + accent: "Tertiary" + iconSize: Appearance.font.size.normal + horizontalPadding: Appearance.padding.normal + verticalPadding: Appearance.padding.smaller + tooltip: qsTr("Toggle WiFi") + + onClicked: { + Nmcli.toggleWifi(null); + } + } + + ToggleButton { + toggled: Nmcli.scanning + icon: "wifi_find" + accent: "Secondary" + iconSize: Appearance.font.size.normal + horizontalPadding: Appearance.padding.normal + verticalPadding: Appearance.padding.smaller + tooltip: qsTr("Scan for networks") + + onClicked: { + Nmcli.rescanWifi(); + } + } + + ToggleButton { + toggled: !root.session.ethernet.active && !root.session.network.active + icon: "settings" + accent: "Primary" + iconSize: Appearance.font.size.normal + horizontalPadding: Appearance.padding.normal + verticalPadding: Appearance.padding.smaller + tooltip: qsTr("Network settings") + + onClicked: { + if (root.session.ethernet.active || root.session.network.active) { + root.session.ethernet.active = null; + root.session.network.active = null; + } else { + if (Nmcli.ethernetDevices.length > 0) { + root.session.ethernet.active = Nmcli.ethernetDevices[0]; + } else if (Nmcli.networks.length > 0) { + root.session.network.active = Nmcli.networks[0]; + } + } + } + } + } + + CollapsibleSection { + id: vpnListSection + + Layout.fillWidth: true + title: qsTr("VPN") + expanded: true + + Loader { + Layout.fillWidth: true + sourceComponent: Component { + VpnList { + session: root.session + showHeader: false + } + } + } + } + + CollapsibleSection { + id: ethernetListSection + + Layout.fillWidth: true + title: qsTr("Ethernet") + expanded: true + + Loader { + Layout.fillWidth: true + sourceComponent: Component { + EthernetList { + session: root.session + showHeader: false + } + } + } + } + + CollapsibleSection { + id: wirelessListSection + + Layout.fillWidth: true + title: qsTr("Wireless") + expanded: true + + Loader { + Layout.fillWidth: true + sourceComponent: Component { + WirelessList { + session: root.session + showHeader: false + } + } + } + } + } + } + } + + rightContent: Component { + Item { + id: rightPaneItem + + property var vpnPane: root.session && root.session.vpn ? root.session.vpn.active : null + property var ethernetPane: root.session && root.session.ethernet ? root.session.ethernet.active : null + property var wirelessPane: root.session && root.session.network ? root.session.network.active : null + property var pane: vpnPane || ethernetPane || wirelessPane + property string paneId: vpnPane ? ("vpn:" + (vpnPane.name || "")) : (ethernetPane ? ("eth:" + (ethernetPane.interface || "")) : (wirelessPane ? ("wifi:" + (wirelessPane.ssid || wirelessPane.bssid || "")) : "settings")) + property Component targetComponent: settingsComponent + property Component nextComponent: settingsComponent + + function getComponentForPane() { + if (vpnPane) + return vpnDetailsComponent; + if (ethernetPane) + return ethernetDetailsComponent; + if (wirelessPane) + return wirelessDetailsComponent; + return settingsComponent; + } + + Component.onCompleted: { + targetComponent = getComponentForPane(); + nextComponent = targetComponent; + } + + Connections { + target: root.session && root.session.vpn ? root.session.vpn : null + enabled: target !== null + + function onActiveChanged() { + // Clear others when VPN is selected + if (root.session && root.session.vpn && root.session.vpn.active) { + if (root.session.ethernet && root.session.ethernet.active) + root.session.ethernet.active = null; + if (root.session.network && root.session.network.active) + root.session.network.active = null; + } + rightPaneItem.nextComponent = rightPaneItem.getComponentForPane(); + } + } + + Connections { + target: root.session && root.session.ethernet ? root.session.ethernet : null + enabled: target !== null + + function onActiveChanged() { + // Clear others when ethernet is selected + if (root.session && root.session.ethernet && root.session.ethernet.active) { + if (root.session.vpn && root.session.vpn.active) + root.session.vpn.active = null; + if (root.session.network && root.session.network.active) + root.session.network.active = null; + } + rightPaneItem.nextComponent = rightPaneItem.getComponentForPane(); + } + } + + Connections { + target: root.session && root.session.network ? root.session.network : null + enabled: target !== null + + function onActiveChanged() { + // Clear others when wireless is selected + if (root.session && root.session.network && root.session.network.active) { + if (root.session.vpn && root.session.vpn.active) + root.session.vpn.active = null; + if (root.session.ethernet && root.session.ethernet.active) + root.session.ethernet.active = null; + } + rightPaneItem.nextComponent = rightPaneItem.getComponentForPane(); + } + } + + Loader { + id: rightLoader + + anchors.fill: parent + + opacity: 1 + scale: 1 + transformOrigin: Item.Center + clip: false + + asynchronous: true + sourceComponent: rightPaneItem.targetComponent + } + + Behavior on paneId { + PaneTransition { + target: rightLoader + propertyActions: [ + PropertyAction { + target: rightPaneItem + property: "targetComponent" + value: rightPaneItem.nextComponent + } + ] + } + } + } + } + } + + Component { + id: settingsComponent + + StyledFlickable { + id: settingsFlickable + flickableDirection: Flickable.VerticalFlick + contentHeight: settingsInner.height + + StyledScrollBar.vertical: StyledScrollBar { + flickable: settingsFlickable + } + + NetworkSettings { + id: settingsInner + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + session: root.session + } + } + } + + Component { + id: ethernetDetailsComponent + + StyledFlickable { + id: ethernetFlickable + flickableDirection: Flickable.VerticalFlick + contentHeight: ethernetDetailsInner.height + + StyledScrollBar.vertical: StyledScrollBar { + flickable: ethernetFlickable + } + + EthernetDetails { + id: ethernetDetailsInner + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + session: root.session + } + } + } + + Component { + id: wirelessDetailsComponent + + StyledFlickable { + id: wirelessFlickable + flickableDirection: Flickable.VerticalFlick + contentHeight: wirelessDetailsInner.height + + StyledScrollBar.vertical: StyledScrollBar { + flickable: wirelessFlickable + } + + WirelessDetails { + id: wirelessDetailsInner + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + session: root.session + } + } + } + + Component { + id: vpnDetailsComponent + + StyledFlickable { + id: vpnFlickable + flickableDirection: Flickable.VerticalFlick + contentHeight: vpnDetailsInner.height + + StyledScrollBar.vertical: StyledScrollBar { + flickable: vpnFlickable + } + + VpnDetails { + id: vpnDetailsInner + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + session: root.session + } + } + } + + WirelessPasswordDialog { + anchors.fill: parent + session: root.session + z: 1000 + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/network/VpnDetails.qml b/.config/quickshell/caelestia/modules/controlcenter/network/VpnDetails.qml new file mode 100644 index 0000000..1c71cd7 --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/network/VpnDetails.qml @@ -0,0 +1,396 @@ +pragma ComponentBehavior: Bound + +import ".." +import "../components" +import qs.components +import qs.components.controls +import qs.components.effects +import qs.components.containers +import qs.services +import qs.config +import qs.utils +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +DeviceDetails { + id: root + + required property Session session + readonly property var vpnProvider: root.session.vpn.active + readonly property bool providerEnabled: { + if (!vpnProvider || vpnProvider.index === undefined) + return false; + const provider = Config.utilities.vpn.provider[vpnProvider.index]; + return provider && typeof provider === "object" && provider.enabled === true; + } + + device: vpnProvider + + headerComponent: Component { + ConnectionHeader { + icon: "vpn_key" + title: root.vpnProvider?.displayName ?? qsTr("Unknown") + } + } + + sections: [ + Component { + ColumnLayout { + spacing: Appearance.spacing.normal + + SectionHeader { + title: qsTr("Connection status") + description: qsTr("VPN connection settings") + } + + SectionContainer { + ToggleRow { + label: qsTr("Enable this provider") + checked: root.providerEnabled + toggle.onToggled: { + if (!root.vpnProvider) + return; + const providers = []; + const index = root.vpnProvider.index; + + // Copy providers and update enabled state + for (let i = 0; i < Config.utilities.vpn.provider.length; i++) { + const p = Config.utilities.vpn.provider[i]; + if (typeof p === "object") { + const newProvider = { + name: p.name, + displayName: p.displayName, + interface: p.interface + }; + + if (checked) { + // Enable this one, disable others + newProvider.enabled = (i === index); + } else { + // Just disable this one + newProvider.enabled = (i === index) ? false : (p.enabled !== false); + } + + providers.push(newProvider); + } else { + providers.push(p); + } + } + + Config.utilities.vpn.provider = providers; + Config.save(); + } + } + + RowLayout { + Layout.fillWidth: true + Layout.topMargin: Appearance.spacing.normal + spacing: Appearance.spacing.normal + + TextButton { + Layout.fillWidth: true + Layout.minimumHeight: Appearance.font.size.normal + Appearance.padding.normal * 2 + visible: root.providerEnabled + enabled: !VPN.connecting + inactiveColour: Colours.palette.m3primaryContainer + inactiveOnColour: Colours.palette.m3onPrimaryContainer + text: VPN.connected ? qsTr("Disconnect") : qsTr("Connect") + + onClicked: { + VPN.toggle(); + } + } + + TextButton { + Layout.fillWidth: true + text: qsTr("Edit Provider") + inactiveColour: Colours.palette.m3secondaryContainer + inactiveOnColour: Colours.palette.m3onSecondaryContainer + + onClicked: { + editVpnDialog.editIndex = root.vpnProvider.index; + editVpnDialog.providerName = root.vpnProvider.name; + editVpnDialog.displayName = root.vpnProvider.displayName; + editVpnDialog.interfaceName = root.vpnProvider.interface; + editVpnDialog.open(); + } + } + + TextButton { + Layout.fillWidth: true + text: qsTr("Delete Provider") + inactiveColour: Colours.palette.m3errorContainer + inactiveOnColour: Colours.palette.m3onErrorContainer + + onClicked: { + const providers = []; + for (let i = 0; i < Config.utilities.vpn.provider.length; i++) { + if (i !== root.vpnProvider.index) { + providers.push(Config.utilities.vpn.provider[i]); + } + } + Config.utilities.vpn.provider = providers; + Config.save(); + root.session.vpn.active = null; + } + } + } + } + } + }, + Component { + ColumnLayout { + spacing: Appearance.spacing.normal + + SectionHeader { + title: qsTr("Provider details") + description: qsTr("VPN provider information") + } + + SectionContainer { + contentSpacing: Appearance.spacing.small / 2 + + PropertyRow { + label: qsTr("Provider") + value: root.vpnProvider?.name ?? qsTr("Unknown") + } + + PropertyRow { + showTopMargin: true + label: qsTr("Display name") + value: root.vpnProvider?.displayName ?? qsTr("Unknown") + } + + PropertyRow { + showTopMargin: true + label: qsTr("Interface") + value: root.vpnProvider?.interface || qsTr("N/A") + } + + PropertyRow { + showTopMargin: true + label: qsTr("Status") + value: { + if (!root.providerEnabled) + return qsTr("Disabled"); + if (VPN.connecting) + return qsTr("Connecting..."); + if (VPN.connected) + return qsTr("Connected"); + return qsTr("Enabled (Not connected)"); + } + } + + PropertyRow { + showTopMargin: true + label: qsTr("Enabled") + value: root.providerEnabled ? qsTr("Yes") : qsTr("No") + } + } + } + } + ] + + // Edit VPN Dialog + Popup { + id: editVpnDialog + + property int editIndex: -1 + property string providerName: "" + property string displayName: "" + property string interfaceName: "" + + parent: Overlay.overlay + anchors.centerIn: parent + width: Math.min(400, parent.width - Appearance.padding.large * 2) + padding: Appearance.padding.large * 1.5 + + modal: true + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + opacity: 0 + scale: 0.7 + + enter: Transition { + Anim { + property: "opacity" + from: 0 + to: 1 + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + } + Anim { + property: "scale" + from: 0.7 + to: 1 + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + } + } + + exit: Transition { + Anim { + property: "opacity" + from: 1 + to: 0 + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + } + Anim { + property: "scale" + from: 1 + to: 0.7 + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + } + } + + function closeWithAnimation(): void { + close(); + } + + Overlay.modal: Rectangle { + color: Qt.rgba(0, 0, 0, 0.4 * editVpnDialog.opacity) + } + + background: StyledRect { + color: Colours.palette.m3surfaceContainerHigh + radius: Appearance.rounding.large + + Elevation { + anchors.fill: parent + radius: parent.radius + level: 3 + z: -1 + } + } + + contentItem: ColumnLayout { + spacing: Appearance.spacing.normal + + StyledText { + text: qsTr("Edit VPN Provider") + font.pointSize: Appearance.font.size.large + font.weight: 500 + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.smaller / 2 + + StyledText { + text: qsTr("Display Name") + font.pointSize: Appearance.font.size.small + color: Colours.palette.m3onSurfaceVariant + } + + StyledRect { + Layout.fillWidth: true + implicitHeight: 40 + color: displayNameField.activeFocus ? Colours.layer(Colours.palette.m3surfaceContainer, 3) : Colours.layer(Colours.palette.m3surfaceContainer, 2) + radius: Appearance.rounding.small + border.width: 1 + border.color: displayNameField.activeFocus ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3outline, 0.3) + + Behavior on color { + CAnim {} + } + Behavior on border.color { + CAnim {} + } + + StyledTextField { + id: displayNameField + anchors.centerIn: parent + width: parent.width - Appearance.padding.normal + horizontalAlignment: TextInput.AlignLeft + text: editVpnDialog.displayName + onTextChanged: editVpnDialog.displayName = text + } + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.smaller / 2 + + StyledText { + text: qsTr("Interface (e.g., wg0, torguard)") + font.pointSize: Appearance.font.size.small + color: Colours.palette.m3onSurfaceVariant + } + + StyledRect { + Layout.fillWidth: true + implicitHeight: 40 + color: interfaceNameField.activeFocus ? Colours.layer(Colours.palette.m3surfaceContainer, 3) : Colours.layer(Colours.palette.m3surfaceContainer, 2) + radius: Appearance.rounding.small + border.width: 1 + border.color: interfaceNameField.activeFocus ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3outline, 0.3) + + Behavior on color { + CAnim {} + } + Behavior on border.color { + CAnim {} + } + + StyledTextField { + id: interfaceNameField + anchors.centerIn: parent + width: parent.width - Appearance.padding.normal + horizontalAlignment: TextInput.AlignLeft + text: editVpnDialog.interfaceName + onTextChanged: editVpnDialog.interfaceName = text + } + } + } + + RowLayout { + Layout.topMargin: Appearance.spacing.normal + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + TextButton { + Layout.fillWidth: true + text: qsTr("Cancel") + inactiveColour: Colours.tPalette.m3surfaceContainerHigh + inactiveOnColour: Colours.palette.m3onSurface + onClicked: editVpnDialog.closeWithAnimation() + } + + TextButton { + Layout.fillWidth: true + text: qsTr("Save") + enabled: editVpnDialog.interfaceName.length > 0 + inactiveColour: Colours.palette.m3primaryContainer + inactiveOnColour: Colours.palette.m3onPrimaryContainer + + onClicked: { + const providers = []; + const oldProvider = Config.utilities.vpn.provider[editVpnDialog.editIndex]; + const wasEnabled = typeof oldProvider === "object" ? (oldProvider.enabled !== false) : true; + + for (let i = 0; i < Config.utilities.vpn.provider.length; i++) { + if (i === editVpnDialog.editIndex) { + providers.push({ + name: editVpnDialog.providerName, + displayName: editVpnDialog.displayName || editVpnDialog.interfaceName, + interface: editVpnDialog.interfaceName, + enabled: wasEnabled + }); + } else { + providers.push(Config.utilities.vpn.provider[i]); + } + } + + Config.utilities.vpn.provider = providers; + Config.save(); + editVpnDialog.closeWithAnimation(); + } + } + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/network/VpnList.qml b/.config/quickshell/caelestia/modules/controlcenter/network/VpnList.qml new file mode 100644 index 0000000..81f4a45 --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/network/VpnList.qml @@ -0,0 +1,686 @@ +pragma ComponentBehavior: Bound + +import ".." +import qs.components +import qs.components.controls +import qs.components.effects +import qs.services +import qs.config +import Quickshell +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +ColumnLayout { + id: root + + required property Session session + property bool showHeader: true + property int pendingSwitchIndex: -1 + + spacing: Appearance.spacing.normal + + Connections { + target: VPN + function onConnectedChanged() { + if (!VPN.connected && root.pendingSwitchIndex >= 0) { + const targetIndex = root.pendingSwitchIndex; + root.pendingSwitchIndex = -1; + + const providers = []; + for (let i = 0; i < Config.utilities.vpn.provider.length; i++) { + const p = Config.utilities.vpn.provider[i]; + if (typeof p === "object") { + const newProvider = { + name: p.name, + displayName: p.displayName, + interface: p.interface, + enabled: (i === targetIndex) + }; + providers.push(newProvider); + } else { + providers.push(p); + } + } + Config.utilities.vpn.provider = providers; + Config.save(); + + Qt.callLater(function () { + VPN.toggle(); + }); + } + } + } + + TextButton { + Layout.fillWidth: true + text: qsTr("+ Add VPN Provider") + inactiveColour: Colours.palette.m3primaryContainer + inactiveOnColour: Colours.palette.m3onPrimaryContainer + + onClicked: { + vpnDialog.showProviderSelection(); + } + } + + ListView { + id: listView + + Layout.fillWidth: true + Layout.preferredHeight: contentHeight + + interactive: false + spacing: Appearance.spacing.smaller + + model: ScriptModel { + values: Config.utilities.vpn.provider.map((provider, index) => { + const isObject = typeof provider === "object"; + const name = isObject ? (provider.name || "custom") : String(provider); + const displayName = isObject ? (provider.displayName || name) : name; + const iface = isObject ? (provider.interface || "") : ""; + const enabled = isObject ? (provider.enabled === true) : false; + + return { + index: index, + name: name, + displayName: displayName, + interface: iface, + provider: provider, + enabled: enabled + }; + }) + } + + delegate: Component { + StyledRect { + required property var modelData + required property int index + + width: ListView.view ? ListView.view.width : undefined + + color: Qt.alpha(Colours.tPalette.m3surfaceContainer, (root.session && root.session.vpn && root.session.vpn.active === modelData) ? Colours.tPalette.m3surfaceContainer.a : 0) + radius: Appearance.rounding.normal + + StateLayer { + function onClicked(): void { + if (root.session && root.session.vpn) { + root.session.vpn.active = modelData; + } + } + } + + RowLayout { + id: rowLayout + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Appearance.padding.normal + + spacing: Appearance.spacing.normal + + StyledRect { + implicitWidth: implicitHeight + implicitHeight: icon.implicitHeight + Appearance.padding.normal * 2 + + radius: Appearance.rounding.normal + color: modelData.enabled && VPN.connected ? Colours.palette.m3primaryContainer : Colours.tPalette.m3surfaceContainerHigh + + MaterialIcon { + id: icon + + anchors.centerIn: parent + text: modelData.enabled && VPN.connected ? "vpn_key" : "vpn_key_off" + font.pointSize: Appearance.font.size.large + fill: modelData.enabled && VPN.connected ? 1 : 0 + color: modelData.enabled && VPN.connected ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface + } + } + + ColumnLayout { + Layout.fillWidth: true + + spacing: 0 + + StyledText { + Layout.fillWidth: true + elide: Text.ElideRight + maximumLineCount: 1 + + text: modelData.displayName || qsTr("Unknown") + } + + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.smaller + + StyledText { + Layout.fillWidth: true + text: { + if (modelData.enabled && VPN.connected) + return qsTr("Connected"); + if (modelData.enabled && VPN.connecting) + return qsTr("Connecting..."); + if (modelData.enabled) + return qsTr("Enabled"); + return qsTr("Disabled"); + } + color: modelData.enabled ? (VPN.connected ? Colours.palette.m3primary : Colours.palette.m3onSurface) : Colours.palette.m3outline + font.pointSize: Appearance.font.size.small + font.weight: modelData.enabled && VPN.connected ? 500 : 400 + elide: Text.ElideRight + } + } + } + + StyledRect { + implicitWidth: implicitHeight + implicitHeight: connectIcon.implicitHeight + Appearance.padding.smaller * 2 + + radius: Appearance.rounding.full + color: Qt.alpha(Colours.palette.m3primaryContainer, VPN.connected && modelData.enabled ? 1 : 0) + + StateLayer { + enabled: !VPN.connecting + function onClicked(): void { + const clickedIndex = modelData.index; + + if (modelData.enabled) { + VPN.toggle(); + } else { + if (VPN.connected) { + root.pendingSwitchIndex = clickedIndex; + VPN.toggle(); + } else { + const providers = []; + for (let i = 0; i < Config.utilities.vpn.provider.length; i++) { + const p = Config.utilities.vpn.provider[i]; + if (typeof p === "object") { + const newProvider = { + name: p.name, + displayName: p.displayName, + interface: p.interface, + enabled: (i === clickedIndex) + }; + providers.push(newProvider); + } else { + providers.push(p); + } + } + Config.utilities.vpn.provider = providers; + Config.save(); + + Qt.callLater(function () { + VPN.toggle(); + }); + } + } + } + } + + MaterialIcon { + id: connectIcon + + anchors.centerIn: parent + text: VPN.connected && modelData.enabled ? "link_off" : "link" + color: VPN.connected && modelData.enabled ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface + } + } + + StyledRect { + implicitWidth: implicitHeight + implicitHeight: deleteIcon.implicitHeight + Appearance.padding.smaller * 2 + + radius: Appearance.rounding.full + color: "transparent" + + StateLayer { + function onClicked(): void { + const providers = []; + for (let i = 0; i < Config.utilities.vpn.provider.length; i++) { + if (i !== modelData.index) { + providers.push(Config.utilities.vpn.provider[i]); + } + } + Config.utilities.vpn.provider = providers; + Config.save(); + } + } + + MaterialIcon { + id: deleteIcon + + anchors.centerIn: parent + text: "delete" + color: Colours.palette.m3onSurface + } + } + } + + implicitHeight: rowLayout.implicitHeight + Appearance.padding.normal * 2 + } + } + } + + Popup { + id: vpnDialog + + property string currentState: "selection" + property int editIndex: -1 + property string providerName: "" + property string displayName: "" + property string interfaceName: "" + + parent: Overlay.overlay + x: Math.round((parent.width - width) / 2) + y: Math.round((parent.height - height) / 2) + implicitWidth: Math.min(400, parent.width - Appearance.padding.large * 2) + padding: Appearance.padding.large * 1.5 + + modal: true + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + opacity: 0 + scale: 0.7 + + enter: Transition { + ParallelAnimation { + Anim { + property: "opacity" + from: 0 + to: 1 + duration: Appearance.anim.durations.normal + easing.bezierCurve: Appearance.anim.curves.emphasized + } + Anim { + property: "scale" + from: 0.7 + to: 1 + duration: Appearance.anim.durations.normal + easing.bezierCurve: Appearance.anim.curves.emphasized + } + } + } + + exit: Transition { + ParallelAnimation { + Anim { + property: "opacity" + from: 1 + to: 0 + duration: Appearance.anim.durations.small + easing.bezierCurve: Appearance.anim.curves.emphasized + } + Anim { + property: "scale" + from: 1 + to: 0.7 + duration: Appearance.anim.durations.small + easing.bezierCurve: Appearance.anim.curves.emphasized + } + } + } + + function showProviderSelection(): void { + currentState = "selection"; + open(); + } + + function closeWithAnimation(): void { + close(); + } + + function showAddForm(providerType: string, defaultDisplayName: string): void { + editIndex = -1; + providerName = providerType; + displayName = defaultDisplayName; + interfaceName = ""; + + if (currentState === "selection") { + transitionToForm.start(); + } else { + currentState = "form"; + isClosing = false; + open(); + } + } + + function showEditForm(index: int): void { + const provider = Config.utilities.vpn.provider[index]; + const isObject = typeof provider === "object"; + + editIndex = index; + providerName = isObject ? (provider.name || "custom") : String(provider); + displayName = isObject ? (provider.displayName || providerName) : providerName; + interfaceName = isObject ? (provider.interface || "") : ""; + + currentState = "form"; + open(); + } + + Overlay.modal: Rectangle { + color: Qt.rgba(0, 0, 0, 0.4 * vpnDialog.opacity) + } + + onClosed: { + currentState = "selection"; + } + + SequentialAnimation { + id: transitionToForm + + ParallelAnimation { + Anim { + target: selectionContent + property: "opacity" + to: 0 + duration: Appearance.anim.durations.small + easing.bezierCurve: Appearance.anim.curves.emphasized + } + } + + ScriptAction { + script: { + vpnDialog.currentState = "form"; + } + } + + ParallelAnimation { + Anim { + target: formContent + property: "opacity" + to: 1 + duration: Appearance.anim.durations.small + easing.bezierCurve: Appearance.anim.curves.emphasized + } + } + } + + background: StyledRect { + color: Colours.palette.m3surfaceContainerHigh + radius: Appearance.rounding.large + + Elevation { + anchors.fill: parent + radius: parent.radius + level: 3 + z: -1 + } + + Behavior on implicitHeight { + Anim { + duration: Appearance.anim.durations.normal + easing.bezierCurve: Appearance.anim.curves.emphasized + } + } + } + + contentItem: Item { + implicitHeight: vpnDialog.currentState === "selection" ? selectionContent.implicitHeight : formContent.implicitHeight + + Behavior on implicitHeight { + Anim { + duration: Appearance.anim.durations.normal + easing.bezierCurve: Appearance.anim.curves.emphasized + } + } + + ColumnLayout { + id: selectionContent + + anchors.fill: parent + spacing: Appearance.spacing.normal + visible: vpnDialog.currentState === "selection" + opacity: vpnDialog.currentState === "selection" ? 1 : 0 + + Behavior on opacity { + Anim { + duration: Appearance.anim.durations.small + easing.bezierCurve: Appearance.anim.curves.emphasized + } + } + + StyledText { + text: qsTr("Add VPN Provider") + font.pointSize: Appearance.font.size.large + font.weight: 500 + } + + StyledText { + Layout.fillWidth: true + text: qsTr("Choose a provider to add") + wrapMode: Text.WordWrap + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.small + } + + TextButton { + Layout.topMargin: Appearance.spacing.normal + Layout.fillWidth: true + text: qsTr("NetBird") + inactiveColour: Colours.tPalette.m3surfaceContainerHigh + inactiveOnColour: Colours.palette.m3onSurface + onClicked: { + const providers = []; + for (let i = 0; i < Config.utilities.vpn.provider.length; i++) { + providers.push(Config.utilities.vpn.provider[i]); + } + providers.push({ + name: "netbird", + displayName: "NetBird", + interface: "wt0" + }); + Config.utilities.vpn.provider = providers; + Config.save(); + vpnDialog.closeWithAnimation(); + } + } + + TextButton { + Layout.fillWidth: true + text: qsTr("Tailscale") + inactiveColour: Colours.tPalette.m3surfaceContainerHigh + inactiveOnColour: Colours.palette.m3onSurface + onClicked: { + const providers = []; + for (let i = 0; i < Config.utilities.vpn.provider.length; i++) { + providers.push(Config.utilities.vpn.provider[i]); + } + providers.push({ + name: "tailscale", + displayName: "Tailscale", + interface: "tailscale0" + }); + Config.utilities.vpn.provider = providers; + Config.save(); + vpnDialog.closeWithAnimation(); + } + } + + TextButton { + Layout.fillWidth: true + text: qsTr("Cloudflare WARP") + inactiveColour: Colours.tPalette.m3surfaceContainerHigh + inactiveOnColour: Colours.palette.m3onSurface + onClicked: { + const providers = []; + for (let i = 0; i < Config.utilities.vpn.provider.length; i++) { + providers.push(Config.utilities.vpn.provider[i]); + } + providers.push({ + name: "warp", + displayName: "Cloudflare WARP", + interface: "CloudflareWARP" + }); + Config.utilities.vpn.provider = providers; + Config.save(); + vpnDialog.closeWithAnimation(); + } + } + + TextButton { + Layout.fillWidth: true + text: qsTr("WireGuard (Custom)") + inactiveColour: Colours.tPalette.m3surfaceContainerHigh + inactiveOnColour: Colours.palette.m3onSurface + onClicked: { + vpnDialog.showAddForm("wireguard", "WireGuard"); + } + } + + TextButton { + Layout.topMargin: Appearance.spacing.normal + Layout.fillWidth: true + text: qsTr("Cancel") + inactiveColour: Colours.palette.m3secondaryContainer + inactiveOnColour: Colours.palette.m3onSecondaryContainer + onClicked: vpnDialog.closeWithAnimation() + } + } + + ColumnLayout { + id: formContent + + anchors.fill: parent + spacing: Appearance.spacing.normal + visible: vpnDialog.currentState === "form" + opacity: vpnDialog.currentState === "form" ? 1 : 0 + + Behavior on opacity { + Anim { + duration: Appearance.anim.durations.small + easing.bezierCurve: Appearance.anim.curves.emphasized + } + } + + StyledText { + text: vpnDialog.editIndex >= 0 ? qsTr("Edit VPN Provider") : qsTr("Add %1 VPN").arg(vpnDialog.displayName) + font.pointSize: Appearance.font.size.large + font.weight: 500 + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.smaller / 2 + + StyledText { + text: qsTr("Display Name") + font.pointSize: Appearance.font.size.small + color: Colours.palette.m3onSurfaceVariant + } + + StyledRect { + Layout.fillWidth: true + implicitHeight: 40 + color: displayNameField.activeFocus ? Colours.layer(Colours.palette.m3surfaceContainer, 3) : Colours.layer(Colours.palette.m3surfaceContainer, 2) + radius: Appearance.rounding.small + border.width: 1 + border.color: displayNameField.activeFocus ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3outline, 0.3) + + Behavior on color { + CAnim {} + } + Behavior on border.color { + CAnim {} + } + + StyledTextField { + id: displayNameField + anchors.centerIn: parent + width: parent.width - Appearance.padding.normal + horizontalAlignment: TextInput.AlignLeft + text: vpnDialog.displayName + onTextChanged: vpnDialog.displayName = text + } + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.smaller / 2 + + StyledText { + text: qsTr("Interface (e.g., wg0, torguard)") + font.pointSize: Appearance.font.size.small + color: Colours.palette.m3onSurfaceVariant + } + + StyledRect { + Layout.fillWidth: true + implicitHeight: 40 + color: interfaceNameField.activeFocus ? Colours.layer(Colours.palette.m3surfaceContainer, 3) : Colours.layer(Colours.palette.m3surfaceContainer, 2) + radius: Appearance.rounding.small + border.width: 1 + border.color: interfaceNameField.activeFocus ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3outline, 0.3) + + Behavior on color { + CAnim {} + } + Behavior on border.color { + CAnim {} + } + + StyledTextField { + id: interfaceNameField + anchors.centerIn: parent + width: parent.width - Appearance.padding.normal + horizontalAlignment: TextInput.AlignLeft + text: vpnDialog.interfaceName + onTextChanged: vpnDialog.interfaceName = text + } + } + } + + RowLayout { + Layout.topMargin: Appearance.spacing.normal + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + TextButton { + Layout.fillWidth: true + text: qsTr("Cancel") + inactiveColour: Colours.tPalette.m3surfaceContainerHigh + inactiveOnColour: Colours.palette.m3onSurface + onClicked: vpnDialog.closeWithAnimation() + } + + TextButton { + Layout.fillWidth: true + text: qsTr("Save") + enabled: vpnDialog.interfaceName.length > 0 + inactiveColour: Colours.palette.m3primaryContainer + inactiveOnColour: Colours.palette.m3onPrimaryContainer + + onClicked: { + const providers = []; + const newProvider = { + name: vpnDialog.providerName, + displayName: vpnDialog.displayName || vpnDialog.interfaceName, + interface: vpnDialog.interfaceName + }; + + if (vpnDialog.editIndex >= 0) { + for (let i = 0; i < Config.utilities.vpn.provider.length; i++) { + if (i === vpnDialog.editIndex) { + providers.push(newProvider); + } else { + providers.push(Config.utilities.vpn.provider[i]); + } + } + } else { + for (let i = 0; i < Config.utilities.vpn.provider.length; i++) { + providers.push(Config.utilities.vpn.provider[i]); + } + providers.push(newProvider); + } + + Config.utilities.vpn.provider = providers; + Config.save(); + vpnDialog.closeWithAnimation(); + } + } + } + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/network/VpnSettings.qml b/.config/quickshell/caelestia/modules/controlcenter/network/VpnSettings.qml new file mode 100644 index 0000000..49d801d --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/network/VpnSettings.qml @@ -0,0 +1,232 @@ +pragma ComponentBehavior: Bound + +import ".." +import "../components" +import qs.components +import qs.components.controls +import qs.components.containers +import qs.components.effects +import qs.services +import qs.config +import Quickshell +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +ColumnLayout { + id: root + + required property Session session + + spacing: Appearance.spacing.normal + + SettingsHeader { + icon: "vpn_key" + title: qsTr("VPN Settings") + } + + SectionHeader { + Layout.topMargin: Appearance.spacing.large + title: qsTr("General") + description: qsTr("VPN configuration") + } + + SectionContainer { + ToggleRow { + label: qsTr("VPN enabled") + checked: Config.utilities.vpn.enabled + toggle.onToggled: { + Config.utilities.vpn.enabled = checked; + Config.save(); + } + } + } + + SectionHeader { + Layout.topMargin: Appearance.spacing.large + title: qsTr("Providers") + description: qsTr("Manage VPN providers") + } + + SectionContainer { + contentSpacing: Appearance.spacing.normal + + ListView { + Layout.fillWidth: true + Layout.preferredHeight: contentHeight + + interactive: false + spacing: Appearance.spacing.smaller + + model: ScriptModel { + values: Config.utilities.vpn.provider.map((provider, index) => { + const isObject = typeof provider === "object"; + const name = isObject ? (provider.name || "custom") : String(provider); + const displayName = isObject ? (provider.displayName || name) : name; + const iface = isObject ? (provider.interface || "") : ""; + + return { + index: index, + name: name, + displayName: displayName, + interface: iface, + provider: provider, + isActive: index === 0 + }; + }) + } + + delegate: Component { + StyledRect { + required property var modelData + required property int index + + width: ListView.view ? ListView.view.width : undefined + color: Colours.tPalette.m3surfaceContainerHigh + radius: Appearance.rounding.normal + + RowLayout { + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Appearance.padding.normal + spacing: Appearance.spacing.normal + + MaterialIcon { + text: modelData.isActive ? "vpn_key" : "vpn_key_off" + font.pointSize: Appearance.font.size.large + color: modelData.isActive ? Colours.palette.m3primary : Colours.palette.m3outline + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + StyledText { + text: modelData.displayName + font.weight: modelData.isActive ? 500 : 400 + } + + StyledText { + text: qsTr("%1 • %2").arg(modelData.name).arg(modelData.interface || qsTr("No interface")) + font.pointSize: Appearance.font.size.small + color: Colours.palette.m3outline + } + } + + IconButton { + icon: modelData.isActive ? "arrow_downward" : "arrow_upward" + visible: !modelData.isActive || Config.utilities.vpn.provider.length > 1 + onClicked: { + if (modelData.isActive && index < Config.utilities.vpn.provider.length - 1) { + // Move down + const providers = [...Config.utilities.vpn.provider]; + const temp = providers[index]; + providers[index] = providers[index + 1]; + providers[index + 1] = temp; + Config.utilities.vpn.provider = providers; + Config.save(); + } else if (!modelData.isActive) { + // Make active (move to top) + const providers = [...Config.utilities.vpn.provider]; + const provider = providers.splice(index, 1)[0]; + providers.unshift(provider); + Config.utilities.vpn.provider = providers; + Config.save(); + } + } + } + + IconButton { + icon: "delete" + onClicked: { + const providers = [...Config.utilities.vpn.provider]; + providers.splice(index, 1); + Config.utilities.vpn.provider = providers; + Config.save(); + } + } + } + + implicitHeight: 60 + } + } + } + + TextButton { + Layout.fillWidth: true + Layout.topMargin: Appearance.spacing.normal + text: qsTr("+ Add Provider") + inactiveColour: Colours.palette.m3primaryContainer + inactiveOnColour: Colours.palette.m3onPrimaryContainer + + onClicked: { + addProviderDialog.open(); + } + } + } + + SectionHeader { + Layout.topMargin: Appearance.spacing.large + title: qsTr("Quick Add") + description: qsTr("Add common VPN providers") + } + + SectionContainer { + contentSpacing: Appearance.spacing.smaller + + TextButton { + Layout.fillWidth: true + text: qsTr("+ Add NetBird") + inactiveColour: Colours.tPalette.m3surfaceContainerHigh + inactiveOnColour: Colours.palette.m3onSurface + + onClicked: { + const providers = [...Config.utilities.vpn.provider]; + providers.push({ + name: "netbird", + displayName: "NetBird", + interface: "wt0" + }); + Config.utilities.vpn.provider = providers; + Config.save(); + } + } + + TextButton { + Layout.fillWidth: true + text: qsTr("+ Add Tailscale") + inactiveColour: Colours.tPalette.m3surfaceContainerHigh + inactiveOnColour: Colours.palette.m3onSurface + + onClicked: { + const providers = [...Config.utilities.vpn.provider]; + providers.push({ + name: "tailscale", + displayName: "Tailscale", + interface: "tailscale0" + }); + Config.utilities.vpn.provider = providers; + Config.save(); + } + } + + TextButton { + Layout.fillWidth: true + text: qsTr("+ Add Cloudflare WARP") + inactiveColour: Colours.tPalette.m3surfaceContainerHigh + inactiveOnColour: Colours.palette.m3onSurface + + onClicked: { + const providers = [...Config.utilities.vpn.provider]; + providers.push({ + name: "warp", + displayName: "Cloudflare WARP", + interface: "CloudflareWARP" + }); + Config.utilities.vpn.provider = providers; + Config.save(); + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/network/WirelessDetails.qml b/.config/quickshell/caelestia/modules/controlcenter/network/WirelessDetails.qml new file mode 100644 index 0000000..e8777cd --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/network/WirelessDetails.qml @@ -0,0 +1,211 @@ +pragma ComponentBehavior: Bound + +import ".." +import "../components" +import "." +import qs.components +import qs.components.controls +import qs.components.effects +import qs.components.containers +import qs.services +import qs.config +import qs.utils +import QtQuick +import QtQuick.Layouts + +DeviceDetails { + id: root + + required property Session session + readonly property var network: root.session.network.active + + device: network + + Component.onCompleted: { + updateDeviceDetails(); + checkSavedProfile(); + } + + onNetworkChanged: { + connectionUpdateTimer.stop(); + if (network && network.ssid) { + connectionUpdateTimer.start(); + } + updateDeviceDetails(); + checkSavedProfile(); + } + + function checkSavedProfile(): void { + if (network && network.ssid) { + Nmcli.loadSavedConnections(() => {}); + } + } + + Connections { + target: Nmcli + function onActiveChanged() { + updateDeviceDetails(); + } + function onWirelessDeviceDetailsChanged() { + if (network && network.ssid) { + const isActive = network.active || (Nmcli.active && Nmcli.active.ssid === network.ssid); + if (isActive && Nmcli.wirelessDeviceDetails && Nmcli.wirelessDeviceDetails !== null) { + connectionUpdateTimer.stop(); + } + } + } + } + + Timer { + id: connectionUpdateTimer + interval: 500 + repeat: true + running: network && network.ssid + onTriggered: { + if (network) { + const isActive = network.active || (Nmcli.active && Nmcli.active.ssid === network.ssid); + if (isActive) { + if (!Nmcli.wirelessDeviceDetails || Nmcli.wirelessDeviceDetails === null) { + Nmcli.getWirelessDeviceDetails("", () => {}); + } else { + connectionUpdateTimer.stop(); + } + } else { + if (Nmcli.wirelessDeviceDetails !== null) { + Nmcli.wirelessDeviceDetails = null; + } + } + } + } + } + + function updateDeviceDetails(): void { + if (network && network.ssid) { + const isActive = network.active || (Nmcli.active && Nmcli.active.ssid === network.ssid); + if (isActive) { + Nmcli.getWirelessDeviceDetails(""); + } else { + Nmcli.wirelessDeviceDetails = null; + } + } else { + Nmcli.wirelessDeviceDetails = null; + } + } + + headerComponent: Component { + ConnectionHeader { + icon: root.network?.isSecure ? "lock" : "wifi" + title: root.network?.ssid ?? qsTr("Unknown") + } + } + + sections: [ + Component { + ColumnLayout { + spacing: Appearance.spacing.normal + + SectionHeader { + title: qsTr("Connection status") + description: qsTr("Connection settings for this network") + } + + SectionContainer { + ToggleRow { + label: qsTr("Connected") + checked: root.network?.active ?? false + toggle.onToggled: { + if (checked) { + NetworkConnection.handleConnect(root.network, root.session, null); + } else { + Nmcli.disconnectFromNetwork(); + } + } + } + + TextButton { + Layout.fillWidth: true + Layout.topMargin: Appearance.spacing.normal + Layout.minimumHeight: Appearance.font.size.normal + Appearance.padding.normal * 2 + visible: { + if (!root.network || !root.network.ssid) { + return false; + } + return Nmcli.hasSavedProfile(root.network.ssid); + } + inactiveColour: Colours.palette.m3secondaryContainer + inactiveOnColour: Colours.palette.m3onSecondaryContainer + text: qsTr("Forget Network") + + onClicked: { + if (root.network && root.network.ssid) { + if (root.network.active) { + Nmcli.disconnectFromNetwork(); + } + Nmcli.forgetNetwork(root.network.ssid); + } + } + } + } + } + }, + Component { + ColumnLayout { + spacing: Appearance.spacing.normal + + SectionHeader { + title: qsTr("Network properties") + description: qsTr("Additional information") + } + + SectionContainer { + contentSpacing: Appearance.spacing.small / 2 + + PropertyRow { + label: qsTr("SSID") + value: root.network?.ssid ?? qsTr("Unknown") + } + + PropertyRow { + showTopMargin: true + label: qsTr("BSSID") + value: root.network?.bssid ?? qsTr("Unknown") + } + + PropertyRow { + showTopMargin: true + label: qsTr("Signal strength") + value: root.network ? qsTr("%1%").arg(root.network.strength) : qsTr("N/A") + } + + PropertyRow { + showTopMargin: true + label: qsTr("Frequency") + value: root.network ? qsTr("%1 MHz").arg(root.network.frequency) : qsTr("N/A") + } + + PropertyRow { + showTopMargin: true + label: qsTr("Security") + value: root.network ? (root.network.isSecure ? root.network.security : qsTr("Open")) : qsTr("N/A") + } + } + } + }, + Component { + ColumnLayout { + spacing: Appearance.spacing.normal + + SectionHeader { + title: qsTr("Connection information") + description: qsTr("Network connection details") + } + + SectionContainer { + ConnectionInfoSection { + deviceDetails: Nmcli.wirelessDeviceDetails + } + } + } + } + ] +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/network/WirelessList.qml b/.config/quickshell/caelestia/modules/controlcenter/network/WirelessList.qml new file mode 100644 index 0000000..57a155f --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/network/WirelessList.qml @@ -0,0 +1,228 @@ +pragma ComponentBehavior: Bound + +import ".." +import "../components" +import "." +import qs.components +import qs.components.controls +import qs.components.containers +import qs.components.effects +import qs.services +import qs.config +import qs.utils +import Quickshell +import QtQuick +import QtQuick.Layouts + +DeviceList { + id: root + + required property Session session + + title: qsTr("Networks (%1)").arg(Nmcli.networks.length) + description: qsTr("All available WiFi networks") + activeItem: session.network.active + + titleSuffix: Component { + StyledText { + visible: Nmcli.scanning + text: qsTr("Scanning...") + color: Colours.palette.m3primary + font.pointSize: Appearance.font.size.small + } + } + + model: ScriptModel { + values: [...Nmcli.networks].sort((a, b) => { + if (a.active !== b.active) + return b.active - a.active; + return b.strength - a.strength; + }) + } + + headerComponent: Component { + RowLayout { + spacing: Appearance.spacing.smaller + + StyledText { + text: qsTr("Settings") + font.pointSize: Appearance.font.size.large + font.weight: 500 + } + + Item { + Layout.fillWidth: true + } + + ToggleButton { + toggled: Nmcli.wifiEnabled + icon: "wifi" + accent: "Tertiary" + iconSize: Appearance.font.size.normal + horizontalPadding: Appearance.padding.normal + verticalPadding: Appearance.padding.smaller + + onClicked: { + Nmcli.toggleWifi(null); + } + } + + ToggleButton { + toggled: Nmcli.scanning + icon: "wifi_find" + accent: "Secondary" + iconSize: Appearance.font.size.normal + horizontalPadding: Appearance.padding.normal + verticalPadding: Appearance.padding.smaller + + onClicked: { + Nmcli.rescanWifi(); + } + } + + ToggleButton { + toggled: !root.session.network.active + icon: "settings" + accent: "Primary" + iconSize: Appearance.font.size.normal + horizontalPadding: Appearance.padding.normal + verticalPadding: Appearance.padding.smaller + + onClicked: { + if (root.session.network.active) + root.session.network.active = null; + else { + root.session.network.active = root.view.model.get(0)?.modelData ?? null; + } + } + } + } + } + + delegate: Component { + StyledRect { + required property var modelData + + width: ListView.view ? ListView.view.width : undefined + + color: Qt.alpha(Colours.tPalette.m3surfaceContainer, root.activeItem === modelData ? Colours.tPalette.m3surfaceContainer.a : 0) + radius: Appearance.rounding.normal + + StateLayer { + function onClicked(): void { + root.session.network.active = modelData; + if (modelData && modelData.ssid) { + root.checkSavedProfileForNetwork(modelData.ssid); + } + } + } + + RowLayout { + id: rowLayout + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Appearance.padding.normal + + spacing: Appearance.spacing.normal + + StyledRect { + implicitWidth: implicitHeight + implicitHeight: icon.implicitHeight + Appearance.padding.normal * 2 + + radius: Appearance.rounding.normal + color: modelData.active ? Colours.palette.m3primaryContainer : Colours.tPalette.m3surfaceContainerHigh + + MaterialIcon { + id: icon + + anchors.centerIn: parent + text: Icons.getNetworkIcon(modelData.strength, modelData.isSecure) + font.pointSize: Appearance.font.size.large + fill: modelData.active ? 1 : 0 + color: modelData.active ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface + } + } + + ColumnLayout { + Layout.fillWidth: true + + spacing: 0 + + StyledText { + Layout.fillWidth: true + elide: Text.ElideRight + maximumLineCount: 1 + + text: modelData.ssid || qsTr("Unknown") + } + + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.smaller + + StyledText { + Layout.fillWidth: true + text: { + if (modelData.active) + return qsTr("Connected"); + if (modelData.isSecure && modelData.security && modelData.security.length > 0) { + return modelData.security; + } + if (modelData.isSecure) + return qsTr("Secured"); + return qsTr("Open"); + } + color: modelData.active ? Colours.palette.m3primary : Colours.palette.m3outline + font.pointSize: Appearance.font.size.small + font.weight: modelData.active ? 500 : 400 + elide: Text.ElideRight + } + } + } + + StyledRect { + implicitWidth: implicitHeight + implicitHeight: connectIcon.implicitHeight + Appearance.padding.smaller * 2 + + radius: Appearance.rounding.full + color: Qt.alpha(Colours.palette.m3primaryContainer, modelData.active ? 1 : 0) + + StateLayer { + function onClicked(): void { + if (modelData.active) { + Nmcli.disconnectFromNetwork(); + } else { + NetworkConnection.handleConnect(modelData, root.session, null); + } + } + } + + MaterialIcon { + id: connectIcon + + anchors.centerIn: parent + text: modelData.active ? "link_off" : "link" + color: modelData.active ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface + } + } + } + + implicitHeight: rowLayout.implicitHeight + Appearance.padding.normal * 2 + } + } + + onItemSelected: function (item) { + session.network.active = item; + if (item && item.ssid) { + checkSavedProfileForNetwork(item.ssid); + } + } + + function checkSavedProfileForNetwork(ssid: string): void { + if (ssid && ssid.length > 0) { + Nmcli.loadSavedConnections(() => {}); + } + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/network/WirelessPane.qml b/.config/quickshell/caelestia/modules/controlcenter/network/WirelessPane.qml new file mode 100644 index 0000000..8150af9 --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/network/WirelessPane.qml @@ -0,0 +1,57 @@ +pragma ComponentBehavior: Bound + +import ".." +import "../components" +import qs.components +import qs.components.containers +import qs.config +import Quickshell.Widgets +import QtQuick + +SplitPaneWithDetails { + id: root + + required property Session session + + anchors.fill: parent + + activeItem: session.network.active + paneIdGenerator: function (item) { + return item ? (item.ssid || item.bssid || "") : ""; + } + + leftContent: Component { + WirelessList { + session: root.session + } + } + + rightDetailsComponent: Component { + WirelessDetails { + session: root.session + } + } + + rightSettingsComponent: Component { + StyledFlickable { + flickableDirection: Flickable.VerticalFlick + contentHeight: settingsInner.height + clip: true + + WirelessSettings { + id: settingsInner + + anchors.left: parent.left + anchors.right: parent.right + session: root.session + } + } + } + + overlayComponent: Component { + WirelessPasswordDialog { + anchors.fill: parent + session: root.session + } + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/network/WirelessPasswordDialog.qml b/.config/quickshell/caelestia/modules/controlcenter/network/WirelessPasswordDialog.qml new file mode 100644 index 0000000..7ad5204 --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/network/WirelessPasswordDialog.qml @@ -0,0 +1,511 @@ +pragma ComponentBehavior: Bound + +import ".." +import "." +import qs.components +import qs.components.controls +import qs.components.effects +import qs.components.containers +import qs.services +import qs.config +import qs.utils +import Quickshell +import QtQuick +import QtQuick.Layouts + +Item { + id: root + + required property Session session + + readonly property var network: { + if (session.network.pendingNetwork) { + return session.network.pendingNetwork; + } + if (session.network.active) { + return session.network.active; + } + return null; + } + + property bool isClosing: false + visible: session.network.showPasswordDialog || isClosing + enabled: session.network.showPasswordDialog && !isClosing + focus: enabled + + Keys.onEscapePressed: { + closeDialog(); + } + + Rectangle { + anchors.fill: parent + color: Qt.rgba(0, 0, 0, 0.5) + opacity: root.session.network.showPasswordDialog && !root.isClosing ? 1 : 0 + + Behavior on opacity { + Anim {} + } + + MouseArea { + anchors.fill: parent + onClicked: closeDialog() + } + } + + StyledRect { + id: dialog + + anchors.centerIn: parent + + implicitWidth: 400 + implicitHeight: content.implicitHeight + Appearance.padding.large * 2 + + radius: Appearance.rounding.normal + color: Colours.tPalette.m3surface + opacity: root.session.network.showPasswordDialog && !root.isClosing ? 1 : 0 + scale: root.session.network.showPasswordDialog && !root.isClosing ? 1 : 0.7 + + Behavior on opacity { + Anim {} + } + + Behavior on scale { + Anim {} + } + + ParallelAnimation { + running: root.isClosing + onFinished: { + if (root.isClosing) { + root.session.network.showPasswordDialog = false; + root.isClosing = false; + } + } + + Anim { + target: dialog + property: "opacity" + to: 0 + } + Anim { + target: dialog + property: "scale" + to: 0.7 + } + } + + Keys.onEscapePressed: closeDialog() + + ColumnLayout { + id: content + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Appearance.padding.large + + spacing: Appearance.spacing.normal + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + text: "lock" + font.pointSize: Appearance.font.size.extraLarge * 2 + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: qsTr("Enter password") + font.pointSize: Appearance.font.size.large + font.weight: 500 + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: root.network ? qsTr("Network: %1").arg(root.network.ssid) : "" + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.small + } + + StyledText { + id: statusText + + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: Appearance.spacing.small + visible: connectButton.connecting || connectButton.hasError + text: { + if (connectButton.hasError) { + return qsTr("Connection failed. Please check your password and try again."); + } + if (connectButton.connecting) { + return qsTr("Connecting..."); + } + return ""; + } + color: connectButton.hasError ? Colours.palette.m3error : Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small + font.weight: 400 + wrapMode: Text.WordWrap + Layout.maximumWidth: parent.width - Appearance.padding.large * 2 + } + + Item { + id: passwordContainer + Layout.topMargin: Appearance.spacing.large + Layout.fillWidth: true + implicitHeight: Math.max(48, charList.implicitHeight + Appearance.padding.normal * 2) + + focus: true + Keys.onPressed: event => { + if (!activeFocus) { + forceActiveFocus(); + } + + if (connectButton.hasError && event.text && event.text.length > 0) { + connectButton.hasError = false; + } + + if (event.key === Qt.Key_Enter || event.key === Qt.Key_Return) { + if (connectButton.enabled) { + connectButton.clicked(); + } + event.accepted = true; + } else if (event.key === Qt.Key_Backspace) { + if (event.modifiers & Qt.ControlModifier) { + passwordBuffer = ""; + } else { + passwordBuffer = passwordBuffer.slice(0, -1); + } + event.accepted = true; + } else if (event.text && event.text.length > 0) { + passwordBuffer += event.text; + event.accepted = true; + } + } + + property string passwordBuffer: "" + + Connections { + target: root.session.network + function onShowPasswordDialogChanged(): void { + if (root.session.network.showPasswordDialog) { + Qt.callLater(() => { + passwordContainer.forceActiveFocus(); + passwordContainer.passwordBuffer = ""; + connectButton.hasError = false; + }); + } + } + } + + Connections { + target: root + function onVisibleChanged(): void { + if (root.visible) { + Qt.callLater(() => { + passwordContainer.forceActiveFocus(); + }); + } + } + } + + StyledRect { + anchors.fill: parent + radius: Appearance.rounding.normal + color: passwordContainer.activeFocus ? Qt.lighter(Colours.tPalette.m3surfaceContainer, 1.05) : Colours.tPalette.m3surfaceContainer + border.width: passwordContainer.activeFocus || connectButton.hasError ? 4 : (root.visible ? 1 : 0) + border.color: { + if (connectButton.hasError) { + return Colours.palette.m3error; + } + if (passwordContainer.activeFocus) { + return Colours.palette.m3primary; + } + return root.visible ? Colours.palette.m3outline : "transparent"; + } + + Behavior on border.color { + CAnim {} + } + + Behavior on border.width { + CAnim {} + } + + Behavior on color { + CAnim {} + } + } + + StateLayer { + hoverEnabled: false + cursorShape: Qt.IBeamCursor + + function onClicked(): void { + passwordContainer.forceActiveFocus(); + } + } + + StyledText { + id: placeholder + anchors.centerIn: parent + text: qsTr("Password") + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.normal + font.family: Appearance.font.family.mono + opacity: passwordContainer.passwordBuffer ? 0 : 1 + + Behavior on opacity { + Anim {} + } + } + + ListView { + id: charList + + readonly property int fullWidth: count * (implicitHeight + spacing) - spacing + + anchors.centerIn: parent + implicitWidth: fullWidth + implicitHeight: Appearance.font.size.normal + + orientation: Qt.Horizontal + spacing: Appearance.spacing.small / 2 + interactive: false + + model: ScriptModel { + values: passwordContainer.passwordBuffer.split("") + } + + delegate: StyledRect { + id: ch + + implicitWidth: implicitHeight + implicitHeight: charList.implicitHeight + + color: Colours.palette.m3onSurface + radius: Appearance.rounding.small / 2 + + opacity: 0 + scale: 0 + Component.onCompleted: { + opacity = 1; + scale = 1; + } + ListView.onRemove: removeAnim.start() + + SequentialAnimation { + id: removeAnim + + PropertyAction { + target: ch + property: "ListView.delayRemove" + value: true + } + ParallelAnimation { + Anim { + target: ch + property: "opacity" + to: 0 + } + Anim { + target: ch + property: "scale" + to: 0.5 + } + } + PropertyAction { + target: ch + property: "ListView.delayRemove" + value: false + } + } + + Behavior on opacity { + Anim {} + } + + Behavior on scale { + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + } + } + } + + Behavior on implicitWidth { + Anim {} + } + } + } + + RowLayout { + Layout.topMargin: Appearance.spacing.normal + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + TextButton { + id: cancelButton + + Layout.fillWidth: true + Layout.minimumHeight: Appearance.font.size.normal + Appearance.padding.normal * 2 + inactiveColour: Colours.palette.m3secondaryContainer + inactiveOnColour: Colours.palette.m3onSecondaryContainer + text: qsTr("Cancel") + + onClicked: root.closeDialog() + } + + TextButton { + id: connectButton + + property bool connecting: false + property bool hasError: false + + Layout.fillWidth: true + Layout.minimumHeight: Appearance.font.size.normal + Appearance.padding.normal * 2 + inactiveColour: Colours.palette.m3primary + inactiveOnColour: Colours.palette.m3onPrimary + text: qsTr("Connect") + enabled: passwordContainer.passwordBuffer.length > 0 && !connecting + + onClicked: { + if (!root.network || connecting) { + return; + } + + const password = passwordContainer.passwordBuffer; + if (!password || password.length === 0) { + return; + } + + hasError = false; + connecting = true; + enabled = false; + text = qsTr("Connecting..."); + + NetworkConnection.connectWithPassword(root.network, password, result => { + if (result && result.success) {} else if (result && result.needsPassword) { + connectionMonitor.stop(); + connecting = false; + hasError = true; + enabled = true; + text = qsTr("Connect"); + passwordContainer.passwordBuffer = ""; + if (root.network && root.network.ssid) { + Nmcli.forgetNetwork(root.network.ssid); + } + } else { + connectionMonitor.stop(); + connecting = false; + hasError = true; + enabled = true; + text = qsTr("Connect"); + passwordContainer.passwordBuffer = ""; + if (root.network && root.network.ssid) { + Nmcli.forgetNetwork(root.network.ssid); + } + } + }); + + connectionMonitor.start(); + } + } + } + } + } + + function checkConnectionStatus(): void { + if (!root.visible || !connectButton.connecting) { + return; + } + + const isConnected = root.network && Nmcli.active && Nmcli.active.ssid && Nmcli.active.ssid.toLowerCase().trim() === root.network.ssid.toLowerCase().trim(); + + if (isConnected) { + connectionSuccessTimer.start(); + return; + } + + if (Nmcli.pendingConnection === null && connectButton.connecting) { + if (connectionMonitor.repeatCount > 10) { + connectionMonitor.stop(); + connectButton.connecting = false; + connectButton.hasError = true; + connectButton.enabled = true; + connectButton.text = qsTr("Connect"); + passwordContainer.passwordBuffer = ""; + if (root.network && root.network.ssid) { + Nmcli.forgetNetwork(root.network.ssid); + } + } + } + } + + Timer { + id: connectionMonitor + interval: 1000 + repeat: true + triggeredOnStart: false + property int repeatCount: 0 + + onTriggered: { + repeatCount++; + checkConnectionStatus(); + } + + onRunningChanged: { + if (!running) { + repeatCount = 0; + } + } + } + + Timer { + id: connectionSuccessTimer + interval: 500 + onTriggered: { + if (root.visible && Nmcli.active && Nmcli.active.ssid) { + const stillConnected = Nmcli.active.ssid.toLowerCase().trim() === root.network.ssid.toLowerCase().trim(); + if (stillConnected) { + connectionMonitor.stop(); + connectButton.connecting = false; + connectButton.text = qsTr("Connect"); + closeDialog(); + } + } + } + } + + Connections { + target: Nmcli + function onActiveChanged() { + if (root.visible) { + checkConnectionStatus(); + } + } + function onConnectionFailed(ssid: string) { + if (root.visible && root.network && root.network.ssid === ssid && connectButton.connecting) { + connectionMonitor.stop(); + connectButton.connecting = false; + connectButton.hasError = true; + connectButton.enabled = true; + connectButton.text = qsTr("Connect"); + passwordContainer.passwordBuffer = ""; + Nmcli.forgetNetwork(ssid); + } + } + } + + function closeDialog(): void { + if (isClosing) { + return; + } + + isClosing = true; + passwordContainer.passwordBuffer = ""; + connectButton.connecting = false; + connectButton.hasError = false; + connectButton.text = qsTr("Connect"); + connectionMonitor.stop(); + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/network/WirelessSettings.qml b/.config/quickshell/caelestia/modules/controlcenter/network/WirelessSettings.qml new file mode 100644 index 0000000..b4eb391 --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/network/WirelessSettings.qml @@ -0,0 +1,73 @@ +pragma ComponentBehavior: Bound + +import ".." +import "../components" +import qs.components +import qs.components.controls +import qs.components.effects +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +ColumnLayout { + id: root + + required property Session session + + spacing: Appearance.spacing.normal + + SettingsHeader { + icon: "wifi" + title: qsTr("Network settings") + } + + SectionHeader { + Layout.topMargin: Appearance.spacing.large + title: qsTr("WiFi status") + description: qsTr("General WiFi settings") + } + + SectionContainer { + ToggleRow { + label: qsTr("WiFi enabled") + checked: Nmcli.wifiEnabled + toggle.onToggled: { + Nmcli.enableWifi(checked); + } + } + } + + SectionHeader { + Layout.topMargin: Appearance.spacing.large + title: qsTr("Network information") + description: qsTr("Current network connection") + } + + SectionContainer { + contentSpacing: Appearance.spacing.small / 2 + + PropertyRow { + label: qsTr("Connected network") + value: Nmcli.active ? Nmcli.active.ssid : qsTr("Not connected") + } + + PropertyRow { + showTopMargin: true + label: qsTr("Signal strength") + value: Nmcli.active ? qsTr("%1%").arg(Nmcli.active.strength) : qsTr("N/A") + } + + PropertyRow { + showTopMargin: true + label: qsTr("Security") + value: Nmcli.active ? (Nmcli.active.isSecure ? qsTr("Secured") : qsTr("Open")) : qsTr("N/A") + } + + PropertyRow { + showTopMargin: true + label: qsTr("Frequency") + value: Nmcli.active ? qsTr("%1 MHz").arg(Nmcli.active.frequency) : qsTr("N/A") + } + } +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/state/BluetoothState.qml b/.config/quickshell/caelestia/modules/controlcenter/state/BluetoothState.qml new file mode 100644 index 0000000..8678672 --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/state/BluetoothState.qml @@ -0,0 +1,12 @@ +import Quickshell.Bluetooth +import QtQuick + +QtObject { + id: root + + property BluetoothDevice active: null + property BluetoothAdapter currentAdapter: Bluetooth.defaultAdapter + property bool editingAdapterName: false + property bool fabMenuOpen: false + property bool editingDeviceName: false +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/state/EthernetState.qml b/.config/quickshell/caelestia/modules/controlcenter/state/EthernetState.qml new file mode 100644 index 0000000..58f5fc8 --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/state/EthernetState.qml @@ -0,0 +1,7 @@ +import QtQuick + +QtObject { + id: root + + property var active: null +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/state/LauncherState.qml b/.config/quickshell/caelestia/modules/controlcenter/state/LauncherState.qml new file mode 100644 index 0000000..58f5fc8 --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/state/LauncherState.qml @@ -0,0 +1,7 @@ +import QtQuick + +QtObject { + id: root + + property var active: null +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/state/NetworkState.qml b/.config/quickshell/caelestia/modules/controlcenter/state/NetworkState.qml new file mode 100644 index 0000000..f9324c8 --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/state/NetworkState.qml @@ -0,0 +1,9 @@ +import QtQuick + +QtObject { + id: root + + property var active: null + property bool showPasswordDialog: false + property var pendingNetwork: null +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/state/VpnState.qml b/.config/quickshell/caelestia/modules/controlcenter/state/VpnState.qml new file mode 100644 index 0000000..aa911f1 --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/state/VpnState.qml @@ -0,0 +1,5 @@ +import QtQuick + +QtObject { + property var active: null +} diff --git a/.config/quickshell/caelestia/modules/controlcenter/taskbar/TaskbarPane.qml b/.config/quickshell/caelestia/modules/controlcenter/taskbar/TaskbarPane.qml new file mode 100644 index 0000000..d12d174 --- /dev/null +++ b/.config/quickshell/caelestia/modules/controlcenter/taskbar/TaskbarPane.qml @@ -0,0 +1,648 @@ +pragma ComponentBehavior: Bound + +import ".." +import "../components" +import qs.components +import qs.components.controls +import qs.components.effects +import qs.components.containers +import qs.services +import qs.config +import qs.utils +import Quickshell +import Quickshell.Widgets +import QtQuick +import QtQuick.Layouts + +Item { + id: root + + required property Session session + + property bool clockShowIcon: Config.bar.clock.showIcon ?? true + property bool persistent: Config.bar.persistent ?? true + property bool showOnHover: Config.bar.showOnHover ?? true + property int dragThreshold: Config.bar.dragThreshold ?? 20 + property bool showAudio: Config.bar.status.showAudio ?? true + property bool showMicrophone: Config.bar.status.showMicrophone ?? true + property bool showKbLayout: Config.bar.status.showKbLayout ?? false + property bool showNetwork: Config.bar.status.showNetwork ?? true + property bool showWifi: Config.bar.status.showWifi ?? true + property bool showBluetooth: Config.bar.status.showBluetooth ?? true + property bool showBattery: Config.bar.status.showBattery ?? true + property bool showLockStatus: Config.bar.status.showLockStatus ?? true + property bool trayBackground: Config.bar.tray.background ?? false + property bool trayCompact: Config.bar.tray.compact ?? false + property bool trayRecolour: Config.bar.tray.recolour ?? false + property int workspacesShown: Config.bar.workspaces.shown ?? 5 + property bool workspacesActiveIndicator: Config.bar.workspaces.activeIndicator ?? true + property bool workspacesOccupiedBg: Config.bar.workspaces.occupiedBg ?? false + property bool workspacesShowWindows: Config.bar.workspaces.showWindows ?? false + property bool workspacesPerMonitor: Config.bar.workspaces.perMonitorWorkspaces ?? true + property bool scrollWorkspaces: Config.bar.scrollActions.workspaces ?? true + property bool scrollVolume: Config.bar.scrollActions.volume ?? true + property bool scrollBrightness: Config.bar.scrollActions.brightness ?? true + property bool popoutActiveWindow: Config.bar.popouts.activeWindow ?? true + property bool popoutTray: Config.bar.popouts.tray ?? true + property bool popoutStatusIcons: Config.bar.popouts.statusIcons ?? true + + anchors.fill: parent + + Component.onCompleted: { + if (Config.bar.entries) { + entriesModel.clear(); + for (let i = 0; i < Config.bar.entries.length; i++) { + const entry = Config.bar.entries[i]; + entriesModel.append({ + id: entry.id, + enabled: entry.enabled !== false + }); + } + } + } + + function saveConfig(entryIndex, entryEnabled) { + Config.bar.clock.showIcon = root.clockShowIcon; + Config.bar.persistent = root.persistent; + Config.bar.showOnHover = root.showOnHover; + Config.bar.dragThreshold = root.dragThreshold; + Config.bar.status.showAudio = root.showAudio; + Config.bar.status.showMicrophone = root.showMicrophone; + Config.bar.status.showKbLayout = root.showKbLayout; + Config.bar.status.showNetwork = root.showNetwork; + Config.bar.status.showWifi = root.showWifi; + Config.bar.status.showBluetooth = root.showBluetooth; + Config.bar.status.showBattery = root.showBattery; + Config.bar.status.showLockStatus = root.showLockStatus; + Config.bar.tray.background = root.trayBackground; + Config.bar.tray.compact = root.trayCompact; + Config.bar.tray.recolour = root.trayRecolour; + Config.bar.workspaces.shown = root.workspacesShown; + Config.bar.workspaces.activeIndicator = root.workspacesActiveIndicator; + Config.bar.workspaces.occupiedBg = root.workspacesOccupiedBg; + Config.bar.workspaces.showWindows = root.workspacesShowWindows; + Config.bar.workspaces.perMonitorWorkspaces = root.workspacesPerMonitor; + Config.bar.scrollActions.workspaces = root.scrollWorkspaces; + Config.bar.scrollActions.volume = root.scrollVolume; + Config.bar.scrollActions.brightness = root.scrollBrightness; + Config.bar.popouts.activeWindow = root.popoutActiveWindow; + Config.bar.popouts.tray = root.popoutTray; + Config.bar.popouts.statusIcons = root.popoutStatusIcons; + + const entries = []; + for (let i = 0; i < entriesModel.count; i++) { + const entry = entriesModel.get(i); + let enabled = entry.enabled; + if (entryIndex !== undefined && i === entryIndex) { + enabled = entryEnabled; + } + entries.push({ + id: entry.id, + enabled: enabled + }); + } + Config.bar.entries = entries; + Config.save(); + } + + ListModel { + id: entriesModel + } + + ClippingRectangle { + id: taskbarClippingRect + anchors.fill: parent + anchors.margins: Appearance.padding.normal + anchors.leftMargin: 0 + anchors.rightMargin: Appearance.padding.normal + + radius: taskbarBorder.innerRadius + color: "transparent" + + Loader { + id: taskbarLoader + + anchors.fill: parent + anchors.margins: Appearance.padding.large + Appearance.padding.normal + anchors.leftMargin: Appearance.padding.large + anchors.rightMargin: Appearance.padding.large + + sourceComponent: taskbarContentComponent + } + } + + InnerBorder { + id: taskbarBorder + leftThickness: 0 + rightThickness: Appearance.padding.normal + } + + Component { + id: taskbarContentComponent + + StyledFlickable { + id: sidebarFlickable + flickableDirection: Flickable.VerticalFlick + contentHeight: sidebarLayout.height + + StyledScrollBar.vertical: StyledScrollBar { + flickable: sidebarFlickable + } + + ColumnLayout { + id: sidebarLayout + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + + spacing: Appearance.spacing.normal + + RowLayout { + spacing: Appearance.spacing.smaller + + StyledText { + text: qsTr("Taskbar") + font.pointSize: Appearance.font.size.large + font.weight: 500 + } + } + + SectionContainer { + Layout.fillWidth: true + alignTop: true + + StyledText { + text: qsTr("Status Icons") + font.pointSize: Appearance.font.size.normal + } + + ConnectedButtonGroup { + rootItem: root + + options: [ + { + label: qsTr("Speakers"), + propertyName: "showAudio", + onToggled: function (checked) { + root.showAudio = checked; + root.saveConfig(); + } + }, + { + label: qsTr("Microphone"), + propertyName: "showMicrophone", + onToggled: function (checked) { + root.showMicrophone = checked; + root.saveConfig(); + } + }, + { + label: qsTr("Keyboard"), + propertyName: "showKbLayout", + onToggled: function (checked) { + root.showKbLayout = checked; + root.saveConfig(); + } + }, + { + label: qsTr("Network"), + propertyName: "showNetwork", + onToggled: function (checked) { + root.showNetwork = checked; + root.saveConfig(); + } + }, + { + label: qsTr("Wifi"), + propertyName: "showWifi", + onToggled: function (checked) { + root.showWifi = checked; + root.saveConfig(); + } + }, + { + label: qsTr("Bluetooth"), + propertyName: "showBluetooth", + onToggled: function (checked) { + root.showBluetooth = checked; + root.saveConfig(); + } + }, + { + label: qsTr("Battery"), + propertyName: "showBattery", + onToggled: function (checked) { + root.showBattery = checked; + root.saveConfig(); + } + }, + { + label: qsTr("Capslock"), + propertyName: "showLockStatus", + onToggled: function (checked) { + root.showLockStatus = checked; + root.saveConfig(); + } + } + ] + } + } + + RowLayout { + id: mainRowLayout + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + ColumnLayout { + id: leftColumnLayout + Layout.fillWidth: true + Layout.alignment: Qt.AlignTop + spacing: Appearance.spacing.normal + + SectionContainer { + Layout.fillWidth: true + alignTop: true + + StyledText { + text: qsTr("Workspaces") + font.pointSize: Appearance.font.size.normal + } + + StyledRect { + Layout.fillWidth: true + implicitHeight: workspacesShownRow.implicitHeight + Appearance.padding.large * 2 + radius: Appearance.rounding.normal + color: Colours.layer(Colours.palette.m3surfaceContainer, 2) + + Behavior on implicitHeight { + Anim {} + } + + RowLayout { + id: workspacesShownRow + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Appearance.padding.large + spacing: Appearance.spacing.normal + + StyledText { + Layout.fillWidth: true + text: qsTr("Shown") + } + + CustomSpinBox { + min: 1 + max: 20 + value: root.workspacesShown + onValueModified: value => { + root.workspacesShown = value; + root.saveConfig(); + } + } + } + } + + StyledRect { + Layout.fillWidth: true + implicitHeight: workspacesActiveIndicatorRow.implicitHeight + Appearance.padding.large * 2 + radius: Appearance.rounding.normal + color: Colours.layer(Colours.palette.m3surfaceContainer, 2) + + Behavior on implicitHeight { + Anim {} + } + + RowLayout { + id: workspacesActiveIndicatorRow + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Appearance.padding.large + spacing: Appearance.spacing.normal + + StyledText { + Layout.fillWidth: true + text: qsTr("Active indicator") + } + + StyledSwitch { + checked: root.workspacesActiveIndicator + onToggled: { + root.workspacesActiveIndicator = checked; + root.saveConfig(); + } + } + } + } + + StyledRect { + Layout.fillWidth: true + implicitHeight: workspacesOccupiedBgRow.implicitHeight + Appearance.padding.large * 2 + radius: Appearance.rounding.normal + color: Colours.layer(Colours.palette.m3surfaceContainer, 2) + + Behavior on implicitHeight { + Anim {} + } + + RowLayout { + id: workspacesOccupiedBgRow + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Appearance.padding.large + spacing: Appearance.spacing.normal + + StyledText { + Layout.fillWidth: true + text: qsTr("Occupied background") + } + + StyledSwitch { + checked: root.workspacesOccupiedBg + onToggled: { + root.workspacesOccupiedBg = checked; + root.saveConfig(); + } + } + } + } + + StyledRect { + Layout.fillWidth: true + implicitHeight: workspacesShowWindowsRow.implicitHeight + Appearance.padding.large * 2 + radius: Appearance.rounding.normal + color: Colours.layer(Colours.palette.m3surfaceContainer, 2) + + Behavior on implicitHeight { + Anim {} + } + + RowLayout { + id: workspacesShowWindowsRow + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Appearance.padding.large + spacing: Appearance.spacing.normal + + StyledText { + Layout.fillWidth: true + text: qsTr("Show windows") + } + + StyledSwitch { + checked: root.workspacesShowWindows + onToggled: { + root.workspacesShowWindows = checked; + root.saveConfig(); + } + } + } + } + + StyledRect { + Layout.fillWidth: true + implicitHeight: workspacesPerMonitorRow.implicitHeight + Appearance.padding.large * 2 + radius: Appearance.rounding.normal + color: Colours.layer(Colours.palette.m3surfaceContainer, 2) + + Behavior on implicitHeight { + Anim {} + } + + RowLayout { + id: workspacesPerMonitorRow + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Appearance.padding.large + spacing: Appearance.spacing.normal + + StyledText { + Layout.fillWidth: true + text: qsTr("Per monitor workspaces") + } + + StyledSwitch { + checked: root.workspacesPerMonitor + onToggled: { + root.workspacesPerMonitor = checked; + root.saveConfig(); + } + } + } + } + } + + SectionContainer { + Layout.fillWidth: true + alignTop: true + + StyledText { + text: qsTr("Scroll Actions") + font.pointSize: Appearance.font.size.normal + } + + ConnectedButtonGroup { + rootItem: root + + options: [ + { + label: qsTr("Workspaces"), + propertyName: "scrollWorkspaces", + onToggled: function (checked) { + root.scrollWorkspaces = checked; + root.saveConfig(); + } + }, + { + label: qsTr("Volume"), + propertyName: "scrollVolume", + onToggled: function (checked) { + root.scrollVolume = checked; + root.saveConfig(); + } + }, + { + label: qsTr("Brightness"), + propertyName: "scrollBrightness", + onToggled: function (checked) { + root.scrollBrightness = checked; + root.saveConfig(); + } + } + ] + } + } + } + + ColumnLayout { + id: middleColumnLayout + Layout.fillWidth: true + Layout.alignment: Qt.AlignTop + spacing: Appearance.spacing.normal + + SectionContainer { + Layout.fillWidth: true + alignTop: true + + StyledText { + text: qsTr("Clock") + font.pointSize: Appearance.font.size.normal + } + + SwitchRow { + label: qsTr("Show clock icon") + checked: root.clockShowIcon + onToggled: checked => { + root.clockShowIcon = checked; + root.saveConfig(); + } + } + } + + SectionContainer { + Layout.fillWidth: true + alignTop: true + + StyledText { + text: qsTr("Bar Behavior") + font.pointSize: Appearance.font.size.normal + } + + SwitchRow { + label: qsTr("Persistent") + checked: root.persistent + onToggled: checked => { + root.persistent = checked; + root.saveConfig(); + } + } + + SwitchRow { + label: qsTr("Show on hover") + checked: root.showOnHover + onToggled: checked => { + root.showOnHover = checked; + root.saveConfig(); + } + } + + SectionContainer { + contentSpacing: Appearance.spacing.normal + + SliderInput { + Layout.fillWidth: true + + label: qsTr("Drag threshold") + value: root.dragThreshold + from: 0 + to: 100 + suffix: "px" + validator: IntValidator { + bottom: 0 + top: 100 + } + formatValueFunction: val => Math.round(val).toString() + parseValueFunction: text => parseInt(text) + + onValueModified: newValue => { + root.dragThreshold = Math.round(newValue); + root.saveConfig(); + } + } + } + } + } + + ColumnLayout { + id: rightColumnLayout + Layout.fillWidth: true + Layout.alignment: Qt.AlignTop + spacing: Appearance.spacing.normal + + SectionContainer { + Layout.fillWidth: true + alignTop: true + + StyledText { + text: qsTr("Popouts") + font.pointSize: Appearance.font.size.normal + } + + SwitchRow { + label: qsTr("Active window") + checked: root.popoutActiveWindow + onToggled: checked => { + root.popoutActiveWindow = checked; + root.saveConfig(); + } + } + + SwitchRow { + label: qsTr("Tray") + checked: root.popoutTray + onToggled: checked => { + root.popoutTray = checked; + root.saveConfig(); + } + } + + SwitchRow { + label: qsTr("Status icons") + checked: root.popoutStatusIcons + onToggled: checked => { + root.popoutStatusIcons = checked; + root.saveConfig(); + } + } + } + + SectionContainer { + Layout.fillWidth: true + alignTop: true + + StyledText { + text: qsTr("Tray Settings") + font.pointSize: Appearance.font.size.normal + } + + ConnectedButtonGroup { + rootItem: root + + options: [ + { + label: qsTr("Background"), + propertyName: "trayBackground", + onToggled: function (checked) { + root.trayBackground = checked; + root.saveConfig(); + } + }, + { + label: qsTr("Compact"), + propertyName: "trayCompact", + onToggled: function (checked) { + root.trayCompact = checked; + root.saveConfig(); + } + }, + { + label: qsTr("Recolour"), + propertyName: "trayRecolour", + onToggled: function (checked) { + root.trayRecolour = checked; + root.saveConfig(); + } + } + ] + } + } + } + } + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/dashboard/Background.qml b/.config/quickshell/caelestia/modules/dashboard/Background.qml new file mode 100644 index 0000000..e2a91f7 --- /dev/null +++ b/.config/quickshell/caelestia/modules/dashboard/Background.qml @@ -0,0 +1,66 @@ +import qs.components +import qs.services +import qs.config +import QtQuick +import QtQuick.Shapes + +ShapePath { + id: root + + required property Wrapper wrapper + readonly property real rounding: Config.border.rounding + readonly property bool flatten: wrapper.height < rounding * 2 + readonly property real roundingY: flatten ? wrapper.height / 2 : rounding + + strokeWidth: -1 + fillColor: Colours.palette.m3surface + + PathArc { + relativeX: root.rounding + relativeY: root.roundingY + radiusX: root.rounding + radiusY: Math.min(root.rounding, root.wrapper.height) + } + + PathLine { + relativeX: 0 + relativeY: root.wrapper.height - root.roundingY * 2 + } + + PathArc { + relativeX: root.rounding + relativeY: root.roundingY + radiusX: root.rounding + radiusY: Math.min(root.rounding, root.wrapper.height) + direction: PathArc.Counterclockwise + } + + PathLine { + relativeX: root.wrapper.width - root.rounding * 2 + relativeY: 0 + } + + PathArc { + relativeX: root.rounding + relativeY: -root.roundingY + radiusX: root.rounding + radiusY: Math.min(root.rounding, root.wrapper.height) + direction: PathArc.Counterclockwise + } + + PathLine { + relativeX: 0 + relativeY: -(root.wrapper.height - root.roundingY * 2) + } + + PathArc { + relativeX: root.rounding + relativeY: -root.roundingY + radiusX: root.rounding + radiusY: Math.min(root.rounding, root.wrapper.height) + } + + Behavior on fillColor { + CAnim {} + } +} diff --git a/.config/quickshell/caelestia/modules/dashboard/Content.qml b/.config/quickshell/caelestia/modules/dashboard/Content.qml new file mode 100644 index 0000000..1cc960a --- /dev/null +++ b/.config/quickshell/caelestia/modules/dashboard/Content.qml @@ -0,0 +1,152 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.components.filedialog +import qs.config +import Quickshell +import Quickshell.Widgets +import QtQuick +import QtQuick.Layouts + +Item { + id: root + + required property PersistentProperties visibilities + required property PersistentProperties state + required property FileDialog facePicker + readonly property real nonAnimWidth: view.implicitWidth + viewWrapper.anchors.margins * 2 + readonly property real nonAnimHeight: tabs.implicitHeight + tabs.anchors.topMargin + view.implicitHeight + viewWrapper.anchors.margins * 2 + + implicitWidth: nonAnimWidth + implicitHeight: nonAnimHeight + + Tabs { + id: tabs + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: Appearance.padding.normal + anchors.margins: Appearance.padding.large + + nonAnimWidth: root.nonAnimWidth - anchors.margins * 2 + state: root.state + } + + ClippingRectangle { + id: viewWrapper + + anchors.top: tabs.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: Appearance.padding.large + + radius: Appearance.rounding.normal + color: "transparent" + + Flickable { + id: view + + readonly property int currentIndex: root.state.currentTab + readonly property Item currentItem: row.children[currentIndex] + + anchors.fill: parent + + flickableDirection: Flickable.HorizontalFlick + + implicitWidth: currentItem.implicitWidth + implicitHeight: currentItem.implicitHeight + + contentX: currentItem.x + contentWidth: row.implicitWidth + contentHeight: row.implicitHeight + + onContentXChanged: { + if (!moving) + return; + + const x = contentX - currentItem.x; + if (x > currentItem.implicitWidth / 2) + root.state.currentTab = Math.min(root.state.currentTab + 1, tabs.count - 1); + else if (x < -currentItem.implicitWidth / 2) + root.state.currentTab = Math.max(root.state.currentTab - 1, 0); + } + + onDragEnded: { + const x = contentX - currentItem.x; + if (x > currentItem.implicitWidth / 10) + root.state.currentTab = Math.min(root.state.currentTab + 1, tabs.count - 1); + else if (x < -currentItem.implicitWidth / 10) + root.state.currentTab = Math.max(root.state.currentTab - 1, 0); + else + contentX = Qt.binding(() => currentItem.x); + } + + RowLayout { + id: row + + Pane { + index: 0 + sourceComponent: Dash { + visibilities: root.visibilities + state: root.state + facePicker: root.facePicker + } + } + + Pane { + index: 1 + sourceComponent: Media { + visibilities: root.visibilities + } + } + + Pane { + index: 2 + sourceComponent: Performance {} + } + + Pane { + index: 3 + sourceComponent: Weather {} + } + } + + Behavior on contentX { + Anim {} + } + } + } + + Behavior on implicitWidth { + Anim { + duration: Appearance.anim.durations.large + easing.bezierCurve: Appearance.anim.curves.emphasized + } + } + + Behavior on implicitHeight { + Anim { + duration: Appearance.anim.durations.large + easing.bezierCurve: Appearance.anim.curves.emphasized + } + } + + component Pane: Loader { + id: pane + + required property int index + + Layout.alignment: Qt.AlignTop + + Component.onCompleted: active = Qt.binding(() => { + // Always keep current tab loaded + if (pane.index === view.currentIndex) + return true; + const vx = Math.floor(view.visibleArea.xPosition * view.contentWidth); + const vex = Math.floor(vx + view.visibleArea.widthRatio * view.contentWidth); + return (vx >= x && vx <= x + implicitWidth) || (vex >= x && vex <= x + implicitWidth); + }) + } +} diff --git a/.config/quickshell/caelestia/modules/dashboard/Dash.qml b/.config/quickshell/caelestia/modules/dashboard/Dash.qml new file mode 100644 index 0000000..71e224f --- /dev/null +++ b/.config/quickshell/caelestia/modules/dashboard/Dash.qml @@ -0,0 +1,105 @@ +import qs.components +import qs.components.filedialog +import qs.services +import qs.config +import "dash" +import Quickshell +import QtQuick.Layouts + +GridLayout { + id: root + + required property PersistentProperties visibilities + required property PersistentProperties state + required property FileDialog facePicker + + rowSpacing: Appearance.spacing.normal + columnSpacing: Appearance.spacing.normal + + Rect { + Layout.column: 2 + Layout.columnSpan: 3 + Layout.preferredWidth: user.implicitWidth + Layout.preferredHeight: user.implicitHeight + + radius: Appearance.rounding.large + + User { + id: user + + visibilities: root.visibilities + state: root.state + facePicker: root.facePicker + } + } + + Rect { + Layout.row: 0 + Layout.columnSpan: 2 + Layout.preferredWidth: Config.dashboard.sizes.weatherWidth + Layout.fillHeight: true + + radius: Appearance.rounding.large * 1.5 + + Weather {} + } + + Rect { + Layout.row: 1 + Layout.preferredWidth: dateTime.implicitWidth + Layout.fillHeight: true + + radius: Appearance.rounding.normal + + DateTime { + id: dateTime + } + } + + Rect { + Layout.row: 1 + Layout.column: 1 + Layout.columnSpan: 3 + Layout.fillWidth: true + Layout.preferredHeight: calendar.implicitHeight + + radius: Appearance.rounding.large + + Calendar { + id: calendar + + state: root.state + } + } + + Rect { + Layout.row: 1 + Layout.column: 4 + Layout.preferredWidth: resources.implicitWidth + Layout.fillHeight: true + + radius: Appearance.rounding.normal + + Resources { + id: resources + } + } + + Rect { + Layout.row: 0 + Layout.column: 5 + Layout.rowSpan: 2 + Layout.preferredWidth: media.implicitWidth + Layout.fillHeight: true + + radius: Appearance.rounding.large * 2 + + Media { + id: media + } + } + + component Rect: StyledRect { + color: Colours.tPalette.m3surfaceContainer + } +} diff --git a/.config/quickshell/caelestia/modules/dashboard/Media.qml b/.config/quickshell/caelestia/modules/dashboard/Media.qml new file mode 100644 index 0000000..722bc93 --- /dev/null +++ b/.config/quickshell/caelestia/modules/dashboard/Media.qml @@ -0,0 +1,403 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.components.controls +import qs.services +import qs.utils +import qs.config +import Caelestia.Services +import Quickshell +import Quickshell.Services.Mpris +import QtQuick +import QtQuick.Layouts +import QtQuick.Shapes + +Item { + id: root + + required property PersistentProperties visibilities + + property real playerProgress: { + const active = Players.active; + return active?.length ? active.position / active.length : 0; + } + + function lengthStr(length: int): string { + if (length < 0) + return "-1:-1"; + + const hours = Math.floor(length / 3600); + const mins = Math.floor((length % 3600) / 60); + const secs = Math.floor(length % 60).toString().padStart(2, "0"); + + if (hours > 0) + return `${hours}:${mins.toString().padStart(2, "0")}:${secs}`; + return `${mins}:${secs}`; + } + + implicitWidth: cover.implicitWidth + Config.dashboard.sizes.mediaVisualiserSize * 2 + details.implicitWidth + details.anchors.leftMargin + bongocat.implicitWidth + bongocat.anchors.leftMargin * 2 + Appearance.padding.large * 2 + implicitHeight: Math.max(cover.implicitHeight + Config.dashboard.sizes.mediaVisualiserSize * 2, details.implicitHeight, bongocat.implicitHeight) + Appearance.padding.large * 2 + + Behavior on playerProgress { + Anim { + duration: Appearance.anim.durations.large + } + } + + Timer { + running: Players.active?.isPlaying ?? false + interval: Config.dashboard.mediaUpdateInterval + triggeredOnStart: true + repeat: true + onTriggered: Players.active?.positionChanged() + } + + ServiceRef { + service: Audio.cava + } + + ServiceRef { + service: Audio.beatTracker + } + + Shape { + id: visualiser + + readonly property real centerX: width / 2 + readonly property real centerY: height / 2 + readonly property real innerX: cover.implicitWidth / 2 + Appearance.spacing.small + readonly property real innerY: cover.implicitHeight / 2 + Appearance.spacing.small + property color colour: Colours.palette.m3primary + + anchors.fill: cover + anchors.margins: -Config.dashboard.sizes.mediaVisualiserSize + + asynchronous: true + preferredRendererType: Shape.CurveRenderer + data: visualiserBars.instances + } + + Variants { + id: visualiserBars + + model: Array.from({ + length: Config.services.visualiserBars + }, (_, i) => i) + + ShapePath { + id: visualiserBar + + required property int modelData + readonly property real value: Math.max(1e-3, Math.min(1, Audio.cava.values[modelData])) + + readonly property real angle: modelData * 2 * Math.PI / Config.services.visualiserBars + readonly property real magnitude: value * Config.dashboard.sizes.mediaVisualiserSize + readonly property real cos: Math.cos(angle) + readonly property real sin: Math.sin(angle) + + capStyle: Appearance.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap + strokeWidth: 360 / Config.services.visualiserBars - Appearance.spacing.small / 4 + strokeColor: Colours.palette.m3primary + + startX: visualiser.centerX + (visualiser.innerX + strokeWidth / 2) * cos + startY: visualiser.centerY + (visualiser.innerY + strokeWidth / 2) * sin + + PathLine { + x: visualiser.centerX + (visualiser.innerX + visualiserBar.strokeWidth / 2 + visualiserBar.magnitude) * visualiserBar.cos + y: visualiser.centerY + (visualiser.innerY + visualiserBar.strokeWidth / 2 + visualiserBar.magnitude) * visualiserBar.sin + } + + Behavior on strokeColor { + CAnim {} + } + } + } + + StyledClippingRect { + id: cover + + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: Appearance.padding.large + Config.dashboard.sizes.mediaVisualiserSize + + implicitWidth: Config.dashboard.sizes.mediaCoverArtSize + implicitHeight: Config.dashboard.sizes.mediaCoverArtSize + + color: Colours.tPalette.m3surfaceContainerHigh + radius: Infinity + + MaterialIcon { + anchors.centerIn: parent + + grade: 200 + text: "art_track" + color: Colours.palette.m3onSurfaceVariant + font.pointSize: (parent.width * 0.4) || 1 + } + + Image { + id: image + + anchors.fill: parent + + source: Players.active?.trackArtUrl ?? "" // qmllint disable incompatible-type + asynchronous: true + fillMode: Image.PreserveAspectCrop + sourceSize.width: width + sourceSize.height: height + } + } + + ColumnLayout { + id: details + + anchors.verticalCenter: parent.verticalCenter + anchors.left: visualiser.right + anchors.leftMargin: Appearance.spacing.normal + + spacing: Appearance.spacing.small + + StyledText { + id: title + + Layout.fillWidth: true + Layout.maximumWidth: parent.implicitWidth + + animate: true + horizontalAlignment: Text.AlignHCenter + text: (Players.active?.trackTitle ?? qsTr("No media")) || qsTr("Unknown title") + color: Players.active ? Colours.palette.m3primary : Colours.palette.m3onSurface + font.pointSize: Appearance.font.size.normal + elide: Text.ElideRight + } + + StyledText { + id: album + + Layout.fillWidth: true + Layout.maximumWidth: parent.implicitWidth + + animate: true + horizontalAlignment: Text.AlignHCenter + visible: !!Players.active + text: Players.active?.trackAlbum || qsTr("Unknown album") + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.small + elide: Text.ElideRight + } + + StyledText { + id: artist + + Layout.fillWidth: true + Layout.maximumWidth: parent.implicitWidth + + animate: true + horizontalAlignment: Text.AlignHCenter + text: (Players.active?.trackArtist ?? qsTr("Play some music for stuff to show up here!")) || qsTr("Unknown artist") + color: Players.active ? Colours.palette.m3secondary : Colours.palette.m3outline + elide: Text.ElideRight + wrapMode: Players.active ? Text.NoWrap : Text.WordWrap + } + + RowLayout { + id: controls + + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: Appearance.spacing.small + Layout.bottomMargin: Appearance.spacing.smaller + + spacing: Appearance.spacing.small + + PlayerControl { + type: IconButton.Text + icon: "skip_previous" + font.pointSize: Math.round(Appearance.font.size.large * 1.5) + disabled: !Players.active?.canGoPrevious + onClicked: Players.active?.previous() + } + + PlayerControl { + icon: Players.active?.isPlaying ? "pause" : "play_arrow" + label.animate: true + toggle: true + padding: Appearance.padding.small / 2 + checked: Players.active?.isPlaying ?? false + font.pointSize: Math.round(Appearance.font.size.large * 1.5) + disabled: !Players.active?.canTogglePlaying + onClicked: Players.active?.togglePlaying() + } + + PlayerControl { + type: IconButton.Text + icon: "skip_next" + font.pointSize: Math.round(Appearance.font.size.large * 1.5) + disabled: !Players.active?.canGoNext + onClicked: Players.active?.next() + } + } + + StyledSlider { + id: slider + + enabled: !!Players.active + implicitWidth: 280 + implicitHeight: Appearance.padding.normal * 3 + + onMoved: { + const active = Players.active; + if (active?.canSeek && active?.positionSupported) + active.position = value * active.length; + } + + Binding { + target: slider + property: "value" + value: root.playerProgress + when: !slider.pressed + } + + CustomMouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + + function onWheel(event: WheelEvent) { + const active = Players.active; + if (!active?.canSeek || !active?.positionSupported) + return; + + event.accepted = true; + const delta = event.angleDelta.y > 0 ? 10 : -10; // Time 10 seconds + Qt.callLater(() => { + active.position = Math.max(0, Math.min(active.length, active.position + delta)); + }); + } + } + } + + Item { + Layout.fillWidth: true + implicitHeight: Math.max(position.implicitHeight, length.implicitHeight) + + StyledText { + id: position + + anchors.left: parent.left + + text: root.lengthStr(Players.active?.position ?? -1) + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small + } + + StyledText { + id: length + + anchors.right: parent.right + + text: root.lengthStr(Players.active?.length ?? -1) + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small + } + } + + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: Appearance.spacing.small + + PlayerControl { + type: IconButton.Text + icon: "move_up" + inactiveOnColour: Colours.palette.m3secondary + padding: Appearance.padding.small + font.pointSize: Appearance.font.size.large + disabled: !Players.active?.canRaise + onClicked: { + Players.active?.raise(); + root.visibilities.dashboard = false; + } + } + + SplitButton { + id: playerSelector + + disabled: !Players.list.length + active: menuItems.find(m => m.modelData === Players.active) ?? menuItems[0] ?? null + menu.onItemSelected: item => Players.manualActive = (item as PlayerItem).modelData + + menuItems: playerList.instances + fallbackIcon: "music_off" + fallbackText: qsTr("No players") + + label.Layout.maximumWidth: slider.implicitWidth * 0.28 + label.elide: Text.ElideRight + + stateLayer.disabled: true + menuOnTop: true + + Variants { + id: playerList + + model: Players.list + + PlayerItem {} + } + } + + PlayerControl { + type: IconButton.Text + icon: "delete" + inactiveOnColour: Colours.palette.m3error + padding: Appearance.padding.small + font.pointSize: Appearance.font.size.large + disabled: !Players.active?.canQuit + onClicked: Players.active?.quit() + } + } + } + + Item { + id: bongocat + + anchors.verticalCenter: parent.verticalCenter + anchors.left: details.right + anchors.leftMargin: Appearance.spacing.normal + + implicitWidth: visualiser.width + implicitHeight: visualiser.height + + AnimatedImage { + anchors.centerIn: parent + + width: visualiser.width * 0.75 + height: visualiser.height * 0.75 + + playing: Players.active?.isPlaying ?? false + speed: Audio.beatTracker.bpm / Appearance.anim.mediaGifSpeedAdjustment // qmllint disable unresolved-type + source: Paths.absolutePath(Config.paths.mediaGif) + asynchronous: true + fillMode: AnimatedImage.PreserveAspectFit + } + } + + component PlayerItem: MenuItem { + required property MprisPlayer modelData + + icon: modelData === Players.active ? "check" : "" + text: Players.getIdentity(modelData) + activeIcon: "animated_images" + } + + component PlayerControl: IconButton { + Layout.preferredWidth: implicitWidth + (stateLayer.pressed ? Appearance.padding.large : internalChecked ? Appearance.padding.smaller : 0) + radius: stateLayer.pressed ? Appearance.rounding.small / 2 : internalChecked ? Appearance.rounding.small : implicitHeight / 2 + radiusAnim.duration: Appearance.anim.durations.expressiveFastSpatial + radiusAnim.easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + + Behavior on Layout.preferredWidth { + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/dashboard/Performance.qml b/.config/quickshell/caelestia/modules/dashboard/Performance.qml new file mode 100644 index 0000000..e73d8ed --- /dev/null +++ b/.config/quickshell/caelestia/modules/dashboard/Performance.qml @@ -0,0 +1,967 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell.Services.UPower +import qs.components +import qs.components.misc +import qs.config +import qs.services + +Item { + id: root + + readonly property int minWidth: 400 + 400 + Appearance.spacing.normal + 120 + Appearance.padding.large * 2 + + function displayTemp(temp: real): string { + return `${Math.ceil(Config.services.useFahrenheitPerformance ? temp * 1.8 + 32 : temp)}°${Config.services.useFahrenheitPerformance ? "F" : "C"}`; + } + + implicitWidth: Math.max(minWidth, content.implicitWidth) + implicitHeight: placeholder.visible ? placeholder.height : content.implicitHeight + + StyledRect { + id: placeholder + + anchors.centerIn: parent + width: 400 + height: 350 + radius: Appearance.rounding.large + color: Colours.tPalette.m3surfaceContainer + visible: !Config.dashboard.performance.showCpu && !(Config.dashboard.performance.showGpu && SystemUsage.gpuType !== "NONE") && !Config.dashboard.performance.showMemory && !Config.dashboard.performance.showStorage && !Config.dashboard.performance.showNetwork && !(UPower.displayDevice.isLaptopBattery && Config.dashboard.performance.showBattery) + + ColumnLayout { + anchors.centerIn: parent + spacing: Appearance.spacing.normal + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + text: "tune" + font.pointSize: Appearance.font.size.extraLarge * 2 + color: Colours.palette.m3onSurfaceVariant + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: qsTr("No widgets enabled") + font.pointSize: Appearance.font.size.large + color: Colours.palette.m3onSurface + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: qsTr("Enable widgets in dashboard settings") + font.pointSize: Appearance.font.size.small + color: Colours.palette.m3onSurfaceVariant + } + } + } + + RowLayout { + id: content + + anchors.left: parent.left + anchors.right: parent.right + spacing: Appearance.spacing.normal + visible: !placeholder.visible + + Ref { + service: SystemUsage + } + + ColumnLayout { + id: mainColumn + + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.normal + visible: Config.dashboard.performance.showCpu || (Config.dashboard.performance.showGpu && SystemUsage.gpuType !== "NONE") + + HeroCard { + Layout.fillWidth: true + Layout.minimumWidth: 400 + Layout.preferredHeight: 150 + visible: Config.dashboard.performance.showCpu + icon: "memory" + title: SystemUsage.cpuName ? `CPU - ${SystemUsage.cpuName}` : qsTr("CPU") + mainValue: `${Math.round(SystemUsage.cpuPerc * 100)}%` + mainLabel: qsTr("Usage") + secondaryValue: root.displayTemp(SystemUsage.cpuTemp) + secondaryLabel: qsTr("Temp") + usage: SystemUsage.cpuPerc + temperature: SystemUsage.cpuTemp + accentColor: Colours.palette.m3primary + } + + HeroCard { + Layout.fillWidth: true + Layout.minimumWidth: 400 + Layout.preferredHeight: 150 + visible: Config.dashboard.performance.showGpu && SystemUsage.gpuType !== "NONE" + icon: "desktop_windows" + title: SystemUsage.gpuName ? `GPU - ${SystemUsage.gpuName}` : qsTr("GPU") + mainValue: `${Math.round(SystemUsage.gpuPerc * 100)}%` + mainLabel: qsTr("Usage") + secondaryValue: root.displayTemp(SystemUsage.gpuTemp) + secondaryLabel: qsTr("Temp") + usage: SystemUsage.gpuPerc + temperature: SystemUsage.gpuTemp + accentColor: Colours.palette.m3secondary + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.normal + visible: Config.dashboard.performance.showMemory || Config.dashboard.performance.showStorage || Config.dashboard.performance.showNetwork + + GaugeCard { + Layout.minimumWidth: 250 + Layout.preferredHeight: 220 + Layout.fillWidth: !Config.dashboard.performance.showStorage && !Config.dashboard.performance.showNetwork + icon: "memory_alt" + title: qsTr("Memory") + percentage: SystemUsage.memPerc + subtitle: { + const usedFmt = SystemUsage.formatKib(SystemUsage.memUsed); + const totalFmt = SystemUsage.formatKib(SystemUsage.memTotal); + return `${usedFmt.value.toFixed(1)} / ${Math.floor(totalFmt.value)} ${totalFmt.unit}`; + } + accentColor: Colours.palette.m3tertiary + visible: Config.dashboard.performance.showMemory + } + + StorageGaugeCard { + Layout.minimumWidth: 250 + Layout.preferredHeight: 220 + Layout.fillWidth: !Config.dashboard.performance.showNetwork + visible: Config.dashboard.performance.showStorage + } + + NetworkCard { + Layout.fillWidth: true + Layout.minimumWidth: 200 + Layout.preferredHeight: 220 + visible: Config.dashboard.performance.showNetwork + } + } + } + + BatteryTank { + Layout.preferredWidth: 120 + Layout.preferredHeight: mainColumn.implicitHeight + visible: UPower.displayDevice.isLaptopBattery && Config.dashboard.performance.showBattery + } + } + + component BatteryTank: StyledClippingRect { + id: batteryTank + + property real percentage: UPower.displayDevice.percentage + property bool isCharging: UPower.displayDevice.state === UPowerDeviceState.Charging + property color accentColor: Colours.palette.m3primary + property real animatedPercentage: 0 + + color: Colours.tPalette.m3surfaceContainer + radius: Appearance.rounding.large + Component.onCompleted: animatedPercentage = percentage + onPercentageChanged: animatedPercentage = percentage + + // Background Fill + StyledRect { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: parent.height * batteryTank.animatedPercentage + color: Qt.alpha(batteryTank.accentColor, 0.15) + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: Appearance.padding.large + spacing: Appearance.spacing.small + + // Header Section + ColumnLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.small + + MaterialIcon { + text: { + if (!UPower.displayDevice.isLaptopBattery) { + if (PowerProfiles.profile === PowerProfile.PowerSaver) + return "energy_savings_leaf"; + + if (PowerProfiles.profile === PowerProfile.Performance) + return "rocket_launch"; + + return "balance"; + } + if (UPower.displayDevice.state === UPowerDeviceState.FullyCharged) + return "battery_full"; + + const perc = UPower.displayDevice.percentage; + const charging = [UPowerDeviceState.Charging, UPowerDeviceState.PendingCharge].includes(UPower.displayDevice.state); + if (perc >= 0.99) + return "battery_full"; + + let level = Math.floor(perc * 7); + if (charging && (level === 4 || level === 1)) + level--; + + return charging ? `battery_charging_${(level + 3) * 10}` : `battery_${level}_bar`; + } + font.pointSize: Appearance.font.size.large + color: batteryTank.accentColor + } + + StyledText { + Layout.fillWidth: true + text: qsTr("Battery") + font.pointSize: Appearance.font.size.normal + color: Colours.palette.m3onSurface + } + } + + Item { + Layout.fillHeight: true + } + + // Bottom Info Section + ColumnLayout { + Layout.fillWidth: true + spacing: -4 + + StyledText { + Layout.alignment: Qt.AlignRight + text: `${Math.round(batteryTank.percentage * 100)}%` + font.pointSize: Appearance.font.size.extraLarge + font.weight: Font.Medium + color: batteryTank.accentColor + } + + StyledText { + Layout.alignment: Qt.AlignRight + text: { + if (UPower.displayDevice.state === UPowerDeviceState.FullyCharged) + return qsTr("Full"); + + if (batteryTank.isCharging) + return qsTr("Charging"); + + const s = UPower.displayDevice.timeToEmpty; + if (s === 0) + return qsTr("..."); + + const hr = Math.floor(s / 3600); + const min = Math.floor((s % 3600) / 60); + if (hr > 0) + return `${hr}h ${min}m`; + + return `${min}m`; + } + font.pointSize: Appearance.font.size.smaller + color: Colours.palette.m3onSurfaceVariant + } + } + } + + Behavior on animatedPercentage { + Anim { + duration: Appearance.anim.durations.large + } + } + } + + component CardHeader: RowLayout { + property string icon + property string title + property color accentColor: Colours.palette.m3primary + + Layout.fillWidth: true + spacing: Appearance.spacing.small + + MaterialIcon { + text: parent.icon + fill: 1 + color: parent.accentColor + font.pointSize: Appearance.spacing.large + } + + StyledText { + Layout.fillWidth: true + text: parent.title + font.pointSize: Appearance.font.size.normal + elide: Text.ElideRight + } + } + + component ProgressBar: StyledRect { + id: progressBar + + property real value: 0 + property color fgColor: Colours.palette.m3primary + property color bgColor: Colours.layer(Colours.palette.m3surfaceContainerHigh, 2) + property real animatedValue: 0 + + color: bgColor + radius: Appearance.rounding.full + Component.onCompleted: animatedValue = value + onValueChanged: animatedValue = value + + StyledRect { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: parent.width * progressBar.animatedValue + color: progressBar.fgColor + radius: Appearance.rounding.full + } + + Behavior on animatedValue { + Anim { + duration: Appearance.anim.durations.large + } + } + } + + component HeroCard: StyledClippingRect { + id: heroCard + + property string icon + property string title + property string mainValue + property string mainLabel + property string secondaryValue + property string secondaryLabel + property real usage: 0 + property real temperature: 0 + property color accentColor: Colours.palette.m3primary + readonly property real maxTemp: 100 + readonly property real tempProgress: Math.min(1, Math.max(0, temperature / maxTemp)) + property real animatedUsage: 0 + property real animatedTemp: 0 + + color: Colours.tPalette.m3surfaceContainer + radius: Appearance.rounding.large + Component.onCompleted: { + animatedUsage = usage; + animatedTemp = tempProgress; + } + onUsageChanged: animatedUsage = usage + onTempProgressChanged: animatedTemp = tempProgress + + StyledRect { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: parent.width * heroCard.animatedUsage + color: Qt.alpha(heroCard.accentColor, 0.15) + } + + ColumnLayout { + anchors.fill: parent + anchors.leftMargin: Appearance.padding.large + anchors.rightMargin: Appearance.padding.large + anchors.topMargin: Appearance.padding.normal + anchors.bottomMargin: Appearance.padding.normal + spacing: Appearance.spacing.small + + CardHeader { + icon: heroCard.icon + title: heroCard.title + accentColor: heroCard.accentColor + } + + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: Appearance.spacing.normal + + Column { + Layout.alignment: Qt.AlignBottom + Layout.fillWidth: true + spacing: Appearance.spacing.small + + Row { + spacing: Appearance.spacing.small + + StyledText { + text: heroCard.secondaryValue + font.pointSize: Appearance.font.size.normal + font.weight: Font.Medium + } + + StyledText { + text: heroCard.secondaryLabel + font.pointSize: Appearance.font.size.small + color: Colours.palette.m3onSurfaceVariant + anchors.baseline: parent.children[0].baseline + } + } + + ProgressBar { + width: parent.width * 0.5 + height: 6 + value: heroCard.tempProgress + fgColor: heroCard.accentColor + bgColor: Qt.alpha(heroCard.accentColor, 0.2) + } + } + + Item { + Layout.fillWidth: true + } + } + } + + Column { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Appearance.padding.large + anchors.rightMargin: 32 + spacing: 0 + + StyledText { + anchors.right: parent.right + text: heroCard.mainLabel + font.pointSize: Appearance.font.size.normal + color: Colours.palette.m3onSurfaceVariant + } + + StyledText { + anchors.right: parent.right + text: heroCard.mainValue + font.pointSize: Appearance.font.size.extraLarge + font.weight: Font.Medium + color: heroCard.accentColor + } + } + + Behavior on animatedUsage { + Anim { + duration: Appearance.anim.durations.large + } + } + + Behavior on animatedTemp { + Anim { + duration: Appearance.anim.durations.large + } + } + } + + component GaugeCard: StyledRect { + id: gaugeCard + + property string icon + property string title + property real percentage: 0 + property string subtitle + property color accentColor: Colours.palette.m3primary + readonly property real arcStartAngle: 0.75 * Math.PI + readonly property real arcSweep: 1.5 * Math.PI + property real animatedPercentage: 0 + + color: Colours.tPalette.m3surfaceContainer + radius: Appearance.rounding.large + clip: true + Component.onCompleted: animatedPercentage = percentage + onPercentageChanged: animatedPercentage = percentage + + ColumnLayout { + anchors.fill: parent + anchors.margins: Appearance.padding.large + spacing: Appearance.spacing.smaller + + CardHeader { + icon: gaugeCard.icon + title: gaugeCard.title + accentColor: gaugeCard.accentColor + } + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + Canvas { + id: gaugeCanvas + + anchors.centerIn: parent + width: Math.min(parent.width, parent.height) + height: width + onPaint: { + const ctx = getContext("2d"); + ctx.reset(); + const cx = width / 2; + const cy = height / 2; + const radius = (Math.min(width, height) - 12) / 2; + const lineWidth = 10; + ctx.beginPath(); + ctx.arc(cx, cy, radius, gaugeCard.arcStartAngle, gaugeCard.arcStartAngle + gaugeCard.arcSweep); + ctx.lineWidth = lineWidth; + ctx.lineCap = "round"; + ctx.strokeStyle = Colours.layer(Colours.palette.m3surfaceContainerHigh, 2); + ctx.stroke(); + if (gaugeCard.animatedPercentage > 0) { + ctx.beginPath(); + ctx.arc(cx, cy, radius, gaugeCard.arcStartAngle, gaugeCard.arcStartAngle + gaugeCard.arcSweep * gaugeCard.animatedPercentage); + ctx.lineWidth = lineWidth; + ctx.lineCap = "round"; + ctx.strokeStyle = gaugeCard.accentColor; + ctx.stroke(); + } + } + Component.onCompleted: requestPaint() + + Connections { + function onAnimatedPercentageChanged() { + gaugeCanvas.requestPaint(); + } + + target: gaugeCard + } + + Connections { + function onPaletteChanged() { + gaugeCanvas.requestPaint(); + } + + target: Colours + } + } + + StyledText { + anchors.centerIn: parent + text: `${Math.round(gaugeCard.percentage * 100)}%` + font.pointSize: Appearance.font.size.extraLarge + font.weight: Font.Medium + color: gaugeCard.accentColor + } + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: gaugeCard.subtitle + font.pointSize: Appearance.font.size.smaller + color: Colours.palette.m3onSurfaceVariant + } + } + + Behavior on animatedPercentage { + Anim { + duration: Appearance.anim.durations.large + } + } + } + + component StorageGaugeCard: StyledRect { + id: storageGaugeCard + + property int currentDiskIndex: 0 + readonly property var currentDisk: SystemUsage.disks.length > 0 ? SystemUsage.disks[currentDiskIndex] : null + property int diskCount: 0 + readonly property real arcStartAngle: 0.75 * Math.PI + readonly property real arcSweep: 1.5 * Math.PI + property real animatedPercentage: 0 + property color accentColor: Colours.palette.m3secondary + + color: Colours.tPalette.m3surfaceContainer + radius: Appearance.rounding.large + clip: true + Component.onCompleted: { + diskCount = SystemUsage.disks.length; + if (currentDisk) + animatedPercentage = currentDisk.perc; + } + onCurrentDiskChanged: { + if (currentDisk) + animatedPercentage = currentDisk.perc; + } + + // Update diskCount and animatedPercentage when disks data changes + Connections { + function onDisksChanged() { + if (SystemUsage.disks.length !== storageGaugeCard.diskCount) + storageGaugeCard.diskCount = SystemUsage.disks.length; + + // Update animated percentage when disk data refreshes + if (storageGaugeCard.currentDisk) + storageGaugeCard.animatedPercentage = storageGaugeCard.currentDisk.perc; + } + + target: SystemUsage + } + + MouseArea { + anchors.fill: parent + onWheel: wheel => { + if (wheel.angleDelta.y > 0) + storageGaugeCard.currentDiskIndex = (storageGaugeCard.currentDiskIndex - 1 + storageGaugeCard.diskCount) % storageGaugeCard.diskCount; + else if (wheel.angleDelta.y < 0) + storageGaugeCard.currentDiskIndex = (storageGaugeCard.currentDiskIndex + 1) % storageGaugeCard.diskCount; + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: Appearance.padding.large + spacing: Appearance.spacing.smaller + + CardHeader { + icon: "hard_disk" + title: { + const base = qsTr("Storage"); + if (!storageGaugeCard.currentDisk) + return base; + + return `${base} - ${storageGaugeCard.currentDisk.mount}`; + } + accentColor: storageGaugeCard.accentColor + + // Scroll hint icon + MaterialIcon { + text: "unfold_more" + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.normal + visible: storageGaugeCard.diskCount > 1 + opacity: 0.7 + ToolTip.visible: hintHover.hovered + ToolTip.text: qsTr("Scroll to switch disks") + ToolTip.delay: 500 + + HoverHandler { + id: hintHover + } + } + } + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + Canvas { + id: storageGaugeCanvas + + anchors.centerIn: parent + width: Math.min(parent.width, parent.height) + height: width + onPaint: { + const ctx = getContext("2d"); + ctx.reset(); + const cx = width / 2; + const cy = height / 2; + const radius = (Math.min(width, height) - 12) / 2; + const lineWidth = 10; + ctx.beginPath(); + ctx.arc(cx, cy, radius, storageGaugeCard.arcStartAngle, storageGaugeCard.arcStartAngle + storageGaugeCard.arcSweep); + ctx.lineWidth = lineWidth; + ctx.lineCap = "round"; + ctx.strokeStyle = Colours.layer(Colours.palette.m3surfaceContainerHigh, 2); + ctx.stroke(); + if (storageGaugeCard.animatedPercentage > 0) { + ctx.beginPath(); + ctx.arc(cx, cy, radius, storageGaugeCard.arcStartAngle, storageGaugeCard.arcStartAngle + storageGaugeCard.arcSweep * storageGaugeCard.animatedPercentage); + ctx.lineWidth = lineWidth; + ctx.lineCap = "round"; + ctx.strokeStyle = storageGaugeCard.accentColor; + ctx.stroke(); + } + } + Component.onCompleted: requestPaint() + + Connections { + function onAnimatedPercentageChanged() { + storageGaugeCanvas.requestPaint(); + } + + target: storageGaugeCard + } + + Connections { + function onPaletteChanged() { + storageGaugeCanvas.requestPaint(); + } + + target: Colours + } + } + + StyledText { + anchors.centerIn: parent + text: storageGaugeCard.currentDisk ? `${Math.round(storageGaugeCard.currentDisk.perc * 100)}%` : "—" + font.pointSize: Appearance.font.size.extraLarge + font.weight: Font.Medium + color: storageGaugeCard.accentColor + } + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: { + if (!storageGaugeCard.currentDisk) + return "—"; + + const usedFmt = SystemUsage.formatKib(storageGaugeCard.currentDisk.used); + const totalFmt = SystemUsage.formatKib(storageGaugeCard.currentDisk.total); + return `${usedFmt.value.toFixed(1)} / ${Math.floor(totalFmt.value)} ${totalFmt.unit}`; + } + font.pointSize: Appearance.font.size.smaller + color: Colours.palette.m3onSurfaceVariant + } + } + + Behavior on animatedPercentage { + Anim { + duration: Appearance.anim.durations.large + } + } + } + + component NetworkCard: StyledRect { + id: networkCard + + property color accentColor: Colours.palette.m3primary + + color: Colours.tPalette.m3surfaceContainer + radius: Appearance.rounding.large + clip: true + + Ref { + service: NetworkUsage + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: Appearance.padding.large + spacing: Appearance.spacing.small + + CardHeader { + icon: "swap_vert" + title: qsTr("Network") + accentColor: networkCard.accentColor + } + + // Sparkline graph + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + Canvas { + id: sparklineCanvas + + property var downHistory: NetworkUsage.downloadHistory + property var upHistory: NetworkUsage.uploadHistory + property real targetMax: 1024 + property real smoothMax: targetMax + property real slideProgress: 0 + property int _tickCount: 0 + property int _lastTickCount: -1 + + function checkAndAnimate(): void { + const currentLength = (downHistory || []).length; + if (currentLength > 0 && _tickCount !== _lastTickCount) { + _lastTickCount = _tickCount; + updateMax(); + } + } + + function updateMax(): void { + const downHist = downHistory || []; + const upHist = upHistory || []; + const allValues = downHist.concat(upHist); + targetMax = Math.max(...allValues, 1024); + requestPaint(); + } + + anchors.fill: parent + onDownHistoryChanged: checkAndAnimate() + onUpHistoryChanged: checkAndAnimate() + onSmoothMaxChanged: requestPaint() + onSlideProgressChanged: requestPaint() + + onPaint: { + const ctx = getContext("2d"); + ctx.reset(); + const w = width; + const h = height; + const downHist = downHistory || []; + const upHist = upHistory || []; + if (downHist.length < 2 && upHist.length < 2) + return; + + const maxVal = smoothMax; + + const drawLine = (history, color, fillAlpha) => { + if (history.length < 2) + return; + + const len = history.length; + const stepX = w / (NetworkUsage.historyLength - 1); + const startX = w - (len - 1) * stepX - stepX * slideProgress + stepX; + ctx.beginPath(); + ctx.moveTo(startX, h - (history[0] / maxVal) * h); + for (let i = 1; i < len; i++) { + const x = startX + i * stepX; + const y = h - (history[i] / maxVal) * h; + ctx.lineTo(x, y); + } + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.stroke(); + ctx.lineTo(startX + (len - 1) * stepX, h); + ctx.lineTo(startX, h); + ctx.closePath(); + ctx.fillStyle = Qt.rgba(Qt.color(color).r, Qt.color(color).g, Qt.color(color).b, fillAlpha); + ctx.fill(); + }; + + drawLine(upHist, Colours.palette.m3secondary.toString(), 0.15); + drawLine(downHist, Colours.palette.m3tertiary.toString(), 0.2); + } + + Component.onCompleted: updateMax() + + Connections { + function onPaletteChanged() { + sparklineCanvas.requestPaint(); + } + + target: Colours + } + + Timer { + interval: Config.dashboard.resourceUpdateInterval + running: true + repeat: true + onTriggered: sparklineCanvas._tickCount++ + } + + NumberAnimation on slideProgress { + from: 0 + to: 1 + duration: Config.dashboard.resourceUpdateInterval + loops: Animation.Infinite + running: true + } + + Behavior on smoothMax { + Anim { + duration: Appearance.anim.durations.large + } + } + } + + // "No data" placeholder + StyledText { + anchors.centerIn: parent + text: qsTr("Collecting data...") + font.pointSize: Appearance.font.size.small + color: Colours.palette.m3onSurfaceVariant + visible: NetworkUsage.downloadHistory.length < 2 + opacity: 0.6 + } + } + + // Download row + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + MaterialIcon { + text: "download" + color: Colours.palette.m3tertiary + font.pointSize: Appearance.font.size.normal + } + + StyledText { + text: qsTr("Download") + font.pointSize: Appearance.font.size.small + color: Colours.palette.m3onSurfaceVariant + } + + Item { + Layout.fillWidth: true + } + + StyledText { + text: { + const fmt = NetworkUsage.formatBytes(NetworkUsage.downloadSpeed ?? 0); + return fmt ? `${fmt.value.toFixed(1)} ${fmt.unit}` : "0.0 B/s"; + } + font.pointSize: Appearance.font.size.normal + font.weight: Font.Medium + color: Colours.palette.m3tertiary + } + } + + // Upload row + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + MaterialIcon { + text: "upload" + color: Colours.palette.m3secondary + font.pointSize: Appearance.font.size.normal + } + + StyledText { + text: qsTr("Upload") + font.pointSize: Appearance.font.size.small + color: Colours.palette.m3onSurfaceVariant + } + + Item { + Layout.fillWidth: true + } + + StyledText { + text: { + const fmt = NetworkUsage.formatBytes(NetworkUsage.uploadSpeed ?? 0); + return fmt ? `${fmt.value.toFixed(1)} ${fmt.unit}` : "0.0 B/s"; + } + font.pointSize: Appearance.font.size.normal + font.weight: Font.Medium + color: Colours.palette.m3secondary + } + } + + // Session totals + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + MaterialIcon { + text: "history" + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.normal + } + + StyledText { + text: qsTr("Total") + font.pointSize: Appearance.font.size.small + color: Colours.palette.m3onSurfaceVariant + } + + Item { + Layout.fillWidth: true + } + + StyledText { + text: { + const down = NetworkUsage.formatBytesTotal(NetworkUsage.downloadTotal ?? 0); + const up = NetworkUsage.formatBytesTotal(NetworkUsage.uploadTotal ?? 0); + return (down && up) ? `↓${down.value.toFixed(1)}${down.unit} ↑${up.value.toFixed(1)}${up.unit}` : "↓0.0B ↑0.0B"; + } + font.pointSize: Appearance.font.size.small + color: Colours.palette.m3onSurfaceVariant + } + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/dashboard/Tabs.qml b/.config/quickshell/caelestia/modules/dashboard/Tabs.qml new file mode 100644 index 0000000..1d50d26 --- /dev/null +++ b/.config/quickshell/caelestia/modules/dashboard/Tabs.qml @@ -0,0 +1,247 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.components.controls +import qs.services +import qs.config +import Quickshell +import Quickshell.Widgets +import QtQuick +import QtQuick.Controls + +Item { + id: root + + required property real nonAnimWidth + required property PersistentProperties state + readonly property alias count: bar.count + + implicitHeight: bar.implicitHeight + indicator.implicitHeight + indicator.anchors.topMargin + separator.implicitHeight + + TabBar { + id: bar + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + + currentIndex: root.state.currentTab + background: null + + onCurrentIndexChanged: root.state.currentTab = currentIndex + + Tab { + iconName: "dashboard" + text: qsTr("Dashboard") + } + + Tab { + iconName: "queue_music" + text: qsTr("Media") + } + + Tab { + iconName: "speed" + text: qsTr("Performance") + } + + Tab { + iconName: "cloud" + text: qsTr("Weather") + } + + // Tab { + // iconName: "workspaces" + // text: qsTr("Workspaces") + // } + } + + Item { + id: indicator + + anchors.top: bar.bottom + anchors.topMargin: 5 + + implicitWidth: bar.currentItem.implicitWidth + implicitHeight: 3 + + x: { + const tab = bar.currentItem; + const width = (root.nonAnimWidth - bar.spacing * (bar.count - 1)) / bar.count; + return width * tab.TabBar.index + (width - tab.implicitWidth) / 2; + } + + clip: true + + StyledRect { + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + implicitHeight: parent.implicitHeight * 2 + + color: Colours.palette.m3primary + radius: Appearance.rounding.full + } + + Behavior on x { + Anim {} + } + + Behavior on implicitWidth { + Anim {} + } + } + + StyledRect { + id: separator + + anchors.top: indicator.bottom + anchors.left: parent.left + anchors.right: parent.right + + implicitHeight: 1 + color: Colours.palette.m3outlineVariant + } + + component Tab: TabButton { + id: tab + + required property string iconName + readonly property bool current: TabBar.tabBar.currentItem === this + + background: null + + contentItem: CustomMouseArea { + id: mouse + + implicitWidth: Math.max(icon.width, label.width) + implicitHeight: icon.height + label.height + + cursorShape: Qt.PointingHandCursor + + onPressed: event => { + root.state.currentTab = tab.TabBar.index; + + const stateY = stateWrapper.y; + rippleAnim.x = event.x; + rippleAnim.y = event.y - stateY; + + const dist = (ox, oy) => ox * ox + oy * oy; + rippleAnim.radius = Math.sqrt(Math.max(dist(event.x, event.y + stateY), dist(event.x, stateWrapper.height - event.y), dist(width - event.x, event.y + stateY), dist(width - event.x, stateWrapper.height - event.y))); + + rippleAnim.restart(); + } + + function onWheel(event: WheelEvent): void { + if (event.angleDelta.y < 0) + root.state.currentTab = Math.min(root.state.currentTab + 1, bar.count - 1); + else if (event.angleDelta.y > 0) + root.state.currentTab = Math.max(root.state.currentTab - 1, 0); + } + + SequentialAnimation { + id: rippleAnim + + property real x + property real y + property real radius + + PropertyAction { + target: ripple + property: "x" + value: rippleAnim.x + } + PropertyAction { + target: ripple + property: "y" + value: rippleAnim.y + } + PropertyAction { + target: ripple + property: "opacity" + value: 0.08 + } + Anim { + target: ripple + properties: "implicitWidth,implicitHeight" + from: 0 + to: rippleAnim.radius * 2 + duration: Appearance.anim.durations.normal + easing.bezierCurve: Appearance.anim.curves.standardDecel + } + Anim { + target: ripple + property: "opacity" + to: 0 + duration: Appearance.anim.durations.normal + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.anim.curves.standard + } + } + + ClippingRectangle { + id: stateWrapper + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + implicitHeight: parent.height + Config.dashboard.sizes.tabIndicatorSpacing * 2 + + color: "transparent" + radius: Appearance.rounding.small + + StyledRect { + id: stateLayer + + anchors.fill: parent + + color: tab.current ? Colours.palette.m3primary : Colours.palette.m3onSurface + opacity: mouse.pressed ? 0.1 : tab.hovered ? 0.08 : 0 + + Behavior on opacity { + Anim {} + } + } + + StyledRect { + id: ripple + + radius: Appearance.rounding.full + color: tab.current ? Colours.palette.m3primary : Colours.palette.m3onSurface + opacity: 0 + + transform: Translate { + x: -ripple.width / 2 + y: -ripple.height / 2 + } + } + } + + MaterialIcon { + id: icon + + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: label.top + + text: tab.iconName + color: tab.current ? Colours.palette.m3primary : Colours.palette.m3onSurfaceVariant + fill: tab.current ? 1 : 0 + font.pointSize: Appearance.font.size.large + + Behavior on fill { + Anim {} + } + } + + StyledText { + id: label + + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + + text: tab.text + color: tab.current ? Colours.palette.m3primary : Colours.palette.m3onSurfaceVariant + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/dashboard/Weather.qml b/.config/quickshell/caelestia/modules/dashboard/Weather.qml new file mode 100644 index 0000000..3981633 --- /dev/null +++ b/.config/quickshell/caelestia/modules/dashboard/Weather.qml @@ -0,0 +1,280 @@ +import qs.components +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +Item { + id: root + + implicitWidth: layout.implicitWidth > 800 ? layout.implicitWidth : 840 + implicitHeight: layout.implicitHeight + + readonly property var today: Weather.forecast && Weather.forecast.length > 0 ? Weather.forecast[0] : null + + Component.onCompleted: Weather.reload() + + ColumnLayout { + id: layout + + anchors.fill: parent + spacing: Appearance.spacing.smaller + + RowLayout { + Layout.leftMargin: Appearance.padding.large + Layout.rightMargin: Appearance.padding.large + Layout.fillWidth: true + + Column { + spacing: Appearance.spacing.small / 2 + + StyledText { + text: Weather.city || qsTr("Loading...") + font.pointSize: Appearance.font.size.extraLarge + font.weight: 600 + color: Colours.palette.m3onSurface + } + + StyledText { + text: new Date().toLocaleDateString(Qt.locale(), "dddd, MMMM d") + font.pointSize: Appearance.font.size.small + color: Colours.palette.m3onSurfaceVariant + } + } + + Item { + Layout.fillWidth: true + } + + Row { + spacing: Appearance.spacing.large + + WeatherStat { + icon: "wb_twilight" + label: "Sunrise" + value: Weather.sunrise + colour: Colours.palette.m3tertiary + } + + WeatherStat { + icon: "bedtime" + label: "Sunset" + value: Weather.sunset + colour: Colours.palette.m3tertiary + } + } + } + + StyledRect { + Layout.fillWidth: true + implicitHeight: bigInfoRow.implicitHeight + Appearance.padding.small * 2 + + radius: Appearance.rounding.large * 2 + color: Colours.tPalette.m3surfaceContainer + + RowLayout { + id: bigInfoRow + + anchors.centerIn: parent + spacing: Appearance.spacing.large + + MaterialIcon { + Layout.alignment: Qt.AlignVCenter + text: Weather.icon + font.pointSize: Appearance.font.size.extraLarge * 3 + color: Colours.palette.m3secondary + animate: true + } + + ColumnLayout { + Layout.alignment: Qt.AlignVCenter + spacing: -Appearance.spacing.small + + StyledText { + text: Weather.temp + font.pointSize: Appearance.font.size.extraLarge * 2 + font.weight: 500 + color: Colours.palette.m3primary + } + + StyledText { + Layout.leftMargin: Appearance.padding.small + text: Weather.description + font.pointSize: Appearance.font.size.normal + color: Colours.palette.m3onSurfaceVariant + } + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.smaller + + DetailCard { + icon: "water_drop" + label: "Humidity" + value: Weather.humidity + "%" + colour: Colours.palette.m3secondary + } + DetailCard { + icon: "thermostat" + label: "Feels Like" + value: Weather.feelsLike + colour: Colours.palette.m3primary + } + DetailCard { + icon: "air" + label: "Wind" + value: Weather.windSpeed ? Weather.windSpeed + " km/h" : "--" + colour: Colours.palette.m3tertiary + } + } + + StyledText { + Layout.topMargin: Appearance.spacing.normal + Layout.leftMargin: Appearance.padding.normal + visible: forecastRepeater.count > 0 + text: qsTr("7-Day Forecast") + font.pointSize: Appearance.font.size.normal + font.weight: 600 + color: Colours.palette.m3onSurface + } + + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.smaller + + Repeater { + id: forecastRepeater + + model: Weather.forecast + + StyledRect { + id: forecastItem + + required property int index + required property var modelData + + Layout.fillWidth: true + implicitHeight: forecastItemColumn.implicitHeight + Appearance.padding.normal * 2 + + radius: Appearance.rounding.normal + color: Colours.tPalette.m3surfaceContainer + + ColumnLayout { + id: forecastItemColumn + + anchors.centerIn: parent + spacing: Appearance.spacing.small + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: forecastItem.index === 0 ? qsTr("Today") : new Date(forecastItem.modelData.date).toLocaleDateString(Qt.locale(), "ddd") + font.pointSize: Appearance.font.size.normal + font.weight: 600 + color: Colours.palette.m3primary + } + + StyledText { + Layout.topMargin: -Appearance.spacing.small / 2 + Layout.alignment: Qt.AlignHCenter + text: new Date(forecastItem.modelData.date).toLocaleDateString(Qt.locale(), "MMM d") + font.pointSize: Appearance.font.size.small + opacity: 0.7 + color: Colours.palette.m3onSurfaceVariant + } + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + text: forecastItem.modelData.icon + font.pointSize: Appearance.font.size.extraLarge + color: Colours.palette.m3secondary + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: Config.services.useFahrenheit ? forecastItem.modelData.maxTempF + "°" + " / " + forecastItem.modelData.minTempF + "°" : forecastItem.modelData.maxTempC + "°" + " / " + forecastItem.modelData.minTempC + "°" + font.weight: 600 + color: Colours.palette.m3tertiary + } + } + } + } + } + } + + component DetailCard: StyledRect { + id: detailRoot + + property string icon + property string label + property string value + property color colour + + Layout.fillWidth: true + Layout.preferredHeight: 60 + radius: Appearance.rounding.small + color: Colours.tPalette.m3surfaceContainer + + Row { + anchors.centerIn: parent + spacing: Appearance.spacing.normal + + MaterialIcon { + text: detailRoot.icon + color: detailRoot.colour + font.pointSize: Appearance.font.size.large + anchors.verticalCenter: parent.verticalCenter + } + + Column { + anchors.verticalCenter: parent.verticalCenter + spacing: 0 + + StyledText { + text: detailRoot.label + font.pointSize: Appearance.font.size.smaller + opacity: 0.7 + horizontalAlignment: Text.AlignLeft + } + StyledText { + text: detailRoot.value + font.weight: 600 + horizontalAlignment: Text.AlignLeft + } + } + } + } + + component WeatherStat: Row { + id: weatherStat + + property string icon + property string label + property string value + property color colour + + spacing: Appearance.spacing.small + + MaterialIcon { + text: weatherStat.icon + font.pointSize: Appearance.font.size.extraLarge + color: weatherStat.colour + } + + Column { + StyledText { + text: weatherStat.label + font.pointSize: Appearance.font.size.smaller + color: Colours.palette.m3onSurfaceVariant + } + StyledText { + text: weatherStat.value + font.pointSize: Appearance.font.size.small + font.weight: 600 + color: Colours.palette.m3onSurface + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/dashboard/Wrapper.qml b/.config/quickshell/caelestia/modules/dashboard/Wrapper.qml new file mode 100644 index 0000000..0e37909 --- /dev/null +++ b/.config/quickshell/caelestia/modules/dashboard/Wrapper.qml @@ -0,0 +1,105 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.components.filedialog +import qs.config +import qs.utils +import Caelestia +import Quickshell +import QtQuick + +Item { + id: root + + required property PersistentProperties visibilities + readonly property PersistentProperties dashState: PersistentProperties { + property int currentTab + property date currentDate: new Date() + + reloadableId: "dashboardState" + } + readonly property FileDialog facePicker: FileDialog { + title: qsTr("Select a profile picture") + filterLabel: qsTr("Image files") + filters: Images.validImageExtensions + onAccepted: path => { + if (CUtils.copyFile(Qt.resolvedUrl(path), Qt.resolvedUrl(`${Paths.home}/.face`))) + Quickshell.execDetached(["notify-send", "-a", "caelestia-shell", "-u", "low", "-h", `STRING:image-path:${path}`, "Profile picture changed", `Profile picture changed to ${Paths.shortenHome(path)}`]); + else + Quickshell.execDetached(["notify-send", "-a", "caelestia-shell", "-u", "critical", "Unable to change profile picture", `Failed to change profile picture to ${Paths.shortenHome(path)}`]); + } + } + + readonly property real nonAnimHeight: state === "visible" ? (content.item?.nonAnimHeight ?? 0) : 0 + + visible: height > 0 + implicitHeight: 0 + implicitWidth: content.implicitWidth + + onStateChanged: { + if (state === "visible" && timer.running) { + timer.triggered(); + timer.stop(); + } + } + + states: State { + name: "visible" + when: root.visibilities.dashboard && Config.dashboard.enabled + + PropertyChanges { + root.implicitHeight: content.implicitHeight + } + } + + transitions: [ + Transition { + from: "" + to: "visible" + + Anim { + target: root + property: "implicitHeight" + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + }, + Transition { + from: "visible" + to: "" + + Anim { + target: root + property: "implicitHeight" + easing.bezierCurve: Appearance.anim.curves.emphasized + } + } + ] + + Timer { + id: timer + + running: true + interval: Appearance.anim.durations.extraLarge + onTriggered: { + content.active = Qt.binding(() => (root.visibilities.dashboard && Config.dashboard.enabled) || root.visible); + content.visible = true; + } + } + + Loader { + id: content + + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + + visible: false + active: true + + sourceComponent: Content { + visibilities: root.visibilities + state: root.dashState + facePicker: root.facePicker + } + } +} diff --git a/.config/quickshell/caelestia/modules/dashboard/dash/Calendar.qml b/.config/quickshell/caelestia/modules/dashboard/dash/Calendar.qml new file mode 100644 index 0000000..56c0493 --- /dev/null +++ b/.config/quickshell/caelestia/modules/dashboard/dash/Calendar.qml @@ -0,0 +1,253 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.components.effects +import qs.components.controls +import qs.services +import qs.config +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +CustomMouseArea { + id: root + + required property var state + + readonly property int currMonth: state.currentDate.getMonth() + readonly property int currYear: state.currentDate.getFullYear() + + anchors.left: parent.left + anchors.right: parent.right + implicitHeight: inner.implicitHeight + inner.anchors.margins * 2 + + acceptedButtons: Qt.MiddleButton + onClicked: root.state.currentDate = new Date() + + function onWheel(event: WheelEvent): void { + if (event.angleDelta.y > 0) + root.state.currentDate = new Date(root.currYear, root.currMonth - 1, 1); + else if (event.angleDelta.y < 0) + root.state.currentDate = new Date(root.currYear, root.currMonth + 1, 1); + } + + ColumnLayout { + id: inner + + anchors.fill: parent + anchors.margins: Appearance.padding.large + spacing: Appearance.spacing.small + + RowLayout { + id: monthNavigationRow + + Layout.fillWidth: true + spacing: Appearance.spacing.small + + Item { + implicitWidth: implicitHeight + implicitHeight: prevMonthText.implicitHeight + Appearance.padding.small * 2 + + StateLayer { + id: prevMonthStateLayer + + radius: Appearance.rounding.full + + function onClicked(): void { + root.state.currentDate = new Date(root.currYear, root.currMonth - 1, 1); + } + } + + MaterialIcon { + id: prevMonthText + + anchors.centerIn: parent + text: "chevron_left" + color: Colours.palette.m3tertiary + font.pointSize: Appearance.font.size.normal + font.weight: 700 + } + } + + Item { + Layout.fillWidth: true + + implicitWidth: monthYearDisplay.implicitWidth + Appearance.padding.small * 2 + implicitHeight: monthYearDisplay.implicitHeight + Appearance.padding.small * 2 + + StateLayer { + anchors.fill: monthYearDisplay + anchors.margins: -Appearance.padding.small + anchors.leftMargin: -Appearance.padding.normal + anchors.rightMargin: -Appearance.padding.normal + + radius: Appearance.rounding.full + disabled: { + const now = new Date(); + return root.currMonth === now.getMonth() && root.currYear === now.getFullYear(); + } + + function onClicked(): void { + root.state.currentDate = new Date(); + } + } + + StyledText { + id: monthYearDisplay + + anchors.centerIn: parent + text: grid.title + color: Colours.palette.m3primary + font.pointSize: Appearance.font.size.normal + font.weight: 500 + font.capitalization: Font.Capitalize + } + } + + Item { + implicitWidth: implicitHeight + implicitHeight: nextMonthText.implicitHeight + Appearance.padding.small * 2 + + StateLayer { + id: nextMonthStateLayer + + radius: Appearance.rounding.full + + function onClicked(): void { + root.state.currentDate = new Date(root.currYear, root.currMonth + 1, 1); + } + } + + MaterialIcon { + id: nextMonthText + + anchors.centerIn: parent + text: "chevron_right" + color: Colours.palette.m3tertiary + font.pointSize: Appearance.font.size.normal + font.weight: 700 + } + } + } + + DayOfWeekRow { + id: daysRow + + Layout.fillWidth: true + locale: grid.locale + + delegate: StyledText { + required property var model + + horizontalAlignment: Text.AlignHCenter + text: model.shortName + font.weight: 500 + color: (model.day === 0 || model.day === 6) ? Colours.palette.m3secondary : Colours.palette.m3onSurfaceVariant + } + } + + Item { + Layout.fillWidth: true + implicitHeight: grid.implicitHeight + + MonthGrid { + id: grid + + month: root.currMonth + year: root.currYear + + anchors.fill: parent + + spacing: 3 + locale: Qt.locale() + + delegate: Item { + id: dayItem + + required property var model + + implicitWidth: implicitHeight + implicitHeight: text.implicitHeight + Appearance.padding.small * 2 + + StyledText { + id: text + + anchors.centerIn: parent + + horizontalAlignment: Text.AlignHCenter + text: grid.locale.toString(dayItem.model.day) + color: { + const dayOfWeek = dayItem.model.date.getUTCDay(); + if (dayOfWeek === 0 || dayOfWeek === 6) + return Colours.palette.m3secondary; + + return Colours.palette.m3onSurfaceVariant; + } + opacity: dayItem.model.today || dayItem.model.month === grid.month ? 1 : 0.4 + font.pointSize: Appearance.font.size.normal + font.weight: 500 + } + } + } + + StyledRect { + id: todayIndicator + + readonly property Item todayItem: grid.contentItem.children.find(c => c.model.today) ?? null + property Item today + + onTodayItemChanged: { + if (todayItem) + today = todayItem; + } + + x: today ? today.x + (today.width - implicitWidth) / 2 : 0 + y: today?.y ?? 0 + + implicitWidth: today?.implicitWidth ?? 0 + implicitHeight: today?.implicitHeight ?? 0 + + clip: true + radius: Appearance.rounding.full + color: Colours.palette.m3primary + + opacity: todayItem ? 1 : 0 + scale: todayItem ? 1 : 0.7 + + Colouriser { + x: -todayIndicator.x + y: -todayIndicator.y + + implicitWidth: grid.width + implicitHeight: grid.height + + source: grid + sourceColor: Colours.palette.m3onSurface + colorizationColor: Colours.palette.m3onPrimary + } + + Behavior on opacity { + Anim {} + } + + Behavior on scale { + Anim {} + } + + Behavior on x { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + + Behavior on y { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/dashboard/dash/DateTime.qml b/.config/quickshell/caelestia/modules/dashboard/dash/DateTime.qml new file mode 100644 index 0000000..e740448 --- /dev/null +++ b/.config/quickshell/caelestia/modules/dashboard/dash/DateTime.qml @@ -0,0 +1,65 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +Item { + id: root + + anchors.top: parent.top + anchors.bottom: parent.bottom + implicitWidth: Config.dashboard.sizes.dateTimeWidth + + ColumnLayout { + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 0 + + StyledText { + Layout.bottomMargin: -(font.pointSize * 0.4) + Layout.alignment: Qt.AlignHCenter + text: Time.hourStr + color: Colours.palette.m3secondary + font.pointSize: Appearance.font.size.extraLarge + font.family: Appearance.font.family.clock + font.weight: 600 + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: "•••" + color: Colours.palette.m3primary + font.pointSize: Appearance.font.size.extraLarge * 0.9 + font.family: Appearance.font.family.clock + } + + StyledText { + Layout.topMargin: -(font.pointSize * 0.4) + Layout.alignment: Qt.AlignHCenter + text: Time.minuteStr + color: Colours.palette.m3secondary + font.pointSize: Appearance.font.size.extraLarge + font.family: Appearance.font.family.clock + font.weight: 600 + } + + Loader { + Layout.alignment: Qt.AlignHCenter + + active: Config.services.useTwelveHourClock + visible: active + + sourceComponent: StyledText { + text: Time.amPmStr + color: Colours.palette.m3primary + font.pointSize: Appearance.font.size.large + font.family: Appearance.font.family.clock + font.weight: 600 + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/dashboard/dash/Media.qml b/.config/quickshell/caelestia/modules/dashboard/dash/Media.qml new file mode 100644 index 0000000..d650669 --- /dev/null +++ b/.config/quickshell/caelestia/modules/dashboard/dash/Media.qml @@ -0,0 +1,254 @@ +import qs.components +import qs.services +import qs.config +import qs.utils +import Caelestia.Services +import QtQuick +import QtQuick.Shapes + +Item { + id: root + + property real playerProgress: { + const active = Players.active; + return active?.length ? active.position / active.length : 0; + } + + anchors.top: parent.top + anchors.bottom: parent.bottom + implicitWidth: Config.dashboard.sizes.mediaWidth + + Behavior on playerProgress { + Anim { + duration: Appearance.anim.durations.large + } + } + + Timer { + running: Players.active?.isPlaying ?? false + interval: Config.dashboard.mediaUpdateInterval + triggeredOnStart: true + repeat: true + onTriggered: Players.active?.positionChanged() + } + + ServiceRef { + service: Audio.beatTracker + } + + Shape { + preferredRendererType: Shape.CurveRenderer + + ShapePath { + fillColor: "transparent" + strokeColor: Colours.layer(Colours.palette.m3surfaceContainerHigh, 2) + strokeWidth: Config.dashboard.sizes.mediaProgressThickness + capStyle: Appearance.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap + + PathAngleArc { + centerX: cover.x + cover.width / 2 + centerY: cover.y + cover.height / 2 + radiusX: (cover.width + Config.dashboard.sizes.mediaProgressThickness) / 2 + Appearance.spacing.small + radiusY: (cover.height + Config.dashboard.sizes.mediaProgressThickness) / 2 + Appearance.spacing.small + startAngle: -90 - Config.dashboard.sizes.mediaProgressSweep / 2 + sweepAngle: Config.dashboard.sizes.mediaProgressSweep + } + + Behavior on strokeColor { + CAnim {} + } + } + + ShapePath { + fillColor: "transparent" + strokeColor: Colours.palette.m3primary + strokeWidth: Config.dashboard.sizes.mediaProgressThickness + capStyle: Appearance.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap + + PathAngleArc { + centerX: cover.x + cover.width / 2 + centerY: cover.y + cover.height / 2 + radiusX: (cover.width + Config.dashboard.sizes.mediaProgressThickness) / 2 + Appearance.spacing.small + radiusY: (cover.height + Config.dashboard.sizes.mediaProgressThickness) / 2 + Appearance.spacing.small + startAngle: -90 - Config.dashboard.sizes.mediaProgressSweep / 2 + sweepAngle: Config.dashboard.sizes.mediaProgressSweep * root.playerProgress + } + + Behavior on strokeColor { + CAnim {} + } + } + } + + StyledClippingRect { + id: cover + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: Appearance.padding.large + Config.dashboard.sizes.mediaProgressThickness + Appearance.spacing.small + + implicitHeight: width + color: Colours.tPalette.m3surfaceContainerHigh + radius: Infinity + + MaterialIcon { + anchors.centerIn: parent + + grade: 200 + text: "art_track" + color: Colours.palette.m3onSurfaceVariant + font.pointSize: (parent.width * 0.4) || 1 + } + + Image { + id: image + + anchors.fill: parent + + source: Players.active?.trackArtUrl ?? "" // qmllint disable incompatible-type + asynchronous: true + fillMode: Image.PreserveAspectCrop + sourceSize.width: width + sourceSize.height: height + } + } + + StyledText { + id: title + + anchors.top: cover.bottom + anchors.horizontalCenter: parent.horizontalCenter + anchors.topMargin: Appearance.spacing.normal + + animate: true + horizontalAlignment: Text.AlignHCenter + text: (Players.active?.trackTitle ?? qsTr("No media")) || qsTr("Unknown title") + color: Colours.palette.m3primary + font.pointSize: Appearance.font.size.normal + + width: parent.implicitWidth - Appearance.padding.large * 2 + elide: Text.ElideRight + } + + StyledText { + id: album + + anchors.top: title.bottom + anchors.horizontalCenter: parent.horizontalCenter + anchors.topMargin: Appearance.spacing.small + + animate: true + horizontalAlignment: Text.AlignHCenter + text: (Players.active?.trackAlbum ?? qsTr("No media")) || qsTr("Unknown album") + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.small + + width: parent.implicitWidth - Appearance.padding.large * 2 + elide: Text.ElideRight + } + + StyledText { + id: artist + + anchors.top: album.bottom + anchors.horizontalCenter: parent.horizontalCenter + anchors.topMargin: Appearance.spacing.small + + animate: true + horizontalAlignment: Text.AlignHCenter + text: (Players.active?.trackArtist ?? qsTr("No media")) || qsTr("Unknown artist") + color: Colours.palette.m3secondary + + width: parent.implicitWidth - Appearance.padding.large * 2 + elide: Text.ElideRight + } + + Row { + id: controls + + anchors.top: artist.bottom + anchors.horizontalCenter: parent.horizontalCenter + anchors.topMargin: Appearance.spacing.smaller + + spacing: Appearance.spacing.small + + Control { + icon: "skip_previous" + canUse: Players.active?.canGoPrevious ?? false + + function onClicked(): void { + Players.active?.previous(); + } + } + + Control { + icon: Players.active?.isPlaying ? "pause" : "play_arrow" + canUse: Players.active?.canTogglePlaying ?? false + + function onClicked(): void { + Players.active?.togglePlaying(); + } + } + + Control { + icon: "skip_next" + canUse: Players.active?.canGoNext ?? false + + function onClicked(): void { + Players.active?.next(); + } + } + } + + AnimatedImage { + id: bongocat + + anchors.top: controls.bottom + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: Appearance.spacing.small + anchors.bottomMargin: Appearance.padding.large + anchors.margins: Appearance.padding.large * 2 + + playing: Players.active?.isPlaying ?? false + speed: Audio.beatTracker.bpm / Appearance.anim.mediaGifSpeedAdjustment + source: Paths.absolutePath(Config.paths.mediaGif) + asynchronous: true + fillMode: AnimatedImage.PreserveAspectFit + } + + component Control: StyledRect { + id: control + + required property string icon + required property bool canUse + function onClicked(): void { + } + + implicitWidth: Math.max(icon.implicitHeight, icon.implicitHeight) + Appearance.padding.small + implicitHeight: implicitWidth + + StateLayer { + disabled: !control.canUse + radius: Appearance.rounding.full + + function onClicked(): void { + control.onClicked(); + } + } + + MaterialIcon { + id: icon + + anchors.centerIn: parent + anchors.verticalCenterOffset: font.pointSize * 0.05 + + animate: true + text: control.icon + color: control.canUse ? Colours.palette.m3onSurface : Colours.palette.m3outline + font.pointSize: Appearance.font.size.large + } + } +} diff --git a/.config/quickshell/caelestia/modules/dashboard/dash/Resources.qml b/.config/quickshell/caelestia/modules/dashboard/dash/Resources.qml new file mode 100644 index 0000000..7f44a9d --- /dev/null +++ b/.config/quickshell/caelestia/modules/dashboard/dash/Resources.qml @@ -0,0 +1,87 @@ +import qs.components +import qs.components.misc +import qs.services +import qs.config +import QtQuick + +Row { + id: root + + anchors.top: parent.top + anchors.bottom: parent.bottom + + padding: Appearance.padding.large + spacing: Appearance.spacing.normal + + Ref { + service: SystemUsage + } + + Resource { + icon: "memory" + value: SystemUsage.cpuPerc + colour: Colours.palette.m3primary + } + + Resource { + icon: "memory_alt" + value: SystemUsage.memPerc + colour: Colours.palette.m3secondary + } + + Resource { + icon: "hard_disk" + value: SystemUsage.storagePerc + colour: Colours.palette.m3tertiary + } + + component Resource: Item { + id: res + + required property string icon + required property real value + required property color colour + + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.margins: Appearance.padding.large + implicitWidth: icon.implicitWidth + + StyledRect { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.bottom: icon.top + anchors.bottomMargin: Appearance.spacing.small + + implicitWidth: Config.dashboard.sizes.resourceProgessThickness + + color: Colours.layer(Colours.palette.m3surfaceContainerHigh, 2) + radius: Appearance.rounding.full + + StyledRect { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + implicitHeight: res.value * parent.height + + color: res.colour + radius: Appearance.rounding.full + } + } + + MaterialIcon { + id: icon + + anchors.bottom: parent.bottom + + text: res.icon + color: res.colour + } + + Behavior on value { + Anim { + duration: Appearance.anim.durations.large + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/dashboard/dash/User.qml b/.config/quickshell/caelestia/modules/dashboard/dash/User.qml new file mode 100644 index 0000000..b66b1f9 --- /dev/null +++ b/.config/quickshell/caelestia/modules/dashboard/dash/User.qml @@ -0,0 +1,195 @@ +import qs.components +import qs.components.effects +import qs.components.images +import qs.components.filedialog +import qs.services +import qs.config +import qs.utils +import Quickshell +import QtQuick + +Row { + id: root + + required property PersistentProperties visibilities + required property PersistentProperties state + required property FileDialog facePicker + + padding: Appearance.padding.large + spacing: Appearance.spacing.normal + + StyledClippingRect { + implicitWidth: info.implicitHeight + implicitHeight: info.implicitHeight + + radius: Appearance.rounding.large + color: Colours.layer(Colours.palette.m3surfaceContainerHigh, 2) + + MaterialIcon { + anchors.centerIn: parent + + text: "person" + fill: 1 + grade: 200 + font.pointSize: Math.floor(info.implicitHeight / 2) || 1 + } + + CachingImage { + id: pfp + + anchors.fill: parent + path: `${Paths.home}/.face` + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + + StyledRect { + anchors.fill: parent + + color: Qt.alpha(Colours.palette.m3scrim, 0.5) + opacity: parent.containsMouse ? 1 : 0 + + Behavior on opacity { + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + } + } + } + + StyledRect { + anchors.centerIn: parent + + implicitWidth: selectIcon.implicitHeight + Appearance.padding.small * 2 + implicitHeight: selectIcon.implicitHeight + Appearance.padding.small * 2 + + radius: Appearance.rounding.normal + color: Colours.palette.m3primary + scale: parent.containsMouse ? 1 : 0.5 + opacity: parent.containsMouse ? 1 : 0 + + StateLayer { + color: Colours.palette.m3onPrimary + + function onClicked(): void { + root.visibilities.launcher = false; + root.facePicker.open(); + } + } + + MaterialIcon { + id: selectIcon + + anchors.centerIn: parent + anchors.horizontalCenterOffset: -font.pointSize * 0.02 + + text: "frame_person" + color: Colours.palette.m3onPrimary + font.pointSize: Appearance.font.size.extraLarge + } + + Behavior on scale { + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + } + } + + Behavior on opacity { + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + } + } + } + } + } + + Column { + id: info + + anchors.verticalCenter: parent.verticalCenter + spacing: Appearance.spacing.normal + + Item { + id: line + + implicitWidth: icon.implicitWidth + text.width + text.anchors.leftMargin + implicitHeight: Math.max(icon.implicitHeight, text.implicitHeight) + + ColouredIcon { + id: icon + + anchors.left: parent.left + anchors.leftMargin: (Config.dashboard.sizes.infoIconSize - implicitWidth) / 2 + + source: SysInfo.osLogo + implicitSize: Math.floor(Appearance.font.size.normal * 1.34) + colour: Colours.palette.m3primary + } + + StyledText { + id: text + + anchors.verticalCenter: icon.verticalCenter + anchors.left: icon.right + anchors.leftMargin: icon.anchors.leftMargin + text: `: ${SysInfo.osPrettyName || SysInfo.osName}` + font.pointSize: Appearance.font.size.normal + + width: Config.dashboard.sizes.infoWidth + elide: Text.ElideRight + } + } + + InfoLine { + icon: "select_window_2" + text: SysInfo.wm + colour: Colours.palette.m3secondary + } + + InfoLine { + id: uptime + + icon: "timer" + text: qsTr("up %1").arg(SysInfo.uptime) + colour: Colours.palette.m3tertiary + } + } + + component InfoLine: Item { + id: line + + required property string icon + required property string text + required property color colour + + implicitWidth: icon.implicitWidth + text.width + text.anchors.leftMargin + implicitHeight: Math.max(icon.implicitHeight, text.implicitHeight) + + MaterialIcon { + id: icon + + anchors.left: parent.left + anchors.leftMargin: (Config.dashboard.sizes.infoIconSize - implicitWidth) / 2 + + fill: 1 + text: line.icon + color: line.colour + font.pointSize: Appearance.font.size.normal + } + + StyledText { + id: text + + anchors.verticalCenter: icon.verticalCenter + anchors.left: icon.right + anchors.leftMargin: icon.anchors.leftMargin + text: `: ${line.text}` + font.pointSize: Appearance.font.size.normal + + width: Config.dashboard.sizes.infoWidth + elide: Text.ElideRight + } + } +} diff --git a/.config/quickshell/caelestia/modules/dashboard/dash/Weather.qml b/.config/quickshell/caelestia/modules/dashboard/dash/Weather.qml new file mode 100644 index 0000000..c90ccf0 --- /dev/null +++ b/.config/quickshell/caelestia/modules/dashboard/dash/Weather.qml @@ -0,0 +1,57 @@ +import qs.components +import qs.services +import qs.config +import qs.utils +import QtQuick + +Item { + id: root + + anchors.centerIn: parent + + implicitWidth: icon.implicitWidth + info.implicitWidth + info.anchors.leftMargin + + Component.onCompleted: Weather.reload() + + MaterialIcon { + id: icon + + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + + animate: true + text: Weather.icon + color: Colours.palette.m3secondary + font.pointSize: Appearance.font.size.extraLarge * 2 + } + + Column { + id: info + + anchors.verticalCenter: parent.verticalCenter + anchors.left: icon.right + anchors.leftMargin: Appearance.spacing.large + + spacing: Appearance.spacing.small + + StyledText { + anchors.horizontalCenter: parent.horizontalCenter + + animate: true + text: Weather.temp + color: Colours.palette.m3primary + font.pointSize: Appearance.font.size.extraLarge + font.weight: 500 + } + + StyledText { + anchors.horizontalCenter: parent.horizontalCenter + + animate: true + text: Weather.description + + elide: Text.ElideRight + width: Math.min(implicitWidth, root.parent.width - icon.implicitWidth - info.anchors.leftMargin - Appearance.padding.large * 2) + } + } +} diff --git a/.config/quickshell/caelestia/modules/drawers/Backgrounds.qml b/.config/quickshell/caelestia/modules/drawers/Backgrounds.qml new file mode 100644 index 0000000..7fa2ca1 --- /dev/null +++ b/.config/quickshell/caelestia/modules/drawers/Backgrounds.qml @@ -0,0 +1,86 @@ +import qs.services +import qs.config +import qs.modules.osd as Osd +import qs.modules.notifications as Notifications +import qs.modules.session as Session +import qs.modules.launcher as Launcher +import qs.modules.dashboard as Dashboard +import qs.modules.bar.popouts as BarPopouts +import qs.modules.utilities as Utilities +import qs.modules.sidebar as Sidebar +import QtQuick +import QtQuick.Shapes + +Shape { + id: root + + required property Panels panels + required property Item bar + + anchors.fill: parent + anchors.margins: Config.border.thickness + anchors.leftMargin: bar.implicitWidth + preferredRendererType: Shape.CurveRenderer + + Osd.Background { + wrapper: root.panels.osd + + startX: root.width - root.panels.session.width - root.panels.sidebar.width + startY: (root.height - wrapper.height) / 2 - rounding + } + + Notifications.Background { + wrapper: root.panels.notifications + sidebar: sidebar + + startX: root.width + startY: 0 + } + + Session.Background { + wrapper: root.panels.session + + startX: root.width - root.panels.sidebar.width + startY: (root.height - wrapper.height) / 2 - rounding + } + + Launcher.Background { + wrapper: root.panels.launcher + + startX: (root.width - wrapper.width) / 2 - rounding + startY: root.height + } + + Dashboard.Background { + wrapper: root.panels.dashboard + + startX: (root.width - wrapper.width) / 2 - rounding + startY: 0 + } + + BarPopouts.Background { + wrapper: root.panels.popouts + invertBottomRounding: wrapper.y + wrapper.height + 1 >= root.height + + startX: wrapper.x + startY: wrapper.y - rounding * sideRounding + } + + Utilities.Background { + wrapper: root.panels.utilities + sidebar: sidebar + + startX: root.width + startY: root.height + } + + Sidebar.Background { + id: sidebar + + wrapper: root.panels.sidebar + panels: root.panels + + startX: root.width + startY: root.panels.notifications.height + } +} diff --git a/.config/quickshell/caelestia/modules/drawers/Border.qml b/.config/quickshell/caelestia/modules/drawers/Border.qml new file mode 100644 index 0000000..6fdd73b --- /dev/null +++ b/.config/quickshell/caelestia/modules/drawers/Border.qml @@ -0,0 +1,44 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.services +import qs.config +import QtQuick +import QtQuick.Effects + +Item { + id: root + + required property Item bar + + anchors.fill: parent + + StyledRect { + anchors.fill: parent + color: Colours.palette.m3surface + + layer.enabled: true + layer.effect: MultiEffect { + maskSource: mask + maskEnabled: true + maskInverted: true + maskThresholdMin: 0.5 + maskSpreadAtMin: 1 + } + } + + Item { + id: mask + + anchors.fill: parent + layer.enabled: true + visible: false + + Rectangle { + anchors.fill: parent + anchors.margins: Config.border.thickness + anchors.leftMargin: root.bar.implicitWidth + radius: Config.border.rounding + } + } +} diff --git a/.config/quickshell/caelestia/modules/drawers/Drawers.qml b/.config/quickshell/caelestia/modules/drawers/Drawers.qml new file mode 100644 index 0000000..93534ec --- /dev/null +++ b/.config/quickshell/caelestia/modules/drawers/Drawers.qml @@ -0,0 +1,181 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.components.containers +import qs.services +import qs.config +import qs.utils +import qs.modules.bar +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland +import QtQuick +import QtQuick.Effects + +Variants { + model: Quickshell.screens + + Scope { + id: scope + + required property ShellScreen modelData + readonly property bool barDisabled: Strings.testRegexList(Config.bar.excludedScreens, modelData.name) + + Exclusions { + screen: scope.modelData + bar: bar + } + + StyledWindow { + id: win + + readonly property bool hasFullscreen: Hypr.monitorFor(screen)?.activeWorkspace?.toplevels.values.some(t => t.lastIpcObject.fullscreen === 2) ?? false + readonly property int dragMaskPadding: { + if (focusGrab.active || panels.popouts.isDetached) + return 0; + + const mon = Hypr.monitorFor(screen); + if (mon?.lastIpcObject?.specialWorkspace?.name || mon?.activeWorkspace?.lastIpcObject?.windows > 0) + return 0; + + const thresholds = []; + for (const panel of ["dashboard", "launcher", "session", "sidebar"]) + if (Config[panel].enabled) + thresholds.push(Config[panel].dragThreshold); + return Math.max(...thresholds); + } + + onHasFullscreenChanged: { + visibilities.launcher = false; + visibilities.session = false; + visibilities.dashboard = false; + } + + screen: scope.modelData + name: "drawers" + WlrLayershell.exclusionMode: ExclusionMode.Ignore + WlrLayershell.keyboardFocus: visibilities.launcher || visibilities.session ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None + + mask: Region { + x: bar.implicitWidth + win.dragMaskPadding + y: Config.border.thickness + win.dragMaskPadding + width: win.width - bar.implicitWidth - Config.border.thickness - win.dragMaskPadding * 2 + height: win.height - Config.border.thickness * 2 - win.dragMaskPadding * 2 + intersection: Intersection.Xor + + regions: regions.instances + } + + anchors.top: true + anchors.bottom: true + anchors.left: true + anchors.right: true + + Variants { + id: regions + + model: panels.children + + Region { + required property Item modelData + + x: modelData.x + bar.implicitWidth + y: modelData.y + Config.border.thickness + width: modelData.width + height: modelData.height + intersection: Intersection.Subtract + } + } + + HyprlandFocusGrab { + id: focusGrab + + active: (visibilities.launcher && Config.launcher.enabled) || (visibilities.session && Config.session.enabled) || (visibilities.sidebar && Config.sidebar.enabled) || (!Config.dashboard.showOnHover && visibilities.dashboard && Config.dashboard.enabled) || (panels.popouts.currentName.startsWith("traymenu") && panels.popouts.current?.depth > 1) + windows: [win] + onCleared: { + visibilities.launcher = false; + visibilities.session = false; + visibilities.sidebar = false; + visibilities.dashboard = false; + panels.popouts.hasCurrent = false; + bar.closeTray(); + } + } + + StyledRect { + anchors.fill: parent + opacity: visibilities.session && Config.session.enabled ? 0.5 : 0 + color: Colours.palette.m3scrim + + Behavior on opacity { + Anim {} + } + } + + Item { + anchors.fill: parent + opacity: Colours.transparency.enabled ? Colours.transparency.base : 1 + layer.enabled: true + layer.effect: MultiEffect { + shadowEnabled: true + blurMax: 15 + shadowColor: Qt.alpha(Colours.palette.m3shadow, 0.7) + } + + Border { + bar: bar + } + + Backgrounds { + panels: panels + bar: bar + } + } + + PersistentProperties { + id: visibilities + + property bool bar + property bool osd + property bool session + property bool launcher + property bool dashboard + property bool utilities + property bool sidebar + + Component.onCompleted: Visibilities.load(scope.modelData, this) + } + + Interactions { + screen: scope.modelData + popouts: panels.popouts + visibilities: visibilities + panels: panels + bar: bar + + Panels { + id: panels + + screen: scope.modelData + visibilities: visibilities + bar: bar + } + + BarWrapper { + id: bar + + anchors.top: parent.top + anchors.bottom: parent.bottom + + screen: scope.modelData + visibilities: visibilities + popouts: panels.popouts + + disabled: scope.barDisabled + + Component.onCompleted: Visibilities.bars.set(scope.modelData, this) + } + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/drawers/Exclusions.qml b/.config/quickshell/caelestia/modules/drawers/Exclusions.qml new file mode 100644 index 0000000..e4015c8 --- /dev/null +++ b/.config/quickshell/caelestia/modules/drawers/Exclusions.qml @@ -0,0 +1,39 @@ +pragma ComponentBehavior: Bound + +import qs.components.containers +import qs.config +import Quickshell +import QtQuick + +Scope { + id: root + + required property ShellScreen screen + required property Item bar + + ExclusionZone { + anchors.left: true + exclusiveZone: root.bar.exclusiveZone + } + + ExclusionZone { + anchors.top: true + } + + ExclusionZone { + anchors.right: true + } + + ExclusionZone { + anchors.bottom: true + } + + component ExclusionZone: StyledWindow { + screen: root.screen + name: "border-exclusion" + exclusiveZone: Config.border.thickness + mask: Region {} + implicitWidth: 1 + implicitHeight: 1 + } +} diff --git a/.config/quickshell/caelestia/modules/drawers/Interactions.qml b/.config/quickshell/caelestia/modules/drawers/Interactions.qml new file mode 100644 index 0000000..9579b15 --- /dev/null +++ b/.config/quickshell/caelestia/modules/drawers/Interactions.qml @@ -0,0 +1,274 @@ +import qs.components.controls +import qs.config +import qs.modules.bar.popouts as BarPopouts +import Quickshell +import QtQuick + +CustomMouseArea { + id: root + + required property ShellScreen screen + required property BarPopouts.Wrapper popouts + required property PersistentProperties visibilities + required property Panels panels + required property Item bar + + property point dragStart + property bool dashboardShortcutActive + property bool osdShortcutActive + property bool utilitiesShortcutActive + + function withinPanelHeight(panel: Item, x: real, y: real): bool { + const panelY = Config.border.thickness + panel.y; + return y >= panelY - Config.border.rounding && y <= panelY + panel.height + Config.border.rounding; + } + + function withinPanelWidth(panel: Item, x: real, y: real): bool { + const panelX = bar.implicitWidth + panel.x; + return x >= panelX - Config.border.rounding && x <= panelX + panel.width + Config.border.rounding; + } + + function inLeftPanel(panel: Item, x: real, y: real): bool { + return x < bar.implicitWidth + panel.x + panel.width && withinPanelHeight(panel, x, y); + } + + function inRightPanel(panel: Item, x: real, y: real): bool { + return x > bar.implicitWidth + panel.x && withinPanelHeight(panel, x, y); + } + + function inTopPanel(panel: Item, x: real, y: real): bool { + return y < Config.border.thickness + panel.y + panel.height && withinPanelWidth(panel, x, y); + } + + function inBottomPanel(panel: Item, x: real, y: real): bool { + return y > root.height - Config.border.thickness - panel.height - Config.border.rounding && withinPanelWidth(panel, x, y); + } + + function onWheel(event: WheelEvent): void { + if (event.x < bar.implicitWidth) { + bar.handleWheel(event.y, event.angleDelta); + } + } + + anchors.fill: parent + hoverEnabled: true + + onPressed: event => dragStart = Qt.point(event.x, event.y) + onContainsMouseChanged: { + if (!containsMouse) { + // Only hide if not activated by shortcut + if (!osdShortcutActive) { + visibilities.osd = false; + root.panels.osd.hovered = false; + } + + if (!dashboardShortcutActive) + visibilities.dashboard = false; + + if (!utilitiesShortcutActive) + visibilities.utilities = false; + + if (!popouts.currentName.startsWith("traymenu") || (popouts.current?.depth ?? 0) <= 1) { + popouts.hasCurrent = false; + bar.closeTray(); + } + + if (Config.bar.showOnHover) + bar.isHovered = false; + } + } + + onPositionChanged: event => { + if (popouts.isDetached) + return; + + const x = event.x; + const y = event.y; + const dragX = x - dragStart.x; + const dragY = y - dragStart.y; + + // Show bar in non-exclusive mode on hover + if (!visibilities.bar && Config.bar.showOnHover && x < bar.implicitWidth) + bar.isHovered = true; + + // Show/hide bar on drag + if (pressed && dragStart.x < bar.implicitWidth) { + if (dragX > Config.bar.dragThreshold) + visibilities.bar = true; + else if (dragX < -Config.bar.dragThreshold) + visibilities.bar = false; + } + + if (panels.sidebar.width === 0) { + // Show osd on hover + const showOsd = inRightPanel(panels.osd, x, y); + + // Always update visibility based on hover if not in shortcut mode + if (!osdShortcutActive) { + visibilities.osd = showOsd; + root.panels.osd.hovered = showOsd; + } else if (showOsd) { + // If hovering over OSD area while in shortcut mode, transition to hover control + osdShortcutActive = false; + root.panels.osd.hovered = true; + } + + const showSidebar = pressed && dragStart.x > bar.implicitWidth + panels.sidebar.x; + + // Show/hide session on drag + if (pressed && inRightPanel(panels.session, dragStart.x, dragStart.y) && withinPanelHeight(panels.session, x, y)) { + if (dragX < -Config.session.dragThreshold) + visibilities.session = true; + else if (dragX > Config.session.dragThreshold) + visibilities.session = false; + + // Show sidebar on drag if in session area and session is nearly fully visible + if (showSidebar && panels.session.width >= panels.session.nonAnimWidth && dragX < -Config.sidebar.dragThreshold) + visibilities.sidebar = true; + } else if (showSidebar && dragX < -Config.sidebar.dragThreshold) { + // Show sidebar on drag if not in session area + visibilities.sidebar = true; + } + } else { + const outOfSidebar = x < width - panels.sidebar.width; + // Show osd on hover + const showOsd = outOfSidebar && inRightPanel(panels.osd, x, y); + + // Always update visibility based on hover if not in shortcut mode + if (!osdShortcutActive) { + visibilities.osd = showOsd; + root.panels.osd.hovered = showOsd; + } else if (showOsd) { + // If hovering over OSD area while in shortcut mode, transition to hover control + osdShortcutActive = false; + root.panels.osd.hovered = true; + } + + // Show/hide session on drag + if (pressed && outOfSidebar && inRightPanel(panels.session, dragStart.x, dragStart.y) && withinPanelHeight(panels.session, x, y)) { + if (dragX < -Config.session.dragThreshold) + visibilities.session = true; + else if (dragX > Config.session.dragThreshold) + visibilities.session = false; + } + + // Hide sidebar on drag + if (pressed && inRightPanel(panels.sidebar, dragStart.x, 0) && dragX > Config.sidebar.dragThreshold) + visibilities.sidebar = false; + } + + // Show launcher on hover, or show/hide on drag if hover is disabled + if (Config.launcher.showOnHover) { + if (!visibilities.launcher && inBottomPanel(panels.launcher, x, y)) + visibilities.launcher = true; + } else if (pressed && inBottomPanel(panels.launcher, dragStart.x, dragStart.y) && withinPanelWidth(panels.launcher, x, y)) { + if (dragY < -Config.launcher.dragThreshold) + visibilities.launcher = true; + else if (dragY > Config.launcher.dragThreshold) + visibilities.launcher = false; + } + + // Show dashboard on hover + const showDashboard = Config.dashboard.showOnHover && inTopPanel(panels.dashboard, x, y); + + // Always update visibility based on hover if not in shortcut mode + if (!dashboardShortcutActive) { + visibilities.dashboard = showDashboard; + } else if (showDashboard) { + // If hovering over dashboard area while in shortcut mode, transition to hover control + dashboardShortcutActive = false; + } + + // Show/hide dashboard on drag (for touchscreen devices) + if (pressed && inTopPanel(panels.dashboard, dragStart.x, dragStart.y) && withinPanelWidth(panels.dashboard, x, y)) { + if (dragY > Config.dashboard.dragThreshold) + visibilities.dashboard = true; + else if (dragY < -Config.dashboard.dragThreshold) + visibilities.dashboard = false; + } + + // Show utilities on hover + const showUtilities = inBottomPanel(panels.utilities, x, y); + + // Always update visibility based on hover if not in shortcut mode + if (!utilitiesShortcutActive) { + visibilities.utilities = showUtilities; + } else if (showUtilities) { + // If hovering over utilities area while in shortcut mode, transition to hover control + utilitiesShortcutActive = false; + } + + // Show popouts on hover + if (x < bar.implicitWidth) { + bar.checkPopout(y); + } else if ((!popouts.currentName.startsWith("traymenu") || (popouts.current?.depth ?? 0) <= 1) && !inLeftPanel(panels.popouts, x, y)) { + popouts.hasCurrent = false; + bar.closeTray(); + } + } + + // Monitor individual visibility changes + Connections { + target: root.visibilities + + function onLauncherChanged() { + // If launcher is hidden, clear shortcut flags for dashboard and OSD + if (!root.visibilities.launcher) { + root.dashboardShortcutActive = false; + root.osdShortcutActive = false; + root.utilitiesShortcutActive = false; + + // Also hide dashboard and OSD if they're not being hovered + const inDashboardArea = root.inTopPanel(root.panels.dashboard, root.mouseX, root.mouseY); + const inOsdArea = root.inRightPanel(root.panels.osd, root.mouseX, root.mouseY); + + if (!inDashboardArea) { + root.visibilities.dashboard = false; + } + if (!inOsdArea) { + root.visibilities.osd = false; + root.panels.osd.hovered = false; + } + } + } + + function onDashboardChanged() { + if (root.visibilities.dashboard) { + // Dashboard became visible, immediately check if this should be shortcut mode + const inDashboardArea = root.inTopPanel(root.panels.dashboard, root.mouseX, root.mouseY); + if (!inDashboardArea) { + root.dashboardShortcutActive = true; + } + } else { + // Dashboard hidden, clear shortcut flag + root.dashboardShortcutActive = false; + } + } + + function onOsdChanged() { + if (root.visibilities.osd) { + // OSD became visible, immediately check if this should be shortcut mode + const inOsdArea = root.inRightPanel(root.panels.osd, root.mouseX, root.mouseY); + if (!inOsdArea) { + root.osdShortcutActive = true; + } + } else { + // OSD hidden, clear shortcut flag + root.osdShortcutActive = false; + } + } + + function onUtilitiesChanged() { + if (root.visibilities.utilities) { + // Utilities became visible, immediately check if this should be shortcut mode + const inUtilitiesArea = root.inBottomPanel(root.panels.utilities, root.mouseX, root.mouseY); + if (!inUtilitiesArea) { + root.utilitiesShortcutActive = true; + } + } else { + // Utilities hidden, clear shortcut flag + root.utilitiesShortcutActive = false; + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/drawers/Panels.qml b/.config/quickshell/caelestia/modules/drawers/Panels.qml new file mode 100644 index 0000000..7705732 --- /dev/null +++ b/.config/quickshell/caelestia/modules/drawers/Panels.qml @@ -0,0 +1,136 @@ +import qs.config +import qs.modules.osd as Osd +import qs.modules.notifications as Notifications +import qs.modules.session as Session +import qs.modules.launcher as Launcher +import qs.modules.dashboard as Dashboard +import qs.modules.bar.popouts as BarPopouts +import qs.modules.utilities as Utilities +import qs.modules.utilities.toasts as Toasts +import qs.modules.sidebar as Sidebar +import Quickshell +import QtQuick + +Item { + id: root + + required property ShellScreen screen + required property PersistentProperties visibilities + required property Item bar + + readonly property alias osd: osd + readonly property alias notifications: notifications + readonly property alias session: session + readonly property alias launcher: launcher + readonly property alias dashboard: dashboard + readonly property alias popouts: popouts + readonly property alias utilities: utilities + readonly property alias toasts: toasts + readonly property alias sidebar: sidebar + + anchors.fill: parent + anchors.margins: Config.border.thickness + anchors.leftMargin: bar.implicitWidth + + Osd.Wrapper { + id: osd + + clip: session.width > 0 || sidebar.width > 0 + screen: root.screen + visibilities: root.visibilities + + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + anchors.rightMargin: session.width + sidebar.width + } + + Notifications.Wrapper { + id: notifications + + visibilities: root.visibilities + panels: root + + anchors.top: parent.top + anchors.right: parent.right + } + + Session.Wrapper { + id: session + + clip: sidebar.width > 0 + visibilities: root.visibilities + panels: root + + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + anchors.rightMargin: sidebar.width + } + + Launcher.Wrapper { + id: launcher + + screen: root.screen + visibilities: root.visibilities + panels: root + + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + } + + Dashboard.Wrapper { + id: dashboard + + visibilities: root.visibilities + + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + } + + BarPopouts.Wrapper { + id: popouts + + screen: root.screen + + x: isDetached ? (root.width - nonAnimWidth) / 2 : 0 + y: { + if (isDetached) + return (root.height - nonAnimHeight) / 2; + + const off = currentCenter - Config.border.thickness - nonAnimHeight / 2; + const diff = root.height - Math.floor(off + nonAnimHeight); + if (diff < 0) + return off + diff; + return Math.max(off, 0); + } + } + + Utilities.Wrapper { + id: utilities + + visibilities: root.visibilities + sidebar: sidebar + popouts: popouts + + anchors.bottom: parent.bottom + anchors.right: parent.right + } + + Toasts.Toasts { + id: toasts + + anchors.bottom: sidebar.visible ? parent.bottom : utilities.top + anchors.right: sidebar.left + anchors.margins: Appearance.padding.normal + } + + Sidebar.Wrapper { + id: sidebar + + visibilities: root.visibilities + panels: root + + anchors.top: notifications.bottom + anchors.bottom: utilities.top + anchors.right: parent.right + } +} diff --git a/.config/quickshell/caelestia/modules/launcher/AppList.qml b/.config/quickshell/caelestia/modules/launcher/AppList.qml new file mode 100644 index 0000000..7f7b843 --- /dev/null +++ b/.config/quickshell/caelestia/modules/launcher/AppList.qml @@ -0,0 +1,257 @@ +pragma ComponentBehavior: Bound + +import "items" +import "services" +import qs.components +import qs.components.controls +import qs.components.containers +import qs.services +import qs.config +import Quickshell +import QtQuick + +StyledListView { + id: root + + required property StyledTextField search + required property PersistentProperties visibilities + + model: ScriptModel { + id: model + + onValuesChanged: root.currentIndex = 0 + } + + spacing: Appearance.spacing.small + orientation: Qt.Vertical + implicitHeight: (Config.launcher.sizes.itemHeight + spacing) * Math.min(Config.launcher.maxShown, count) - spacing + + preferredHighlightBegin: 0 + preferredHighlightEnd: height + highlightRangeMode: ListView.ApplyRange + + highlightFollowsCurrentItem: false + highlight: StyledRect { + radius: Appearance.rounding.normal + color: Colours.palette.m3onSurface + opacity: 0.08 + + y: root.currentItem?.y ?? 0 + implicitWidth: root.width + implicitHeight: root.currentItem?.implicitHeight ?? 0 + + Behavior on y { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + } + + state: { + const text = search.text; + const prefix = Config.launcher.actionPrefix; + if (text.startsWith(prefix)) { + for (const action of ["calc", "scheme", "variant"]) + if (text.startsWith(`${prefix}${action} `)) + return action; + + return "actions"; + } + + return "apps"; + } + + onStateChanged: { + if (state === "scheme" || state === "variant") + Schemes.reload(); + } + + states: [ + State { + name: "apps" + + PropertyChanges { + model.values: Apps.search(search.text) + root.delegate: appItem + } + }, + State { + name: "actions" + + PropertyChanges { + model.values: Actions.query(search.text) + root.delegate: actionItem + } + }, + State { + name: "calc" + + PropertyChanges { + model.values: [0] + root.delegate: calcItem + } + }, + State { + name: "scheme" + + PropertyChanges { + model.values: Schemes.query(search.text) + root.delegate: schemeItem + } + }, + State { + name: "variant" + + PropertyChanges { + model.values: M3Variants.query(search.text) + root.delegate: variantItem + } + } + ] + + transitions: Transition { + SequentialAnimation { + ParallelAnimation { + Anim { + target: root + property: "opacity" + from: 1 + to: 0 + duration: Appearance.anim.durations.small + easing.bezierCurve: Appearance.anim.curves.standardAccel + } + Anim { + target: root + property: "scale" + from: 1 + to: 0.9 + duration: Appearance.anim.durations.small + easing.bezierCurve: Appearance.anim.curves.standardAccel + } + } + PropertyAction { + targets: [model, root] + properties: "values,delegate" + } + ParallelAnimation { + Anim { + target: root + property: "opacity" + from: 0 + to: 1 + duration: Appearance.anim.durations.small + easing.bezierCurve: Appearance.anim.curves.standardDecel + } + Anim { + target: root + property: "scale" + from: 0.9 + to: 1 + duration: Appearance.anim.durations.small + easing.bezierCurve: Appearance.anim.curves.standardDecel + } + } + PropertyAction { + targets: [root.add, root.remove] + property: "enabled" + value: true + } + } + } + + StyledScrollBar.vertical: StyledScrollBar { + flickable: root + } + + add: Transition { + enabled: !root.state + + Anim { + properties: "opacity,scale" + from: 0 + to: 1 + } + } + + remove: Transition { + enabled: !root.state + + Anim { + properties: "opacity,scale" + from: 1 + to: 0 + } + } + + move: Transition { + Anim { + property: "y" + } + Anim { + properties: "opacity,scale" + to: 1 + } + } + + addDisplaced: Transition { + Anim { + property: "y" + duration: Appearance.anim.durations.small + } + Anim { + properties: "opacity,scale" + to: 1 + } + } + + displaced: Transition { + Anim { + property: "y" + } + Anim { + properties: "opacity,scale" + to: 1 + } + } + + Component { + id: appItem + + AppItem { + visibilities: root.visibilities + } + } + + Component { + id: actionItem + + ActionItem { + list: root + } + } + + Component { + id: calcItem + + CalcItem { + list: root + } + } + + Component { + id: schemeItem + + SchemeItem { + list: root + } + } + + Component { + id: variantItem + + VariantItem { + list: root + } + } +} diff --git a/.config/quickshell/caelestia/modules/launcher/Background.qml b/.config/quickshell/caelestia/modules/launcher/Background.qml new file mode 100644 index 0000000..709c7d0 --- /dev/null +++ b/.config/quickshell/caelestia/modules/launcher/Background.qml @@ -0,0 +1,60 @@ +import qs.components +import qs.services +import qs.config +import QtQuick +import QtQuick.Shapes + +ShapePath { + id: root + + required property Wrapper wrapper + readonly property real rounding: Config.border.rounding + readonly property bool flatten: wrapper.height < rounding * 2 + readonly property real roundingY: flatten ? wrapper.height / 2 : rounding + + strokeWidth: -1 + fillColor: Colours.palette.m3surface + + PathArc { + relativeX: root.rounding + relativeY: -root.roundingY + radiusX: root.rounding + radiusY: Math.min(root.rounding, root.wrapper.height) + direction: PathArc.Counterclockwise + } + PathLine { + relativeX: 0 + relativeY: -(root.wrapper.height - root.roundingY * 2) + } + PathArc { + relativeX: root.rounding + relativeY: -root.roundingY + radiusX: root.rounding + radiusY: Math.min(root.rounding, root.wrapper.height) + } + PathLine { + relativeX: root.wrapper.width - root.rounding * 2 + relativeY: 0 + } + PathArc { + relativeX: root.rounding + relativeY: root.roundingY + radiusX: root.rounding + radiusY: Math.min(root.rounding, root.wrapper.height) + } + PathLine { + relativeX: 0 + relativeY: root.wrapper.height - root.roundingY * 2 + } + PathArc { + relativeX: root.rounding + relativeY: root.roundingY + radiusX: root.rounding + radiusY: Math.min(root.rounding, root.wrapper.height) + direction: PathArc.Counterclockwise + } + + Behavior on fillColor { + CAnim {} + } +} diff --git a/.config/quickshell/caelestia/modules/launcher/Content.qml b/.config/quickshell/caelestia/modules/launcher/Content.qml new file mode 100644 index 0000000..c085976 --- /dev/null +++ b/.config/quickshell/caelestia/modules/launcher/Content.qml @@ -0,0 +1,191 @@ +pragma ComponentBehavior: Bound + +import "services" +import qs.components +import qs.components.controls +import qs.services +import qs.config +import Quickshell +import QtQuick + +Item { + id: root + + required property PersistentProperties visibilities + required property var panels + required property real maxHeight + + readonly property int padding: Appearance.padding.large + readonly property int rounding: Appearance.rounding.large + + implicitWidth: listWrapper.width + padding * 2 + implicitHeight: searchWrapper.height + listWrapper.height + padding * 2 + + Item { + id: listWrapper + + implicitWidth: list.width + implicitHeight: list.height + root.padding + + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: searchWrapper.top + anchors.bottomMargin: root.padding + + ContentList { + id: list + + content: root + visibilities: root.visibilities + panels: root.panels + maxHeight: root.maxHeight - searchWrapper.implicitHeight - root.padding * 3 + search: search + padding: root.padding + rounding: root.rounding + } + } + + StyledRect { + id: searchWrapper + + color: Colours.layer(Colours.palette.m3surfaceContainer, 2) + radius: Appearance.rounding.full + + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: root.padding + + implicitHeight: Math.max(searchIcon.implicitHeight, search.implicitHeight, clearIcon.implicitHeight) + + MaterialIcon { + id: searchIcon + + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: root.padding + + text: "search" + color: Colours.palette.m3onSurfaceVariant + } + + StyledTextField { + id: search + + anchors.left: searchIcon.right + anchors.right: clearIcon.left + anchors.leftMargin: Appearance.spacing.small + anchors.rightMargin: Appearance.spacing.small + + topPadding: Appearance.padding.larger + bottomPadding: Appearance.padding.larger + + placeholderText: qsTr("Type \"%1\" for commands").arg(Config.launcher.actionPrefix) + + onAccepted: { + const currentItem = list.currentList?.currentItem; + if (currentItem) { + if (list.showWallpapers) { + if (Colours.scheme === "dynamic" && currentItem.modelData.path !== Wallpapers.actualCurrent) + Wallpapers.previewColourLock = true; + Wallpapers.setWallpaper(currentItem.modelData.path); + root.visibilities.launcher = false; + } else if (text.startsWith(Config.launcher.actionPrefix)) { + if (text.startsWith(`${Config.launcher.actionPrefix}calc `)) + currentItem.onClicked(); + else + currentItem.modelData.onClicked(list.currentList); + } else { + Apps.launch(currentItem.modelData); + root.visibilities.launcher = false; + } + } + } + + Keys.onUpPressed: list.currentList?.decrementCurrentIndex() + Keys.onDownPressed: list.currentList?.incrementCurrentIndex() + + Keys.onEscapePressed: root.visibilities.launcher = false + + Keys.onPressed: event => { + if (!Config.launcher.vimKeybinds) + return; + + if (event.modifiers & Qt.ControlModifier) { + if (event.key === Qt.Key_J) { + list.currentList?.incrementCurrentIndex(); + event.accepted = true; + } else if (event.key === Qt.Key_K) { + list.currentList?.decrementCurrentIndex(); + event.accepted = true; + } + } else if (event.key === Qt.Key_Tab) { + list.currentList?.incrementCurrentIndex(); + event.accepted = true; + } else if (event.key === Qt.Key_Backtab || (event.key === Qt.Key_Tab && (event.modifiers & Qt.ShiftModifier))) { + list.currentList?.decrementCurrentIndex(); + event.accepted = true; + } + } + + Component.onCompleted: forceActiveFocus() + + Connections { + target: root.visibilities + + function onLauncherChanged(): void { + if (!root.visibilities.launcher) + search.text = ""; + } + + function onSessionChanged(): void { + if (!root.visibilities.session) + search.forceActiveFocus(); + } + } + } + + MaterialIcon { + id: clearIcon + + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + anchors.rightMargin: root.padding + + width: search.text ? implicitWidth : implicitWidth / 2 + opacity: { + if (!search.text) + return 0; + if (mouse.pressed) + return 0.7; + if (mouse.containsMouse) + return 0.8; + return 1; + } + + text: "close" + color: Colours.palette.m3onSurfaceVariant + + MouseArea { + id: mouse + + anchors.fill: parent + hoverEnabled: true + cursorShape: search.text ? Qt.PointingHandCursor : undefined + + onClicked: search.text = "" + } + + Behavior on width { + Anim { + duration: Appearance.anim.durations.small + } + } + + Behavior on opacity { + Anim { + duration: Appearance.anim.durations.small + } + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/launcher/ContentList.qml b/.config/quickshell/caelestia/modules/launcher/ContentList.qml new file mode 100644 index 0000000..b2a9c77 --- /dev/null +++ b/.config/quickshell/caelestia/modules/launcher/ContentList.qml @@ -0,0 +1,170 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.components.controls +import qs.services +import qs.config +import qs.utils +import Quickshell +import QtQuick + +Item { + id: root + + required property var content + required property PersistentProperties visibilities + required property var panels + required property real maxHeight + required property StyledTextField search + required property int padding + required property int rounding + + readonly property bool showWallpapers: search.text.startsWith(`${Config.launcher.actionPrefix}wallpaper `) + readonly property Item currentList: showWallpapers ? wallpaperList.item : appList.item + + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + + clip: true + state: showWallpapers ? "wallpapers" : "apps" + + states: [ + State { + name: "apps" + + PropertyChanges { + root.implicitWidth: Config.launcher.sizes.itemWidth + root.implicitHeight: Math.min(root.maxHeight, appList.implicitHeight > 0 ? appList.implicitHeight : empty.implicitHeight) + appList.active: true + } + + AnchorChanges { + anchors.left: root.parent.left + anchors.right: root.parent.right + } + }, + State { + name: "wallpapers" + + PropertyChanges { + root.implicitWidth: Math.max(Config.launcher.sizes.itemWidth * 1.2, wallpaperList.implicitWidth) + root.implicitHeight: Config.launcher.sizes.wallpaperHeight + wallpaperList.active: true + } + } + ] + + Behavior on state { + SequentialAnimation { + Anim { + target: root + property: "opacity" + from: 1 + to: 0 + duration: Appearance.anim.durations.small + } + PropertyAction {} + Anim { + target: root + property: "opacity" + from: 0 + to: 1 + duration: Appearance.anim.durations.small + } + } + } + + Loader { + id: appList + + active: false + + anchors.fill: parent + + sourceComponent: AppList { + search: root.search + visibilities: root.visibilities + } + } + + Loader { + id: wallpaperList + + active: false + + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + + sourceComponent: WallpaperList { + search: root.search + visibilities: root.visibilities + panels: root.panels + content: root.content + } + } + + Row { + id: empty + + opacity: root.currentList?.count === 0 ? 1 : 0 + scale: root.currentList?.count === 0 ? 1 : 0.5 + + spacing: Appearance.spacing.normal + padding: Appearance.padding.large + + anchors.horizontalCenter: parent.horizontalCenter + anchors.verticalCenter: parent.verticalCenter + + MaterialIcon { + text: root.state === "wallpapers" ? "wallpaper_slideshow" : "manage_search" + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.extraLarge + + anchors.verticalCenter: parent.verticalCenter + } + + Column { + anchors.verticalCenter: parent.verticalCenter + + StyledText { + text: root.state === "wallpapers" ? qsTr("No wallpapers found") : qsTr("No results") + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.larger + font.weight: 500 + } + + StyledText { + text: root.state === "wallpapers" && Wallpapers.list.length === 0 ? qsTr("Try putting some wallpapers in %1").arg(Paths.shortenHome(Paths.wallsdir)) : qsTr("Try searching for something else") + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.normal + } + } + + Behavior on opacity { + Anim {} + } + + Behavior on scale { + Anim {} + } + } + + Behavior on implicitWidth { + enabled: root.visibilities.launcher + + Anim { + duration: Appearance.anim.durations.large + easing.bezierCurve: Appearance.anim.curves.emphasizedDecel + } + } + + Behavior on implicitHeight { + enabled: root.visibilities.launcher + + Anim { + duration: Appearance.anim.durations.large + easing.bezierCurve: Appearance.anim.curves.emphasizedDecel + } + } +} diff --git a/.config/quickshell/caelestia/modules/launcher/WallpaperList.qml b/.config/quickshell/caelestia/modules/launcher/WallpaperList.qml new file mode 100644 index 0000000..4aba436 --- /dev/null +++ b/.config/quickshell/caelestia/modules/launcher/WallpaperList.qml @@ -0,0 +1,97 @@ +pragma ComponentBehavior: Bound + +import "items" +import qs.components.controls +import qs.services +import qs.config +import Quickshell +import QtQuick + +PathView { + id: root + + required property StyledTextField search + required property var visibilities + required property var panels + required property var content + + readonly property int itemWidth: Config.launcher.sizes.wallpaperWidth * 0.8 + Appearance.padding.larger * 2 + + readonly property int numItems: { + const screen = QsWindow.window?.screen; + if (!screen) + return 0; + + // Screen width - 4x outer rounding - 2x max side thickness (cause centered) + const barMargins = Math.max(Config.border.thickness, panels.bar.implicitWidth); + let outerMargins = 0; + if (panels.popouts.hasCurrent && panels.popouts.currentCenter + panels.popouts.nonAnimHeight / 2 > screen.height - content.implicitHeight - Config.border.thickness * 2) + outerMargins = panels.popouts.nonAnimWidth; + if ((visibilities.utilities || visibilities.sidebar) && panels.utilities.implicitWidth > outerMargins) + outerMargins = panels.utilities.implicitWidth; + const maxWidth = screen.width - Config.border.rounding * 4 - (barMargins + outerMargins) * 2; + + if (maxWidth <= 0) + return 0; + + const maxItemsOnScreen = Math.floor(maxWidth / itemWidth); + const visible = Math.min(maxItemsOnScreen, Config.launcher.maxWallpapers, scriptModel.values.length); + + if (visible === 2) + return 1; + if (visible > 1 && visible % 2 === 0) + return visible - 1; + return visible; + } + + model: ScriptModel { + id: scriptModel + + readonly property string search: root.search.text.split(" ").slice(1).join(" ") + + values: Wallpapers.query(search) + onValuesChanged: root.currentIndex = search ? 0 : values.findIndex(w => w.path === Wallpapers.actualCurrent) + } + + Component.onCompleted: currentIndex = Wallpapers.list.findIndex(w => w.path === Wallpapers.actualCurrent) + Component.onDestruction: Wallpapers.stopPreview() + + onCurrentItemChanged: { + if (currentItem) + Wallpapers.preview(currentItem.modelData.path); + } + + implicitWidth: Math.min(numItems, count) * itemWidth + pathItemCount: numItems + cacheItemCount: 4 + + snapMode: PathView.SnapToItem + preferredHighlightBegin: 0.5 + preferredHighlightEnd: 0.5 + highlightRangeMode: PathView.StrictlyEnforceRange + + delegate: WallpaperItem { + visibilities: root.visibilities + } + + path: Path { + startY: root.height / 2 + + PathAttribute { + name: "z" + value: 0 + } + PathLine { + x: root.width / 2 + relativeY: 0 + } + PathAttribute { + name: "z" + value: 1 + } + PathLine { + x: root.width + relativeY: 0 + } + } +} diff --git a/.config/quickshell/caelestia/modules/launcher/Wrapper.qml b/.config/quickshell/caelestia/modules/launcher/Wrapper.qml new file mode 100644 index 0000000..d62d726 --- /dev/null +++ b/.config/quickshell/caelestia/modules/launcher/Wrapper.qml @@ -0,0 +1,130 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.config +import Quickshell +import QtQuick + +Item { + id: root + + required property ShellScreen screen + required property PersistentProperties visibilities + required property var panels + + readonly property bool shouldBeActive: visibilities.launcher && Config.launcher.enabled + property int contentHeight + + readonly property real maxHeight: { + let max = screen.height - Config.border.thickness * 2 - Appearance.spacing.large; + if (visibilities.dashboard) + max -= panels.dashboard.nonAnimHeight; + return max; + } + + onMaxHeightChanged: timer.start() + + visible: height > 0 + implicitHeight: 0 + implicitWidth: content.implicitWidth + + onShouldBeActiveChanged: { + if (shouldBeActive) { + timer.stop(); + hideAnim.stop(); + showAnim.start(); + } else { + showAnim.stop(); + hideAnim.start(); + } + } + + SequentialAnimation { + id: showAnim + + Anim { + target: root + property: "implicitHeight" + to: root.contentHeight + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + ScriptAction { + script: root.implicitHeight = Qt.binding(() => content.implicitHeight) + } + } + + SequentialAnimation { + id: hideAnim + + ScriptAction { + script: root.implicitHeight = root.implicitHeight + } + Anim { + target: root + property: "implicitHeight" + to: 0 + easing.bezierCurve: Appearance.anim.curves.emphasized + } + } + + Connections { + target: Config.launcher + + function onEnabledChanged(): void { + timer.start(); + } + + function onMaxShownChanged(): void { + timer.start(); + } + } + + Connections { + target: DesktopEntries.applications + + function onValuesChanged(): void { + if (DesktopEntries.applications.values.length < Config.launcher.maxShown) + timer.start(); + } + } + + Timer { + id: timer + + interval: Appearance.anim.durations.extraLarge + onRunningChanged: { + if (running && !root.shouldBeActive) { + content.visible = false; + content.active = true; + } else { + root.contentHeight = Math.min(root.maxHeight, content.implicitHeight); + content.active = Qt.binding(() => root.shouldBeActive || root.visible); + content.visible = true; + if (showAnim.running) { + showAnim.stop(); + showAnim.start(); + } + } + } + } + + Loader { + id: content + + anchors.top: parent.top + anchors.horizontalCenter: parent.horizontalCenter + + visible: false + active: false + Component.onCompleted: timer.start() + + sourceComponent: Content { + visibilities: root.visibilities + panels: root.panels + maxHeight: root.maxHeight + + Component.onCompleted: root.contentHeight = implicitHeight + } + } +} diff --git a/.config/quickshell/caelestia/modules/launcher/items/ActionItem.qml b/.config/quickshell/caelestia/modules/launcher/items/ActionItem.qml new file mode 100644 index 0000000..e158029 --- /dev/null +++ b/.config/quickshell/caelestia/modules/launcher/items/ActionItem.qml @@ -0,0 +1,70 @@ +import "../services" +import qs.components +import qs.services +import qs.config +import QtQuick + +Item { + id: root + + required property var modelData + required property var list + + implicitHeight: Config.launcher.sizes.itemHeight + + anchors.left: parent?.left + anchors.right: parent?.right + + StateLayer { + radius: Appearance.rounding.normal + + function onClicked(): void { + root.modelData?.onClicked(root.list); + } + } + + Item { + anchors.fill: parent + anchors.leftMargin: Appearance.padding.larger + anchors.rightMargin: Appearance.padding.larger + anchors.margins: Appearance.padding.smaller + + MaterialIcon { + id: icon + + text: root.modelData?.icon ?? "" + font.pointSize: Appearance.font.size.extraLarge + + anchors.verticalCenter: parent.verticalCenter + } + + Item { + anchors.left: icon.right + anchors.leftMargin: Appearance.spacing.normal + anchors.verticalCenter: icon.verticalCenter + + implicitWidth: parent.width - icon.width + implicitHeight: name.implicitHeight + desc.implicitHeight + + StyledText { + id: name + + text: root.modelData?.name ?? "" + font.pointSize: Appearance.font.size.normal + } + + StyledText { + id: desc + + text: root.modelData?.desc ?? "" + font.pointSize: Appearance.font.size.small + color: Colours.palette.m3outline + + elide: Text.ElideRight + width: root.width - icon.width - Appearance.rounding.normal * 2 + + anchors.top: name.bottom + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/launcher/items/AppItem.qml b/.config/quickshell/caelestia/modules/launcher/items/AppItem.qml new file mode 100644 index 0000000..2bd818d --- /dev/null +++ b/.config/quickshell/caelestia/modules/launcher/items/AppItem.qml @@ -0,0 +1,88 @@ +import "../services" +import qs.components +import qs.services +import qs.config +import qs.utils +import Quickshell +import Quickshell.Widgets +import QtQuick + +Item { + id: root + + required property DesktopEntry modelData + required property PersistentProperties visibilities + + implicitHeight: Config.launcher.sizes.itemHeight + + anchors.left: parent?.left + anchors.right: parent?.right + + StateLayer { + radius: Appearance.rounding.normal + + function onClicked(): void { + Apps.launch(root.modelData); + root.visibilities.launcher = false; + } + } + + Item { + anchors.fill: parent + anchors.leftMargin: Appearance.padding.larger + anchors.rightMargin: Appearance.padding.larger + anchors.margins: Appearance.padding.smaller + + IconImage { + id: icon + + source: Quickshell.iconPath(root.modelData?.icon, "image-missing") + implicitSize: parent.height * 0.8 + + anchors.verticalCenter: parent.verticalCenter + } + + Item { + anchors.left: icon.right + anchors.leftMargin: Appearance.spacing.normal + anchors.verticalCenter: icon.verticalCenter + + implicitWidth: parent.width - icon.width - favouriteIcon.width + implicitHeight: name.implicitHeight + comment.implicitHeight + + StyledText { + id: name + + text: root.modelData?.name ?? "" + font.pointSize: Appearance.font.size.normal + } + + StyledText { + id: comment + + text: (root.modelData?.comment || root.modelData?.genericName || root.modelData?.name) ?? "" + font.pointSize: Appearance.font.size.small + color: Colours.palette.m3outline + + elide: Text.ElideRight + width: root.width - icon.width - favouriteIcon.width - Appearance.rounding.normal * 2 + + anchors.top: name.bottom + } + } + + Loader { + id: favouriteIcon + + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + active: modelData && Strings.testRegexList(Config.launcher.favouriteApps, modelData.id) + + sourceComponent: MaterialIcon { + text: "favorite" + fill: 1 + color: Colours.palette.m3primary + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/launcher/items/CalcItem.qml b/.config/quickshell/caelestia/modules/launcher/items/CalcItem.qml new file mode 100644 index 0000000..65489d9 --- /dev/null +++ b/.config/quickshell/caelestia/modules/launcher/items/CalcItem.qml @@ -0,0 +1,123 @@ +import qs.components +import qs.services +import qs.config +import Caelestia +import Quickshell +import QtQuick +import QtQuick.Layouts + +Item { + id: root + + required property var list + readonly property string math: list.search.text.slice(`${Config.launcher.actionPrefix}calc `.length) + + function onClicked(): void { + Quickshell.execDetached(["wl-copy", Qalculator.eval(math, false)]); + root.list.visibilities.launcher = false; + } + + implicitHeight: Config.launcher.sizes.itemHeight + + anchors.left: parent?.left + anchors.right: parent?.right + + StateLayer { + radius: Appearance.rounding.normal + + function onClicked(): void { + root.onClicked(); + } + } + + RowLayout { + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Appearance.padding.larger + + spacing: Appearance.spacing.normal + + MaterialIcon { + text: "function" + font.pointSize: Appearance.font.size.extraLarge + Layout.alignment: Qt.AlignVCenter + } + + StyledText { + id: result + + color: { + if (text.includes("error: ") || text.includes("warning: ")) + return Colours.palette.m3error; + if (!root.math) + return Colours.palette.m3onSurfaceVariant; + return Colours.palette.m3onSurface; + } + + text: root.math.length > 0 ? Qalculator.eval(root.math) : qsTr("Type an expression to calculate") + elide: Text.ElideLeft + + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + } + + StyledRect { + color: Colours.palette.m3tertiary + radius: Appearance.rounding.normal + clip: true + + implicitWidth: (stateLayer.containsMouse ? label.implicitWidth + label.anchors.rightMargin : 0) + icon.implicitWidth + Appearance.padding.normal * 2 + implicitHeight: Math.max(label.implicitHeight, icon.implicitHeight) + Appearance.padding.small * 2 + + Layout.alignment: Qt.AlignVCenter + + StateLayer { + id: stateLayer + + color: Colours.palette.m3onTertiary + + function onClicked(): void { + Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.terminal, "fish", "-C", `exec qalc -i '${root.math}'`]); + root.list.visibilities.launcher = false; + } + } + + StyledText { + id: label + + anchors.verticalCenter: parent.verticalCenter + anchors.right: icon.left + anchors.rightMargin: Appearance.spacing.small + + text: qsTr("Open in calculator") + color: Colours.palette.m3onTertiary + font.pointSize: Appearance.font.size.normal + + opacity: stateLayer.containsMouse ? 1 : 0 + + Behavior on opacity { + Anim {} + } + } + + MaterialIcon { + id: icon + + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + anchors.rightMargin: Appearance.padding.normal + + text: "open_in_new" + color: Colours.palette.m3onTertiary + font.pointSize: Appearance.font.size.large + } + + Behavior on implicitWidth { + Anim { + easing.bezierCurve: Appearance.anim.curves.emphasized + } + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/launcher/items/SchemeItem.qml b/.config/quickshell/caelestia/modules/launcher/items/SchemeItem.qml new file mode 100644 index 0000000..3ff1846 --- /dev/null +++ b/.config/quickshell/caelestia/modules/launcher/items/SchemeItem.qml @@ -0,0 +1,104 @@ +import "../services" +import qs.components +import qs.services +import qs.config +import QtQuick + +Item { + id: root + + required property Schemes.Scheme modelData + required property var list + + implicitHeight: Config.launcher.sizes.itemHeight + + anchors.left: parent?.left + anchors.right: parent?.right + + StateLayer { + radius: Appearance.rounding.normal + + function onClicked(): void { + root.modelData?.onClicked(root.list); + } + } + + Item { + anchors.fill: parent + anchors.leftMargin: Appearance.padding.larger + anchors.rightMargin: Appearance.padding.larger + anchors.margins: Appearance.padding.smaller + + StyledRect { + id: preview + + anchors.verticalCenter: parent.verticalCenter + + border.width: 1 + border.color: Qt.alpha(`#${root.modelData?.colours?.outline}`, 0.5) + + color: `#${root.modelData?.colours?.surface}` + radius: Appearance.rounding.full + implicitWidth: parent.height * 0.8 + implicitHeight: parent.height * 0.8 + + Item { + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.right: parent.right + + implicitWidth: parent.implicitWidth / 2 + clip: true + + StyledRect { + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.right: parent.right + + implicitWidth: preview.implicitWidth + color: `#${root.modelData?.colours?.primary}` + radius: Appearance.rounding.full + } + } + } + + Column { + anchors.left: preview.right + anchors.leftMargin: Appearance.spacing.normal + anchors.verticalCenter: parent.verticalCenter + + width: parent.width - preview.width - anchors.leftMargin - (current.active ? current.width + Appearance.spacing.normal : 0) + spacing: 0 + + StyledText { + text: root.modelData?.flavour ?? "" + font.pointSize: Appearance.font.size.normal + } + + StyledText { + text: root.modelData?.name ?? "" + font.pointSize: Appearance.font.size.small + color: Colours.palette.m3outline + + elide: Text.ElideRight + anchors.left: parent.left + anchors.right: parent.right + } + } + + Loader { + id: current + + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + + active: `${root.modelData?.name} ${root.modelData?.flavour}` === Schemes.currentScheme + + sourceComponent: MaterialIcon { + text: "check" + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.large + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/launcher/items/VariantItem.qml b/.config/quickshell/caelestia/modules/launcher/items/VariantItem.qml new file mode 100644 index 0000000..5c34fa8 --- /dev/null +++ b/.config/quickshell/caelestia/modules/launcher/items/VariantItem.qml @@ -0,0 +1,80 @@ +import "../services" +import qs.components +import qs.services +import qs.config +import QtQuick + +Item { + id: root + + required property M3Variants.Variant modelData + required property var list + + implicitHeight: Config.launcher.sizes.itemHeight + + anchors.left: parent?.left + anchors.right: parent?.right + + StateLayer { + radius: Appearance.rounding.normal + + function onClicked(): void { + root.modelData?.onClicked(root.list); + } + } + + Item { + anchors.fill: parent + anchors.leftMargin: Appearance.padding.larger + anchors.rightMargin: Appearance.padding.larger + anchors.margins: Appearance.padding.smaller + + MaterialIcon { + id: icon + + text: root.modelData?.icon ?? "" + font.pointSize: Appearance.font.size.extraLarge + + anchors.verticalCenter: parent.verticalCenter + } + + Column { + anchors.left: icon.right + anchors.leftMargin: Appearance.spacing.larger + anchors.verticalCenter: icon.verticalCenter + + width: parent.width - icon.width - anchors.leftMargin - (current.active ? current.width + Appearance.spacing.normal : 0) + spacing: 0 + + StyledText { + text: root.modelData?.name ?? "" + font.pointSize: Appearance.font.size.normal + } + + StyledText { + text: root.modelData?.description ?? "" + font.pointSize: Appearance.font.size.small + color: Colours.palette.m3outline + + elide: Text.ElideRight + anchors.left: parent.left + anchors.right: parent.right + } + } + + Loader { + id: current + + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + + active: root.modelData?.variant === Schemes.currentVariant + + sourceComponent: MaterialIcon { + text: "check" + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.large + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/launcher/items/WallpaperItem.qml b/.config/quickshell/caelestia/modules/launcher/items/WallpaperItem.qml new file mode 100644 index 0000000..9fdac3f --- /dev/null +++ b/.config/quickshell/caelestia/modules/launcher/items/WallpaperItem.qml @@ -0,0 +1,98 @@ +import qs.components +import qs.components.effects +import qs.components.images +import qs.services +import qs.config +import Caelestia.Models +import Quickshell +import QtQuick + +Item { + id: root + + required property FileSystemEntry modelData + required property PersistentProperties visibilities + + scale: 0.5 + opacity: 0 + z: PathView.z ?? 0 + + Component.onCompleted: { + scale = Qt.binding(() => PathView.isCurrentItem ? 1 : PathView.onPath ? 0.8 : 0); + opacity = Qt.binding(() => PathView.onPath ? 1 : 0); + } + + implicitWidth: image.width + Appearance.padding.larger * 2 + implicitHeight: image.height + label.height + Appearance.spacing.small / 2 + Appearance.padding.large + Appearance.padding.normal + + StateLayer { + radius: Appearance.rounding.normal + + function onClicked(): void { + Wallpapers.setWallpaper(root.modelData.path); + root.visibilities.launcher = false; + } + } + + Elevation { + anchors.fill: image + radius: image.radius + opacity: root.PathView.isCurrentItem ? 1 : 0 + level: 4 + + Behavior on opacity { + Anim {} + } + } + + StyledClippingRect { + id: image + + anchors.horizontalCenter: parent.horizontalCenter + y: Appearance.padding.large + color: Colours.tPalette.m3surfaceContainer + radius: Appearance.rounding.normal + + implicitWidth: Config.launcher.sizes.wallpaperWidth + implicitHeight: implicitWidth / 16 * 9 + + MaterialIcon { + anchors.centerIn: parent + text: "image" + color: Colours.tPalette.m3outline + font.pointSize: Appearance.font.size.extraLarge * 2 + font.weight: 600 + } + + CachingImage { + path: root.modelData.path + smooth: !root.PathView.view.moving + cache: true + + anchors.fill: parent + } + } + + StyledText { + id: label + + anchors.top: image.bottom + anchors.topMargin: Appearance.spacing.small / 2 + anchors.horizontalCenter: parent.horizontalCenter + + width: image.width - Appearance.padding.normal * 2 + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + renderType: Text.QtRendering + text: root.modelData.relativePath + font.pointSize: Appearance.font.size.normal + } + + Behavior on scale { + Anim {} + } + + Behavior on opacity { + Anim {} + } +} diff --git a/.config/quickshell/caelestia/modules/launcher/services/Actions.qml b/.config/quickshell/caelestia/modules/launcher/services/Actions.qml new file mode 100644 index 0000000..5c1cb6b --- /dev/null +++ b/.config/quickshell/caelestia/modules/launcher/services/Actions.qml @@ -0,0 +1,52 @@ +pragma Singleton + +import ".." +import qs.services +import qs.config +import qs.utils +import Quickshell +import QtQuick + +Searcher { + id: root + + function transformSearch(search: string): string { + return search.slice(Config.launcher.actionPrefix.length); + } + + list: variants.instances + useFuzzy: Config.launcher.useFuzzy.actions + + Variants { + id: variants + + model: Config.launcher.actions.filter(a => (a.enabled ?? true) && (Config.launcher.enableDangerousActions || !(a.dangerous ?? false))) + + Action {} + } + + component Action: QtObject { + required property var modelData + readonly property string name: modelData.name ?? qsTr("Unnamed") + readonly property string desc: modelData.description ?? qsTr("No description") + readonly property string icon: modelData.icon ?? "help_outline" + readonly property list command: modelData.command ?? [] + readonly property bool enabled: modelData.enabled ?? true + readonly property bool dangerous: modelData.dangerous ?? false + + function onClicked(list: AppList): void { + if (command.length === 0) + return; + + if (command[0] === "autocomplete" && command.length > 1) { + list.search.text = `${Config.launcher.actionPrefix}${command[1]} `; + } else if (command[0] === "setMode" && command.length > 1) { + list.visibilities.launcher = false; + Colours.setMode(command[1]); + } else { + list.visibilities.launcher = false; + Quickshell.execDetached(command); + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/launcher/services/Apps.qml b/.config/quickshell/caelestia/modules/launcher/services/Apps.qml new file mode 100644 index 0000000..3002eb5 --- /dev/null +++ b/.config/quickshell/caelestia/modules/launcher/services/Apps.qml @@ -0,0 +1,78 @@ +pragma Singleton + +import qs.config +import qs.utils +import Caelestia +import Quickshell + +Searcher { + id: root + + function launch(entry: DesktopEntry): void { + appDb.incrementFrequency(entry.id); + + if (entry.runInTerminal) + Quickshell.execDetached({ + command: ["app2unit", "--", ...Config.general.apps.terminal, `${Quickshell.shellDir}/assets/wrap_term_launch.sh`, ...entry.command], + workingDirectory: entry.workingDirectory + }); + else + Quickshell.execDetached({ + command: ["app2unit", "--", ...entry.command], + workingDirectory: entry.workingDirectory + }); + } + + function search(search: string): list { + const prefix = Config.launcher.specialPrefix; + + if (search.startsWith(`${prefix}i `)) { + keys = ["id", "name"]; + weights = [0.9, 0.1]; + } else if (search.startsWith(`${prefix}c `)) { + keys = ["categories", "name"]; + weights = [0.9, 0.1]; + } else if (search.startsWith(`${prefix}d `)) { + keys = ["comment", "name"]; + weights = [0.9, 0.1]; + } else if (search.startsWith(`${prefix}e `)) { + keys = ["execString", "name"]; + weights = [0.9, 0.1]; + } else if (search.startsWith(`${prefix}w `)) { + keys = ["startupClass", "name"]; + weights = [0.9, 0.1]; + } else if (search.startsWith(`${prefix}g `)) { + keys = ["genericName", "name"]; + weights = [0.9, 0.1]; + } else if (search.startsWith(`${prefix}k `)) { + keys = ["keywords", "name"]; + weights = [0.9, 0.1]; + } else { + keys = ["name"]; + weights = [1]; + + if (!search.startsWith(`${prefix}t `)) + return query(search).map(e => e.entry); + } + + const results = query(search.slice(prefix.length + 2)).map(e => e.entry); + if (search.startsWith(`${prefix}t `)) + return results.filter(a => a.runInTerminal); + return results; + } + + function selector(item: var): string { + return keys.map(k => item[k]).join(" "); + } + + list: appDb.apps + useFuzzy: Config.launcher.useFuzzy.apps + + AppDb { + id: appDb + + path: `${Paths.state}/apps.sqlite` + // favouriteApps: Config.launcher.favouriteApps + entries: DesktopEntries.applications.values.filter(a => !Strings.testRegexList(Config.launcher.hiddenApps, a.id)) + } +} diff --git a/.config/quickshell/caelestia/modules/launcher/services/M3Variants.qml b/.config/quickshell/caelestia/modules/launcher/services/M3Variants.qml new file mode 100644 index 0000000..963a4d4 --- /dev/null +++ b/.config/quickshell/caelestia/modules/launcher/services/M3Variants.qml @@ -0,0 +1,85 @@ +pragma Singleton + +import ".." +import qs.config +import qs.utils +import Quickshell +import QtQuick + +Searcher { + id: root + + function transformSearch(search: string): string { + return search.slice(`${Config.launcher.actionPrefix}variant `.length); + } + + list: [ + Variant { + variant: "vibrant" + icon: "sentiment_very_dissatisfied" + name: qsTr("Vibrant") + description: qsTr("A high chroma palette. The primary palette's chroma is at maximum.") + }, + Variant { + variant: "tonalspot" + icon: "android" + name: qsTr("Tonal Spot") + description: qsTr("Default for Material theme colours. A pastel palette with a low chroma.") + }, + Variant { + variant: "expressive" + icon: "compare_arrows" + name: qsTr("Expressive") + description: qsTr("A medium chroma palette. The primary palette's hue is different from the seed colour, for variety.") + }, + Variant { + variant: "fidelity" + icon: "compare" + name: qsTr("Fidelity") + description: qsTr("Matches the seed colour, even if the seed colour is very bright (high chroma).") + }, + Variant { + variant: "content" + icon: "sentiment_calm" + name: qsTr("Content") + description: qsTr("Almost identical to fidelity.") + }, + Variant { + variant: "fruitsalad" + icon: "nutrition" + name: qsTr("Fruit Salad") + description: qsTr("A playful theme - the seed colour's hue does not appear in the theme.") + }, + Variant { + variant: "rainbow" + icon: "looks" + name: qsTr("Rainbow") + description: qsTr("A playful theme - the seed colour's hue does not appear in the theme.") + }, + Variant { + variant: "neutral" + icon: "contrast" + name: qsTr("Neutral") + description: qsTr("Close to grayscale, a hint of chroma.") + }, + Variant { + variant: "monochrome" + icon: "filter_b_and_w" + name: qsTr("Monochrome") + description: qsTr("All colours are grayscale, no chroma.") + } + ] + useFuzzy: Config.launcher.useFuzzy.variants + + component Variant: QtObject { + required property string variant + required property string icon + required property string name + required property string description + + function onClicked(list: AppList): void { + list.visibilities.launcher = false; + Quickshell.execDetached(["caelestia", "scheme", "set", "-v", variant]); + } + } +} diff --git a/.config/quickshell/caelestia/modules/launcher/services/Schemes.qml b/.config/quickshell/caelestia/modules/launcher/services/Schemes.qml new file mode 100644 index 0000000..dbb2dac --- /dev/null +++ b/.config/quickshell/caelestia/modules/launcher/services/Schemes.qml @@ -0,0 +1,88 @@ +pragma Singleton + +import ".." +import qs.config +import qs.utils +import Quickshell +import Quickshell.Io +import QtQuick + +Searcher { + id: root + + property string currentScheme + property string currentVariant + + function transformSearch(search: string): string { + return search.slice(`${Config.launcher.actionPrefix}scheme `.length); + } + + function selector(item: var): string { + return `${item.name} ${item.flavour}`; + } + + function reload(): void { + getCurrent.running = true; + } + + list: schemes.instances + useFuzzy: Config.launcher.useFuzzy.schemes + keys: ["name", "flavour"] + weights: [0.9, 0.1] + + Variants { + id: schemes + + Scheme {} + } + + Process { + id: getSchemes + + running: true + command: ["caelestia", "scheme", "list"] + stdout: StdioCollector { + onStreamFinished: { + const schemeData = JSON.parse(text); + const list = Object.entries(schemeData).map(([name, f]) => Object.entries(f).map(([flavour, colours]) => ({ + name, + flavour, + colours + }))); + + const flat = []; + for (const s of list) + for (const f of s) + flat.push(f); + + schemes.model = flat.sort((a, b) => (a.name + a.flavour).localeCompare((b.name + b.flavour))); + } + } + } + + Process { + id: getCurrent + + running: true + command: ["caelestia", "scheme", "get", "-nfv"] + stdout: StdioCollector { + onStreamFinished: { + const [name, flavour, variant] = text.trim().split("\n"); + root.currentScheme = `${name} ${flavour}`; + root.currentVariant = variant; + } + } + } + + component Scheme: QtObject { + required property var modelData + readonly property string name: modelData.name + readonly property string flavour: modelData.flavour + readonly property var colours: modelData.colours + + function onClicked(list: AppList): void { + list.visibilities.launcher = false; + Quickshell.execDetached(["caelestia", "scheme", "set", "-n", name, "-f", flavour]); + } + } +} diff --git a/.config/quickshell/caelestia/modules/lock/Center.qml b/.config/quickshell/caelestia/modules/lock/Center.qml new file mode 100644 index 0000000..19cf9d2 --- /dev/null +++ b/.config/quickshell/caelestia/modules/lock/Center.qml @@ -0,0 +1,416 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.components.controls +import qs.components.images +import qs.services +import qs.config +import qs.utils +import QtQuick +import QtQuick.Layouts + +ColumnLayout { + id: root + + required property var lock + readonly property real centerScale: Math.min(1, (lock.screen?.height ?? 1440) / 1440) + readonly property int centerWidth: Config.lock.sizes.centerWidth * centerScale + + Layout.preferredWidth: centerWidth + Layout.fillWidth: false + Layout.fillHeight: true + + spacing: Appearance.spacing.large * 2 + + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: Appearance.spacing.small + + StyledText { + Layout.alignment: Qt.AlignVCenter + text: Time.hourStr + color: Colours.palette.m3secondary + font.pointSize: Math.floor(Appearance.font.size.extraLarge * 3 * root.centerScale) + font.family: Appearance.font.family.clock + font.bold: true + } + + StyledText { + Layout.alignment: Qt.AlignVCenter + text: ":" + color: Colours.palette.m3primary + font.pointSize: Math.floor(Appearance.font.size.extraLarge * 3 * root.centerScale) + font.family: Appearance.font.family.clock + font.bold: true + } + + StyledText { + Layout.alignment: Qt.AlignVCenter + text: Time.minuteStr + color: Colours.palette.m3secondary + font.pointSize: Math.floor(Appearance.font.size.extraLarge * 3 * root.centerScale) + font.family: Appearance.font.family.clock + font.bold: true + } + + Loader { + Layout.leftMargin: Appearance.spacing.small + Layout.alignment: Qt.AlignVCenter + + active: Config.services.useTwelveHourClock + visible: active + + sourceComponent: StyledText { + text: Time.amPmStr + color: Colours.palette.m3primary + font.pointSize: Math.floor(Appearance.font.size.extraLarge * 2 * root.centerScale) + font.family: Appearance.font.family.clock + font.bold: true + } + } + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: -Appearance.padding.large * 2 + + text: Time.format("dddd, d MMMM yyyy") + color: Colours.palette.m3tertiary + font.pointSize: Math.floor(Appearance.font.size.extraLarge * root.centerScale) + font.family: Appearance.font.family.mono + font.bold: true + } + + StyledClippingRect { + Layout.topMargin: Appearance.spacing.large * 2 + Layout.alignment: Qt.AlignHCenter + + implicitWidth: root.centerWidth / 2 + implicitHeight: root.centerWidth / 2 + + color: Colours.tPalette.m3surfaceContainer + radius: Appearance.rounding.full + + MaterialIcon { + anchors.centerIn: parent + + text: "person" + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Math.floor(root.centerWidth / 4) + } + + CachingImage { + id: pfp + + anchors.fill: parent + path: `${Paths.home}/.face` + } + } + + StyledRect { + Layout.alignment: Qt.AlignHCenter + + implicitWidth: root.centerWidth * 0.8 + implicitHeight: input.implicitHeight + Appearance.padding.small * 2 + + color: Colours.tPalette.m3surfaceContainer + radius: Appearance.rounding.full + + focus: true + onActiveFocusChanged: { + if (!activeFocus) + forceActiveFocus(); + } + + Keys.onPressed: event => { + if (root.lock.unlocking) + return; + + if (event.key === Qt.Key_Enter || event.key === Qt.Key_Return) + inputField.placeholder.animate = false; + + root.lock.pam.handleKey(event); + } + + StateLayer { + hoverEnabled: false + cursorShape: Qt.IBeamCursor + + function onClicked(): void { + parent.forceActiveFocus(); + } + } + + RowLayout { + id: input + + anchors.fill: parent + anchors.margins: Appearance.padding.small + spacing: Appearance.spacing.normal + + Item { + implicitWidth: implicitHeight + implicitHeight: fprintIcon.implicitHeight + Appearance.padding.small * 2 + + MaterialIcon { + id: fprintIcon + + anchors.centerIn: parent + animate: true + text: { + if (root.lock.pam.fprint.tries >= Config.lock.maxFprintTries) + return "fingerprint_off"; + if (root.lock.pam.fprint.active) + return "fingerprint"; + return "lock"; + } + color: root.lock.pam.fprint.tries >= Config.lock.maxFprintTries ? Colours.palette.m3error : Colours.palette.m3onSurface + opacity: root.lock.pam.passwd.active ? 0 : 1 + + Behavior on opacity { + Anim {} + } + } + + CircularIndicator { + anchors.fill: parent + running: root.lock.pam.passwd.active + } + } + + InputField { + id: inputField + + pam: root.lock.pam + } + + StyledRect { + implicitWidth: implicitHeight + implicitHeight: enterIcon.implicitHeight + Appearance.padding.small * 2 + + color: root.lock.pam.buffer ? Colours.palette.m3primary : Colours.layer(Colours.palette.m3surfaceContainerHigh, 2) + radius: Appearance.rounding.full + + StateLayer { + color: root.lock.pam.buffer ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface + + function onClicked(): void { + root.lock.pam.passwd.start(); + } + } + + MaterialIcon { + id: enterIcon + + anchors.centerIn: parent + text: "arrow_forward" + color: root.lock.pam.buffer ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface + font.weight: 500 + } + } + } + } + + Item { + Layout.fillWidth: true + Layout.topMargin: -Appearance.spacing.large + + implicitHeight: Math.max(message.implicitHeight, stateMessage.implicitHeight) + + Behavior on implicitHeight { + Anim {} + } + + StyledText { + id: stateMessage + + readonly property string msg: { + if (Hypr.kbLayout !== Hypr.defaultKbLayout) { + if (Hypr.capsLock && Hypr.numLock) + return qsTr("Caps lock and Num lock are ON.\nKeyboard layout: %1").arg(Hypr.kbLayoutFull); + if (Hypr.capsLock) + return qsTr("Caps lock is ON. Kb layout: %1").arg(Hypr.kbLayoutFull); + if (Hypr.numLock) + return qsTr("Num lock is ON. Kb layout: %1").arg(Hypr.kbLayoutFull); + return qsTr("Keyboard layout: %1").arg(Hypr.kbLayoutFull); + } + + if (Hypr.capsLock && Hypr.numLock) + return qsTr("Caps lock and Num lock are ON."); + if (Hypr.capsLock) + return qsTr("Caps lock is ON."); + if (Hypr.numLock) + return qsTr("Num lock is ON."); + + return ""; + } + + property bool shouldBeVisible + + onMsgChanged: { + if (msg) { + if (opacity > 0) { + animate = true; + text = msg; + animate = false; + } else { + text = msg; + } + shouldBeVisible = true; + } else { + shouldBeVisible = false; + } + } + + anchors.left: parent.left + anchors.right: parent.right + + scale: shouldBeVisible && !message.msg ? 1 : 0.7 + opacity: shouldBeVisible && !message.msg ? 1 : 0 + color: Colours.palette.m3onSurfaceVariant + animateProp: "opacity" + + font.family: Appearance.font.family.mono + horizontalAlignment: Qt.AlignHCenter + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + lineHeight: 1.2 + + Behavior on scale { + Anim {} + } + + Behavior on opacity { + Anim {} + } + } + + StyledText { + id: message + + readonly property Pam pam: root.lock.pam + readonly property string msg: { + if (pam.fprintState === "error") + return qsTr("FP ERROR: %1").arg(pam.fprint.message); + if (pam.state === "error") + return qsTr("PW ERROR: %1").arg(pam.passwd.message); + + if (pam.lockMessage) + return pam.lockMessage; + + if (pam.state === "max" && pam.fprintState === "max") + return qsTr("Maximum password and fingerprint attempts reached."); + if (pam.state === "max") { + if (pam.fprint.available) + return qsTr("Maximum password attempts reached. Please use fingerprint."); + return qsTr("Maximum password attempts reached."); + } + if (pam.fprintState === "max") + return qsTr("Maximum fingerprint attempts reached. Please use password."); + + if (pam.state === "fail") { + if (pam.fprint.available) + return qsTr("Incorrect password. Please try again or use fingerprint."); + return qsTr("Incorrect password. Please try again."); + } + if (pam.fprintState === "fail") + return qsTr("Fingerprint not recognized (%1/%2). Please try again or use password.").arg(pam.fprint.tries).arg(Config.lock.maxFprintTries); + + return ""; + } + + anchors.left: parent.left + anchors.right: parent.right + + scale: 0.7 + opacity: 0 + color: Colours.palette.m3error + + font.pointSize: Appearance.font.size.small + font.family: Appearance.font.family.mono + horizontalAlignment: Qt.AlignHCenter + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + + onMsgChanged: { + if (msg) { + if (opacity > 0) { + animate = true; + text = msg; + animate = false; + + exitAnim.stop(); + if (scale < 1) + appearAnim.restart(); + else + flashAnim.restart(); + } else { + text = msg; + exitAnim.stop(); + appearAnim.restart(); + } + } else { + appearAnim.stop(); + flashAnim.stop(); + exitAnim.start(); + } + } + + Connections { + target: root.lock.pam + + function onFlashMsg(): void { + exitAnim.stop(); + if (message.scale < 1) + appearAnim.restart(); + else + flashAnim.restart(); + } + } + + Anim { + id: appearAnim + + target: message + properties: "scale,opacity" + to: 1 + onFinished: flashAnim.restart() + } + + SequentialAnimation { + id: flashAnim + + loops: 2 + + FlashAnim { + to: 0.3 + } + FlashAnim { + to: 1 + } + } + + ParallelAnimation { + id: exitAnim + + Anim { + target: message + property: "scale" + to: 0.7 + duration: Appearance.anim.durations.large + } + Anim { + target: message + property: "opacity" + to: 0 + duration: Appearance.anim.durations.large + } + } + } + } + + component FlashAnim: NumberAnimation { + target: message + property: "opacity" + duration: Appearance.anim.durations.small + easing.type: Easing.Linear + } +} diff --git a/.config/quickshell/caelestia/modules/lock/Content.qml b/.config/quickshell/caelestia/modules/lock/Content.qml new file mode 100644 index 0000000..a024ddc --- /dev/null +++ b/.config/quickshell/caelestia/modules/lock/Content.qml @@ -0,0 +1,93 @@ +import qs.components +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +RowLayout { + id: root + + required property var lock + + spacing: Appearance.spacing.large * 2 + + ColumnLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + StyledRect { + Layout.fillWidth: true + implicitHeight: weather.implicitHeight + + topLeftRadius: Appearance.rounding.large + radius: Appearance.rounding.small + color: Colours.tPalette.m3surfaceContainer + + WeatherInfo { + id: weather + + rootHeight: root.height + } + } + + StyledRect { + Layout.fillWidth: true + Layout.fillHeight: true + + radius: Appearance.rounding.small + color: Colours.tPalette.m3surfaceContainer + + Fetch {} + } + + StyledClippingRect { + Layout.fillWidth: true + implicitHeight: media.implicitHeight + + bottomLeftRadius: Appearance.rounding.large + radius: Appearance.rounding.small + color: Colours.tPalette.m3surfaceContainer + + Media { + id: media + + lock: root.lock + } + } + } + + Center { + lock: root.lock + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + StyledRect { + Layout.fillWidth: true + implicitHeight: resources.implicitHeight + + topRightRadius: Appearance.rounding.large + radius: Appearance.rounding.small + color: Colours.tPalette.m3surfaceContainer + + Resources { + id: resources + } + } + + StyledRect { + Layout.fillWidth: true + Layout.fillHeight: true + + bottomRightRadius: Appearance.rounding.large + radius: Appearance.rounding.small + color: Colours.tPalette.m3surfaceContainer + + NotifDock { + lock: root.lock + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/lock/Fetch.qml b/.config/quickshell/caelestia/modules/lock/Fetch.qml new file mode 100644 index 0000000..55d6aa7 --- /dev/null +++ b/.config/quickshell/caelestia/modules/lock/Fetch.qml @@ -0,0 +1,178 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.components.effects +import qs.services +import qs.config +import qs.utils +import Quickshell.Services.UPower +import QtQuick +import QtQuick.Layouts + +ColumnLayout { + id: root + + anchors.fill: parent + anchors.margins: Appearance.padding.large * 2 + anchors.topMargin: Appearance.padding.large + + spacing: Appearance.spacing.small + + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: false + spacing: Appearance.spacing.normal + + StyledRect { + implicitWidth: prompt.implicitWidth + Appearance.padding.normal * 2 + implicitHeight: prompt.implicitHeight + Appearance.padding.normal * 2 + + color: Colours.palette.m3primary + radius: Appearance.rounding.small + + MonoText { + id: prompt + + anchors.centerIn: parent + text: ">" + font.pointSize: root.width > 400 ? Appearance.font.size.larger : Appearance.font.size.normal + color: Colours.palette.m3onPrimary + } + } + + MonoText { + Layout.fillWidth: true + text: "caelestiafetch.sh" + font.pointSize: root.width > 400 ? Appearance.font.size.larger : Appearance.font.size.normal + elide: Text.ElideRight + } + + WrappedLoader { + Layout.fillHeight: true + active: !iconLoader.active + + sourceComponent: SysInfo.isDefaultLogo ? caelestiaLogo : distroIcon + } + } + + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: false + spacing: height * 0.15 + + WrappedLoader { + id: iconLoader + + Layout.fillHeight: true + active: root.width > 320 + + sourceComponent: SysInfo.isDefaultLogo ? caelestiaLogo : distroIcon + } + + ColumnLayout { + Layout.fillWidth: true + Layout.topMargin: Appearance.padding.normal + Layout.bottomMargin: Appearance.padding.normal + Layout.leftMargin: iconLoader.active ? 0 : width * 0.1 + spacing: Appearance.spacing.normal + + WrappedLoader { + Layout.fillWidth: true + active: !batLoader.active && root.height > 200 + + sourceComponent: FetchText { + text: `OS : ${SysInfo.osPrettyName || SysInfo.osName}` + } + } + + WrappedLoader { + Layout.fillWidth: true + active: root.height > (batLoader.active ? 200 : 110) + + sourceComponent: FetchText { + text: `WM : ${SysInfo.wm}` + } + } + + WrappedLoader { + Layout.fillWidth: true + active: !batLoader.active || root.height > 110 + + sourceComponent: FetchText { + text: `USER: ${SysInfo.user}` + } + } + + FetchText { + text: `UP : ${SysInfo.uptime}` + } + + WrappedLoader { + id: batLoader + + Layout.fillWidth: true + active: UPower.displayDevice.isLaptopBattery + + sourceComponent: FetchText { + text: `BATT: ${[UPowerDeviceState.Charging, UPowerDeviceState.FullyCharged, UPowerDeviceState.PendingCharge].includes(UPower.displayDevice.state) ? "(+) " : ""}${Math.round(UPower.displayDevice.percentage * 100)}%` + } + } + } + } + + WrappedLoader { + Layout.alignment: Qt.AlignHCenter + active: root.height > 180 + + sourceComponent: RowLayout { + spacing: Appearance.spacing.large + + Repeater { + model: Math.max(0, Math.min(8, root.width / (Appearance.font.size.larger * 2 + Appearance.spacing.large))) + + StyledRect { + required property int index + + implicitWidth: implicitHeight + implicitHeight: Appearance.font.size.larger * 2 + color: Colours.palette[`term${index}`] + radius: Appearance.rounding.small + } + } + } + } + + Component { + id: caelestiaLogo + + Logo { + width: height + height: height + } + } + + Component { + id: distroIcon + + ColouredIcon { + source: SysInfo.osLogo + implicitSize: height + colour: Colours.palette.m3primary + layer.enabled: Config.lock.recolourLogo + } + } + + component WrappedLoader: Loader { + visible: active + } + + component FetchText: MonoText { + Layout.fillWidth: true + font.pointSize: root.width > 400 ? Appearance.font.size.larger : Appearance.font.size.normal + elide: Text.ElideRight + } + + component MonoText: StyledText { + font.family: Appearance.font.family.mono + } +} diff --git a/.config/quickshell/caelestia/modules/lock/InputField.qml b/.config/quickshell/caelestia/modules/lock/InputField.qml new file mode 100644 index 0000000..358093f --- /dev/null +++ b/.config/quickshell/caelestia/modules/lock/InputField.qml @@ -0,0 +1,149 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.services +import qs.config +import Quickshell +import QtQuick +import QtQuick.Layouts + +Item { + id: root + + required property Pam pam + readonly property alias placeholder: placeholder + property string buffer + + Layout.fillWidth: true + Layout.fillHeight: true + + clip: true + + Connections { + target: root.pam + + function onBufferChanged(): void { + if (root.pam.buffer.length > root.buffer.length) { + charList.bindImWidth(); + } else if (root.pam.buffer.length === 0) { + charList.implicitWidth = charList.implicitWidth; + placeholder.animate = true; + } + + root.buffer = root.pam.buffer; + } + } + + StyledText { + id: placeholder + + anchors.centerIn: parent + + text: { + if (root.pam.passwd.active) + return qsTr("Loading..."); + if (root.pam.state === "max") + return qsTr("You have reached the maximum number of tries"); + return qsTr("Enter your password"); + } + + animate: true + color: root.pam.passwd.active ? Colours.palette.m3secondary : Colours.palette.m3outline + font.pointSize: Appearance.font.size.normal + font.family: Appearance.font.family.mono + + opacity: root.buffer ? 0 : 1 + + Behavior on opacity { + Anim {} + } + } + + ListView { + id: charList + + readonly property int fullWidth: count * (implicitHeight + spacing) - spacing + + function bindImWidth(): void { + imWidthBehavior.enabled = false; + implicitWidth = Qt.binding(() => fullWidth); + imWidthBehavior.enabled = true; + } + + anchors.centerIn: parent + anchors.horizontalCenterOffset: implicitWidth > root.width ? -(implicitWidth - root.width) / 2 : 0 + + implicitWidth: fullWidth + implicitHeight: Appearance.font.size.normal + + orientation: Qt.Horizontal + spacing: Appearance.spacing.small / 2 + interactive: false + + model: ScriptModel { + values: root.buffer.split("") + } + + delegate: StyledRect { + id: ch + + implicitWidth: implicitHeight + implicitHeight: charList.implicitHeight + + color: Colours.palette.m3onSurface + radius: Appearance.rounding.small / 2 + + opacity: 0 + scale: 0 + Component.onCompleted: { + opacity = 1; + scale = 1; + } + ListView.onRemove: removeAnim.start() + + SequentialAnimation { + id: removeAnim + + PropertyAction { + target: ch + property: "ListView.delayRemove" + value: true + } + ParallelAnimation { + Anim { + target: ch + property: "opacity" + to: 0 + } + Anim { + target: ch + property: "scale" + to: 0.5 + } + } + PropertyAction { + target: ch + property: "ListView.delayRemove" + value: false + } + } + + Behavior on opacity { + Anim {} + } + + Behavior on scale { + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + } + } + } + + Behavior on implicitWidth { + id: imWidthBehavior + + Anim {} + } + } +} diff --git a/.config/quickshell/caelestia/modules/lock/Lock.qml b/.config/quickshell/caelestia/modules/lock/Lock.qml new file mode 100644 index 0000000..6fd5277 --- /dev/null +++ b/.config/quickshell/caelestia/modules/lock/Lock.qml @@ -0,0 +1,55 @@ +pragma ComponentBehavior: Bound + +import qs.components.misc +import Quickshell +import Quickshell.Io +import Quickshell.Wayland + +Scope { + property alias lock: lock + + WlSessionLock { + id: lock + + signal unlock + + LockSurface { + lock: lock + pam: pam + } + } + + Pam { + id: pam + + lock: lock + } + + CustomShortcut { + name: "lock" + description: "Lock the current session" + onPressed: lock.locked = true + } + + CustomShortcut { + name: "unlock" + description: "Unlock the current session" + onPressed: lock.unlock() + } + + IpcHandler { + target: "lock" + + function lock(): void { + lock.locked = true; + } + + function unlock(): void { + lock.unlock(); + } + + function isLocked(): bool { + return lock.locked; + } + } +} diff --git a/.config/quickshell/caelestia/modules/lock/LockSurface.qml b/.config/quickshell/caelestia/modules/lock/LockSurface.qml new file mode 100644 index 0000000..279c551 --- /dev/null +++ b/.config/quickshell/caelestia/modules/lock/LockSurface.qml @@ -0,0 +1,230 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.services +import qs.config +import Quickshell.Wayland +import QtQuick +import QtQuick.Effects + +WlSessionLockSurface { + id: root + + required property WlSessionLock lock + required property Pam pam + + readonly property alias unlocking: unlockAnim.running + + color: "transparent" + + Connections { + target: root.lock + + function onUnlock(): void { + unlockAnim.start(); + } + } + + SequentialAnimation { + id: unlockAnim + + ParallelAnimation { + Anim { + target: lockContent + properties: "implicitWidth,implicitHeight" + to: lockContent.size + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + Anim { + target: lockBg + property: "radius" + to: lockContent.radius + } + Anim { + target: content + property: "scale" + to: 0 + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + Anim { + target: content + property: "opacity" + to: 0 + duration: Appearance.anim.durations.small + } + Anim { + target: lockIcon + property: "opacity" + to: 1 + duration: Appearance.anim.durations.large + } + Anim { + target: background + property: "opacity" + to: 0 + duration: Appearance.anim.durations.large + } + SequentialAnimation { + PauseAnimation { + duration: Appearance.anim.durations.small + } + Anim { + target: lockContent + property: "opacity" + to: 0 + } + } + } + PropertyAction { + target: root.lock + property: "locked" + value: false + } + } + + ParallelAnimation { + id: initAnim + + running: true + + Anim { + target: background + property: "opacity" + to: 1 + duration: Appearance.anim.durations.large + } + SequentialAnimation { + ParallelAnimation { + Anim { + target: lockContent + property: "scale" + to: 1 + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + } + Anim { + target: lockContent + property: "rotation" + to: 360 + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.standardAccel + } + } + ParallelAnimation { + Anim { + target: lockIcon + property: "rotation" + to: 360 + easing.bezierCurve: Appearance.anim.curves.standardDecel + } + Anim { + target: lockIcon + property: "opacity" + to: 0 + } + Anim { + target: content + property: "opacity" + to: 1 + } + Anim { + target: content + property: "scale" + to: 1 + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + Anim { + target: lockBg + property: "radius" + to: Appearance.rounding.large * 1.5 + } + Anim { + target: lockContent + property: "implicitWidth" + to: (root.screen?.height ?? 0) * Config.lock.sizes.heightMult * Config.lock.sizes.ratio + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + Anim { + target: lockContent + property: "implicitHeight" + to: (root.screen?.height ?? 0) * Config.lock.sizes.heightMult + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + } + } + + ScreencopyView { + id: background + + anchors.fill: parent + captureSource: root.screen + opacity: 0 + + layer.enabled: true + layer.effect: MultiEffect { + autoPaddingEnabled: false + blurEnabled: true + blur: 1 + blurMax: 64 + blurMultiplier: 1 + } + } + + Item { + id: lockContent + + readonly property int size: lockIcon.implicitHeight + Appearance.padding.large * 4 + readonly property int radius: size / 4 * Appearance.rounding.scale + + anchors.centerIn: parent + implicitWidth: size + implicitHeight: size + + rotation: 180 + scale: 0 + + StyledRect { + id: lockBg + + anchors.fill: parent + color: Colours.palette.m3surface + radius: parent.radius + opacity: Colours.transparency.enabled ? Colours.transparency.base : 1 + + layer.enabled: true + layer.effect: MultiEffect { + shadowEnabled: true + blurMax: 15 + shadowColor: Qt.alpha(Colours.palette.m3shadow, 0.7) + } + } + + MaterialIcon { + id: lockIcon + + anchors.centerIn: parent + text: "lock" + font.pointSize: Appearance.font.size.extraLarge * 4 + font.bold: true + rotation: 180 + } + + Content { + id: content + + anchors.centerIn: parent + width: (root.screen?.height ?? 0) * Config.lock.sizes.heightMult * Config.lock.sizes.ratio - Appearance.padding.large * 2 + height: (root.screen?.height ?? 0) * Config.lock.sizes.heightMult - Appearance.padding.large * 2 + + lock: root + opacity: 0 + scale: 0 + } + } +} diff --git a/.config/quickshell/caelestia/modules/lock/Media.qml b/.config/quickshell/caelestia/modules/lock/Media.qml new file mode 100644 index 0000000..b7e58bb --- /dev/null +++ b/.config/quickshell/caelestia/modules/lock/Media.qml @@ -0,0 +1,208 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.components.effects +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +Item { + id: root + + required property var lock + + anchors.left: parent.left + anchors.right: parent.right + implicitHeight: layout.implicitHeight + + Image { + anchors.fill: parent + source: Players.active?.trackArtUrl ?? "" + + asynchronous: true + fillMode: Image.PreserveAspectCrop + sourceSize.width: width + sourceSize.height: height + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: mask + } + + opacity: status === Image.Ready ? 1 : 0 + + Behavior on opacity { + Anim { + duration: Appearance.anim.durations.extraLarge + } + } + } + + Rectangle { + id: mask + + anchors.fill: parent + layer.enabled: true + visible: false + + gradient: Gradient { + orientation: Gradient.Horizontal + + GradientStop { + position: 0 + color: Qt.rgba(0, 0, 0, 0.5) + } + GradientStop { + position: 0.4 + color: Qt.rgba(0, 0, 0, 0.2) + } + GradientStop { + position: 0.8 + color: Qt.rgba(0, 0, 0, 0) + } + } + } + + ColumnLayout { + id: layout + + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: Appearance.padding.large + + StyledText { + Layout.topMargin: Appearance.padding.large + Layout.bottomMargin: Appearance.spacing.larger + text: qsTr("Now playing") + color: Colours.palette.m3onSurfaceVariant + font.family: Appearance.font.family.mono + font.weight: 500 + } + + StyledText { + Layout.fillWidth: true + animate: true + text: Players.active?.trackArtist ?? qsTr("No media") + color: Colours.palette.m3primary + horizontalAlignment: Text.AlignHCenter + font.pointSize: Appearance.font.size.large + font.family: Appearance.font.family.mono + font.weight: 600 + elide: Text.ElideRight + } + + StyledText { + Layout.fillWidth: true + animate: true + text: Players.active?.trackTitle ?? qsTr("No media") + horizontalAlignment: Text.AlignHCenter + font.pointSize: Appearance.font.size.larger + font.family: Appearance.font.family.mono + elide: Text.ElideRight + } + + RowLayout { + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: Appearance.spacing.large * 1.2 + Layout.bottomMargin: Appearance.padding.large + + spacing: Appearance.spacing.large + + PlayerControl { + icon: "skip_previous" + + function onClicked(): void { + if (Players.active?.canGoPrevious) + Players.active.previous(); + } + } + + PlayerControl { + animate: true + icon: active ? "pause" : "play_arrow" + colour: "Primary" + level: active ? 2 : 1 + active: Players.active?.isPlaying ?? false + + function onClicked(): void { + if (Players.active?.canTogglePlaying) + Players.active.togglePlaying(); + } + } + + PlayerControl { + icon: "skip_next" + + function onClicked(): void { + if (Players.active?.canGoNext) + Players.active.next(); + } + } + } + } + + component PlayerControl: StyledRect { + id: control + + property alias animate: controlIcon.animate + property alias icon: controlIcon.text + property bool active + property string colour: "Secondary" + property int level: 1 + + function onClicked(): void { + } + + Layout.preferredWidth: implicitWidth + (controlState.pressed ? Appearance.padding.normal * 2 : active ? Appearance.padding.small * 2 : 0) + implicitWidth: controlIcon.implicitWidth + Appearance.padding.large * 2 + implicitHeight: controlIcon.implicitHeight + Appearance.padding.normal * 2 + + color: active ? Colours.palette[`m3${colour.toLowerCase()}`] : Colours.palette[`m3${colour.toLowerCase()}Container`] + radius: active || controlState.pressed ? Appearance.rounding.normal : Math.min(implicitWidth, implicitHeight) / 2 * Math.min(1, Appearance.rounding.scale) + + Elevation { + anchors.fill: parent + radius: parent.radius + z: -1 + level: controlState.containsMouse && !controlState.pressed ? control.level + 1 : control.level + } + + StateLayer { + id: controlState + + color: control.active ? Colours.palette[`m3on${control.colour}`] : Colours.palette[`m3on${control.colour}Container`] + + function onClicked(): void { + control.onClicked(); + } + } + + MaterialIcon { + id: controlIcon + + anchors.centerIn: parent + color: control.active ? Colours.palette[`m3on${control.colour}`] : Colours.palette[`m3on${control.colour}Container`] + font.pointSize: Appearance.font.size.large + fill: control.active ? 1 : 0 + + Behavior on fill { + Anim {} + } + } + + Behavior on Layout.preferredWidth { + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + } + } + + Behavior on radius { + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/lock/NotifDock.qml b/.config/quickshell/caelestia/modules/lock/NotifDock.qml new file mode 100644 index 0000000..01f7e4b --- /dev/null +++ b/.config/quickshell/caelestia/modules/lock/NotifDock.qml @@ -0,0 +1,145 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.components.containers +import qs.components.effects +import qs.services +import qs.config +import Quickshell +import Quickshell.Widgets +import QtQuick +import QtQuick.Layouts + +ColumnLayout { + id: root + + required property var lock + + anchors.fill: parent + anchors.margins: Appearance.padding.large + + spacing: Appearance.spacing.smaller + + StyledText { + Layout.fillWidth: true + text: Notifs.list.length > 0 ? qsTr("%1 notification%2").arg(Notifs.list.length).arg(Notifs.list.length === 1 ? "" : "s") : qsTr("Notifications") + color: Colours.palette.m3outline + font.family: Appearance.font.family.mono + font.weight: 500 + elide: Text.ElideRight + } + + ClippingRectangle { + id: clipRect + + Layout.fillWidth: true + Layout.fillHeight: true + + radius: Appearance.rounding.small + color: "transparent" + + Loader { + anchors.centerIn: parent + active: opacity > 0 + opacity: Notifs.list.length > 0 ? 0 : 1 + + sourceComponent: ColumnLayout { + spacing: Appearance.spacing.large + + Image { + asynchronous: true + source: Qt.resolvedUrl(`${Quickshell.shellDir}/assets/dino.png`) + fillMode: Image.PreserveAspectFit + sourceSize.width: clipRect.width * 0.8 + + layer.enabled: true + layer.effect: Colouriser { + colorizationColor: Colours.palette.m3outlineVariant + brightness: 1 + } + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: qsTr("No Notifications") + color: Colours.palette.m3outlineVariant + font.pointSize: Appearance.font.size.large + font.family: Appearance.font.family.mono + font.weight: 500 + } + } + + Behavior on opacity { + Anim { + duration: Appearance.anim.durations.extraLarge + } + } + } + + StyledListView { + anchors.fill: parent + + spacing: Appearance.spacing.small + clip: true + + model: ScriptModel { + values: { + const list = Notifs.notClosed.map(n => [n.appName, null]); + return [...new Map(list).keys()]; + } + } + + delegate: NotifGroup {} + + add: Transition { + Anim { + property: "opacity" + from: 0 + to: 1 + } + Anim { + property: "scale" + from: 0 + to: 1 + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + + remove: Transition { + Anim { + property: "opacity" + to: 0 + } + Anim { + property: "scale" + to: 0.6 + } + } + + move: Transition { + Anim { + properties: "opacity,scale" + to: 1 + } + Anim { + property: "y" + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + + displaced: Transition { + Anim { + properties: "opacity,scale" + to: 1 + } + Anim { + property: "y" + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/lock/NotifGroup.qml b/.config/quickshell/caelestia/modules/lock/NotifGroup.qml new file mode 100644 index 0000000..7796090 --- /dev/null +++ b/.config/quickshell/caelestia/modules/lock/NotifGroup.qml @@ -0,0 +1,316 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.components.effects +import qs.services +import qs.config +import qs.utils +import Quickshell +import Quickshell.Widgets +import Quickshell.Services.Notifications +import QtQuick +import QtQuick.Layouts + +StyledRect { + id: root + + required property string modelData + + readonly property list notifs: Notifs.list.filter(notif => notif.appName === modelData) + readonly property string image: notifs.find(n => n.image.length > 0)?.image ?? "" + readonly property string appIcon: notifs.find(n => n.appIcon.length > 0)?.appIcon ?? "" + readonly property string urgency: notifs.some(n => n.urgency === NotificationUrgency.Critical) ? "critical" : notifs.some(n => n.urgency === NotificationUrgency.Normal) ? "normal" : "low" + + property bool expanded + + anchors.left: parent?.left + anchors.right: parent?.right + implicitHeight: content.implicitHeight + Appearance.padding.normal * 2 + + clip: true + radius: Appearance.rounding.normal + color: root.urgency === "critical" ? Colours.palette.m3secondaryContainer : Colours.layer(Colours.palette.m3surfaceContainerHigh, 2) + + RowLayout { + id: content + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Appearance.padding.normal + + spacing: Appearance.spacing.normal + + Item { + Layout.alignment: Qt.AlignLeft | Qt.AlignTop + implicitWidth: Config.notifs.sizes.image + implicitHeight: Config.notifs.sizes.image + + Component { + id: imageComp + + Image { + source: Qt.resolvedUrl(root.image) + fillMode: Image.PreserveAspectCrop + cache: false + asynchronous: true + width: Config.notifs.sizes.image + height: Config.notifs.sizes.image + } + } + + Component { + id: appIconComp + + ColouredIcon { + implicitSize: Math.round(Config.notifs.sizes.image * 0.6) + source: Quickshell.iconPath(root.appIcon) + colour: root.urgency === "critical" ? Colours.palette.m3onError : root.urgency === "low" ? Colours.palette.m3onSurface : Colours.palette.m3onSecondaryContainer + layer.enabled: root.appIcon.endsWith("symbolic") + } + } + + Component { + id: materialIconComp + + MaterialIcon { + text: Icons.getNotifIcon(root.notifs[0]?.summary, root.urgency) + color: root.urgency === "critical" ? Colours.palette.m3onError : root.urgency === "low" ? Colours.palette.m3onSurface : Colours.palette.m3onSecondaryContainer + font.pointSize: Appearance.font.size.large + } + } + + ClippingRectangle { + anchors.fill: parent + color: root.urgency === "critical" ? Colours.palette.m3error : root.urgency === "low" ? Colours.layer(Colours.palette.m3surfaceContainerHighest, 3) : Colours.palette.m3secondaryContainer + radius: Appearance.rounding.full + + Loader { + anchors.centerIn: parent + sourceComponent: root.image ? imageComp : root.appIcon ? appIconComp : materialIconComp + } + } + + Loader { + anchors.right: parent.right + anchors.bottom: parent.bottom + active: root.appIcon && root.image + + sourceComponent: StyledRect { + implicitWidth: Config.notifs.sizes.badge + implicitHeight: Config.notifs.sizes.badge + + color: root.urgency === "critical" ? Colours.palette.m3error : root.urgency === "low" ? Colours.palette.m3surfaceContainerHighest : Colours.palette.m3secondaryContainer + radius: Appearance.rounding.full + + ColouredIcon { + anchors.centerIn: parent + implicitSize: Math.round(Config.notifs.sizes.badge * 0.6) + source: Quickshell.iconPath(root.appIcon) + colour: root.urgency === "critical" ? Colours.palette.m3onError : root.urgency === "low" ? Colours.palette.m3onSurface : Colours.palette.m3onSecondaryContainer + layer.enabled: root.appIcon.endsWith("symbolic") + } + } + } + } + + ColumnLayout { + Layout.topMargin: -Appearance.padding.small + Layout.bottomMargin: -Appearance.padding.small / 2 - (root.expanded ? 0 : spacing) + Layout.fillWidth: true + spacing: Math.round(Appearance.spacing.small / 2) + + RowLayout { + Layout.bottomMargin: -parent.spacing + Layout.fillWidth: true + spacing: Appearance.spacing.smaller + + StyledText { + Layout.fillWidth: true + text: root.modelData + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small + elide: Text.ElideRight + } + + StyledText { + animate: true + text: root.notifs[0]?.timeStr ?? "" + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.small + } + + StyledRect { + implicitWidth: expandBtn.implicitWidth + Appearance.padding.smaller * 2 + implicitHeight: groupCount.implicitHeight + Appearance.padding.small + + color: root.urgency === "critical" ? Colours.palette.m3error : Colours.layer(Colours.palette.m3surfaceContainerHighest, 2) + radius: Appearance.rounding.full + + opacity: root.notifs.length > Config.notifs.groupPreviewNum ? 1 : 0 + Layout.preferredWidth: root.notifs.length > Config.notifs.groupPreviewNum ? implicitWidth : 0 + + StateLayer { + color: root.urgency === "critical" ? Colours.palette.m3onError : Colours.palette.m3onSurface + + function onClicked(): void { + root.expanded = !root.expanded; + } + } + + RowLayout { + id: expandBtn + + anchors.centerIn: parent + spacing: Appearance.spacing.small / 2 + + StyledText { + id: groupCount + + Layout.leftMargin: Appearance.padding.small / 2 + animate: true + text: root.notifs.length + color: root.urgency === "critical" ? Colours.palette.m3onError : Colours.palette.m3onSurface + font.pointSize: Appearance.font.size.small + } + + MaterialIcon { + Layout.rightMargin: -Appearance.padding.small / 2 + animate: true + text: root.expanded ? "expand_less" : "expand_more" + color: root.urgency === "critical" ? Colours.palette.m3onError : Colours.palette.m3onSurface + } + } + + Behavior on opacity { + Anim {} + } + + Behavior on Layout.preferredWidth { + Anim {} + } + } + } + + Repeater { + model: ScriptModel { + values: root.notifs.slice(0, Config.notifs.groupPreviewNum) + } + + NotifLine { + id: notif + + ParallelAnimation { + running: true + + Anim { + target: notif + property: "opacity" + from: 0 + to: 1 + } + Anim { + target: notif + property: "scale" + from: 0.7 + to: 1 + } + Anim { + target: notif.Layout + property: "preferredHeight" + from: 0 + to: notif.implicitHeight + } + } + + ParallelAnimation { + running: notif.modelData.closed + onFinished: notif.modelData.unlock(notif) + + Anim { + target: notif + property: "opacity" + to: 0 + } + Anim { + target: notif + property: "scale" + to: 0.7 + } + Anim { + target: notif.Layout + property: "preferredHeight" + to: 0 + } + } + } + } + + Loader { + Layout.fillWidth: true + + opacity: root.expanded ? 1 : 0 + Layout.preferredHeight: root.expanded ? implicitHeight : 0 + active: opacity > 0 + + sourceComponent: ColumnLayout { + Repeater { + model: ScriptModel { + values: root.notifs.slice(Config.notifs.groupPreviewNum) + } + + NotifLine {} + } + } + + Behavior on opacity { + Anim {} + } + } + } + } + + Behavior on implicitHeight { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + + component NotifLine: StyledText { + id: notifLine + + required property Notifs.Notif modelData + + Layout.fillWidth: true + textFormat: Text.MarkdownText + text: { + const summary = modelData.summary.replace(/\n/g, " "); + const body = modelData.body.replace(/\n/g, " "); + const colour = root.urgency === "critical" ? Colours.palette.m3secondary : Colours.palette.m3outline; + + if (metrics.text === metrics.elidedText) + return `${summary} ${body}`; + + const t = metrics.elidedText.length - 3; + if (t < summary.length) + return `${summary.slice(0, t)}...`; + + return `${summary} ${body.slice(0, t - summary.length)}...`; + } + color: root.urgency === "critical" ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface + + Component.onCompleted: modelData.lock(this) + Component.onDestruction: modelData.unlock(this) + + TextMetrics { + id: metrics + + text: `${notifLine.modelData.summary} ${notifLine.modelData.body}`.replace(/\n/g, " ") + font.pointSize: notifLine.font.pointSize + font.family: notifLine.font.family + elideWidth: notifLine.width + elide: Text.ElideRight + } + } +} diff --git a/.config/quickshell/caelestia/modules/lock/Pam.qml b/.config/quickshell/caelestia/modules/lock/Pam.qml new file mode 100644 index 0000000..0186c2f --- /dev/null +++ b/.config/quickshell/caelestia/modules/lock/Pam.qml @@ -0,0 +1,193 @@ +import qs.config +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import Quickshell.Services.Pam +import QtQuick + +Scope { + id: root + + required property WlSessionLock lock + + readonly property alias passwd: passwd + readonly property alias fprint: fprint + property string lockMessage + property string state + property string fprintState + property string buffer + + signal flashMsg + + function handleKey(event: KeyEvent): void { + if (passwd.active || state === "max") + return; + + if (event.key === Qt.Key_Enter || event.key === Qt.Key_Return) { + passwd.start(); + } else if (event.key === Qt.Key_Backspace) { + if (event.modifiers & Qt.ControlModifier) { + buffer = ""; + } else { + buffer = buffer.slice(0, -1); + } + } else if (" abcdefghijklmnopqrstuvwxyz1234567890`~!@#$%^&*()-_=+[{]}\\|;:'\",<.>/?".includes(event.text.toLowerCase())) { + // No illegal characters (you are insane if you use unicode in your password) + buffer += event.text; + } + } + + PamContext { + id: passwd + + config: "passwd" + configDirectory: Quickshell.shellDir + "/assets/pam.d" + + onMessageChanged: { + if (message.startsWith("The account is locked")) + root.lockMessage = message; + else if (root.lockMessage && message.endsWith(" left to unlock)")) + root.lockMessage += "\n" + message; + } + + onResponseRequiredChanged: { + if (!responseRequired) + return; + + respond(root.buffer); + root.buffer = ""; + } + + onCompleted: res => { + if (res === PamResult.Success) + return root.lock.unlock(); + + if (res === PamResult.Error) + root.state = "error"; + else if (res === PamResult.MaxTries) + root.state = "max"; + else if (res === PamResult.Failed) + root.state = "fail"; + + root.flashMsg(); + stateReset.restart(); + } + } + + PamContext { + id: fprint + + property bool available + property int tries + property int errorTries + + function checkAvail(): void { + if (!available || !Config.lock.enableFprint || !root.lock.secure) { + abort(); + return; + } + + tries = 0; + errorTries = 0; + start(); + } + + config: "fprint" + configDirectory: Quickshell.shellDir + "/assets/pam.d" + + onCompleted: res => { + if (!available) + return; + + if (res === PamResult.Success) + return root.lock.unlock(); + + if (res === PamResult.Error) { + root.fprintState = "error"; + errorTries++; + if (errorTries < 5) { + abort(); + errorRetry.restart(); + } + } else if (res === PamResult.MaxTries) { + // Isn't actually the real max tries as pam only reports completed + // when max tries is reached. + tries++; + if (tries < Config.lock.maxFprintTries) { + // Restart if not actually real max tries + root.fprintState = "fail"; + start(); + } else { + root.fprintState = "max"; + abort(); + } + } + + root.flashMsg(); + fprintStateReset.start(); + } + } + + Process { + id: availProc + + command: ["sh", "-c", "fprintd-list $USER"] + onExited: code => { + fprint.available = code === 0; + fprint.checkAvail(); + } + } + + Timer { + id: errorRetry + + interval: 800 + onTriggered: fprint.start() + } + + Timer { + id: stateReset + + interval: 4000 + onTriggered: { + if (root.state !== "max") + root.state = ""; + } + } + + Timer { + id: fprintStateReset + + interval: 4000 + onTriggered: { + root.fprintState = ""; + fprint.errorTries = 0; + } + } + + Connections { + target: root.lock + + function onSecureChanged(): void { + if (root.lock.secure) { + availProc.running = true; + root.buffer = ""; + root.state = ""; + root.fprintState = ""; + root.lockMessage = ""; + } + } + + function onUnlock(): void { + fprint.abort(); + } + } + + Connections { + target: Config.lock + + function onEnableFprintChanged(): void { + fprint.checkAvail(); + } + } +} diff --git a/.config/quickshell/caelestia/modules/lock/Resources.qml b/.config/quickshell/caelestia/modules/lock/Resources.qml new file mode 100644 index 0000000..82c004c --- /dev/null +++ b/.config/quickshell/caelestia/modules/lock/Resources.qml @@ -0,0 +1,93 @@ +import qs.components +import qs.components.controls +import qs.components.misc +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +GridLayout { + id: root + + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: Appearance.padding.large + + rowSpacing: Appearance.spacing.large + columnSpacing: Appearance.spacing.large + rows: 2 + columns: 2 + + Ref { + service: SystemUsage + } + + Resource { + Layout.topMargin: Appearance.padding.large + icon: "memory" + value: SystemUsage.cpuPerc + colour: Colours.palette.m3primary + } + + Resource { + Layout.topMargin: Appearance.padding.large + icon: "thermostat" + value: Math.min(1, SystemUsage.cpuTemp / 90) + colour: Colours.palette.m3secondary + } + + Resource { + Layout.bottomMargin: Appearance.padding.large + icon: "memory_alt" + value: SystemUsage.memPerc + colour: Colours.palette.m3secondary + } + + Resource { + Layout.bottomMargin: Appearance.padding.large + icon: "hard_disk" + value: SystemUsage.storagePerc + colour: Colours.palette.m3tertiary + } + + component Resource: StyledRect { + id: res + + required property string icon + required property real value + required property color colour + + Layout.fillWidth: true + implicitHeight: width + + color: Colours.layer(Colours.palette.m3surfaceContainerHigh, 2) + radius: Appearance.rounding.large + + CircularProgress { + id: circ + + anchors.fill: parent + value: res.value + padding: Appearance.padding.large * 3 + fgColour: res.colour + bgColour: Colours.layer(Colours.palette.m3surfaceContainerHighest, 3) + strokeWidth: width < 200 ? Appearance.padding.smaller : Appearance.padding.normal + } + + MaterialIcon { + id: icon + + anchors.centerIn: parent + text: res.icon + color: res.colour + font.pointSize: (circ.arcRadius * 0.7) || 1 + font.weight: 600 + } + + Behavior on value { + Anim { + duration: Appearance.anim.durations.large + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/lock/WeatherInfo.qml b/.config/quickshell/caelestia/modules/lock/WeatherInfo.qml new file mode 100644 index 0000000..d6c25af --- /dev/null +++ b/.config/quickshell/caelestia/modules/lock/WeatherInfo.qml @@ -0,0 +1,176 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.services +import qs.config +import qs.utils +import QtQuick +import QtQuick.Layouts + +ColumnLayout { + id: root + + required property int rootHeight + + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: Appearance.padding.large * 2 + + spacing: Appearance.spacing.small + + Loader { + Layout.topMargin: Appearance.padding.large * 2 + Layout.bottomMargin: -Appearance.padding.large + Layout.alignment: Qt.AlignHCenter + + active: root.rootHeight > 610 + visible: active + + sourceComponent: StyledText { + text: qsTr("Weather") + color: Colours.palette.m3primary + font.pointSize: Appearance.font.size.extraLarge + font.weight: 500 + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.large + + MaterialIcon { + animate: true + text: Weather.icon + color: Colours.palette.m3secondary + font.pointSize: Appearance.font.size.extraLarge * 2.5 + } + + ColumnLayout { + spacing: Appearance.spacing.small + + StyledText { + Layout.fillWidth: true + + animate: true + text: Weather.description + color: Colours.palette.m3secondary + font.pointSize: Appearance.font.size.large + font.weight: 500 + elide: Text.ElideRight + } + + StyledText { + Layout.fillWidth: true + + animate: true + text: qsTr("Humidity: %1%").arg(Weather.humidity) + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.normal + elide: Text.ElideRight + } + } + + Loader { + Layout.rightMargin: Appearance.padding.smaller + active: root.width > 400 + visible: active + + sourceComponent: ColumnLayout { + spacing: Appearance.spacing.small + + StyledText { + Layout.fillWidth: true + + animate: true + text: Weather.temp + color: Colours.palette.m3primary + horizontalAlignment: Text.AlignRight + font.pointSize: Appearance.font.size.extraLarge + font.weight: 500 + elide: Text.ElideLeft + } + + StyledText { + Layout.fillWidth: true + + animate: true + text: qsTr("Feels like: %1").arg(Weather.feelsLike) + color: Colours.palette.m3outline + horizontalAlignment: Text.AlignRight + font.pointSize: Appearance.font.size.smaller + elide: Text.ElideLeft + } + } + } + } + + Loader { + id: forecastLoader + + Layout.topMargin: Appearance.spacing.smaller + Layout.bottomMargin: Appearance.padding.large * 2 + Layout.fillWidth: true + + active: root.rootHeight > 820 + visible: active + + sourceComponent: RowLayout { + spacing: Appearance.spacing.large + + Repeater { + model: { + const forecast = Weather.hourlyForecast; + const count = root.width < 320 ? 3 : root.width < 400 ? 4 : 5; + if (!forecast) + return Array.from({ + length: count + }, () => null); + + return forecast.slice(0, count); + } + + ColumnLayout { + id: forecastHour + + required property var modelData + + Layout.fillWidth: true + spacing: Appearance.spacing.small + + StyledText { + Layout.fillWidth: true + text: { + const hour = forecastHour.modelData?.hour ?? 0; + return hour > 12 ? `${(hour - 12).toString().padStart(2, "0")} PM` : `${hour.toString().padStart(2, "0")} AM`; + } + color: Colours.palette.m3outline + horizontalAlignment: Text.AlignHCenter + font.pointSize: Appearance.font.size.larger + } + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + text: forecastHour.modelData?.icon ?? "cloud_alert" + font.pointSize: Appearance.font.size.extraLarge * 1.5 + font.weight: 500 + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: Config.services.useFahrenheit ? `${forecastHour.modelData?.tempF ?? 0}°F` : `${forecastHour.modelData?.tempC ?? 0}°C` + color: Colours.palette.m3secondary + font.pointSize: Appearance.font.size.larger + } + } + } + } + } + + Timer { + running: true + triggeredOnStart: true + repeat: true + interval: 900000 // 15 minutes + onTriggered: Weather.reload() + } +} diff --git a/.config/quickshell/caelestia/modules/notifications/Background.qml b/.config/quickshell/caelestia/modules/notifications/Background.qml new file mode 100644 index 0000000..a44cb19 --- /dev/null +++ b/.config/quickshell/caelestia/modules/notifications/Background.qml @@ -0,0 +1,54 @@ +import qs.components +import qs.services +import qs.config +import QtQuick +import QtQuick.Shapes + +ShapePath { + id: root + + required property Wrapper wrapper + required property var sidebar + readonly property real rounding: Config.border.rounding + readonly property bool flatten: wrapper.height < rounding * 2 + readonly property real roundingY: flatten ? wrapper.height / 2 : rounding + + strokeWidth: -1 + fillColor: Colours.palette.m3surface + + PathLine { + relativeX: -(root.wrapper.width + root.rounding) + relativeY: 0 + } + PathArc { + relativeX: root.rounding + relativeY: root.roundingY + radiusX: root.rounding + radiusY: Math.min(root.rounding, root.wrapper.height) + } + PathLine { + relativeX: 0 + relativeY: root.wrapper.height - root.roundingY * 2 + } + PathArc { + relativeX: root.sidebar.notifsRoundingX + relativeY: root.roundingY + radiusX: root.sidebar.notifsRoundingX + radiusY: Math.min(root.rounding, root.wrapper.height) + direction: PathArc.Counterclockwise + } + PathLine { + relativeX: root.wrapper.height > 0 ? root.wrapper.width - root.rounding - root.sidebar.notifsRoundingX : root.wrapper.width + relativeY: 0 + } + PathArc { + relativeX: root.rounding + relativeY: root.rounding + radiusX: root.rounding + radiusY: root.rounding + } + + Behavior on fillColor { + CAnim {} + } +} diff --git a/.config/quickshell/caelestia/modules/notifications/Content.qml b/.config/quickshell/caelestia/modules/notifications/Content.qml new file mode 100644 index 0000000..2d4590e --- /dev/null +++ b/.config/quickshell/caelestia/modules/notifications/Content.qml @@ -0,0 +1,204 @@ +import qs.components.containers +import qs.components.widgets +import qs.services +import qs.config +import Quickshell +import Quickshell.Widgets +import QtQuick + +Item { + id: root + + required property PersistentProperties visibilities + required property Item panels + readonly property int padding: Appearance.padding.large + + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.right: parent.right + + implicitWidth: Config.notifs.sizes.width + padding * 2 + implicitHeight: { + const count = list.count; + if (count === 0) + return 0; + + let height = (count - 1) * Appearance.spacing.smaller; + for (let i = 0; i < count; i++) + height += list.itemAtIndex(i)?.nonAnimHeight ?? 0; + + if (visibilities && panels) { + if (visibilities.osd) { + const h = panels.osd.y - Config.border.rounding * 2 - padding * 2; + if (height > h) + height = h; + } + + if (visibilities.session) { + const h = panels.session.y - Config.border.rounding * 2 - padding * 2; + if (height > h) + height = h; + } + } + + return Math.min((QsWindow.window?.screen?.height ?? 0) - Config.border.thickness * 2, height + padding * 2); + } + + ClippingWrapperRectangle { + anchors.fill: parent + anchors.margins: root.padding + + color: "transparent" + radius: Appearance.rounding.normal + + StyledListView { + id: list + + model: ScriptModel { + values: Notifs.popups.filter(n => !n.closed) + } + + anchors.fill: parent + + orientation: Qt.Vertical + spacing: 0 + cacheBuffer: QsWindow.window?.screen.height ?? 0 + + delegate: Item { + id: wrapper + + required property Notifs.Notif modelData + required property int index + readonly property alias nonAnimHeight: notif.nonAnimHeight + property int idx + + onIndexChanged: { + if (index !== -1) + idx = index; + } + + implicitWidth: notif.implicitWidth + implicitHeight: notif.implicitHeight + (idx === 0 ? 0 : Appearance.spacing.smaller) + + ListView.onRemove: removeAnim.start() + + SequentialAnimation { + id: removeAnim + + PropertyAction { + target: wrapper + property: "ListView.delayRemove" + value: true + } + PropertyAction { + target: wrapper + property: "enabled" + value: false + } + PropertyAction { + target: wrapper + property: "implicitHeight" + value: 0 + } + PropertyAction { + target: wrapper + property: "z" + value: 1 + } + Anim { + target: notif + property: "x" + to: (notif.x >= 0 ? Config.notifs.sizes.width : -Config.notifs.sizes.width) * 2 + duration: Appearance.anim.durations.normal + easing.bezierCurve: Appearance.anim.curves.emphasized + } + PropertyAction { + target: wrapper + property: "ListView.delayRemove" + value: false + } + } + + ClippingRectangle { + anchors.top: parent.top + anchors.topMargin: wrapper.idx === 0 ? 0 : Appearance.spacing.smaller + + color: "transparent" + radius: notif.radius + implicitWidth: notif.implicitWidth + implicitHeight: notif.implicitHeight + + Notification { + id: notif + + modelData: wrapper.modelData + } + } + } + + move: Transition { + Anim { + property: "y" + } + } + + displaced: Transition { + Anim { + property: "y" + } + } + + ExtraIndicator { + anchors.top: parent.top + extra: { + const count = list.count; + if (count === 0) + return 0; + + const scrollY = list.contentY; + + let height = 0; + for (let i = 0; i < count; i++) { + height += (list.itemAtIndex(i)?.nonAnimHeight ?? 0) + Appearance.spacing.smaller; + + if (height - Appearance.spacing.smaller >= scrollY) + return i; + } + + return count; + } + } + + ExtraIndicator { + anchors.bottom: parent.bottom + extra: { + const count = list.count; + if (count === 0) + return 0; + + const scrollY = list.contentHeight - (list.contentY + list.height); + + let height = 0; + for (let i = count - 1; i >= 0; i--) { + height += (list.itemAtIndex(i)?.nonAnimHeight ?? 0) + Appearance.spacing.smaller; + + if (height - Appearance.spacing.smaller >= scrollY) + return count - i - 1; + } + + return 0; + } + } + } + } + + Behavior on implicitHeight { + Anim {} + } + + component Anim: NumberAnimation { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } +} diff --git a/.config/quickshell/caelestia/modules/notifications/Notification.qml b/.config/quickshell/caelestia/modules/notifications/Notification.qml new file mode 100644 index 0000000..8c2d3ec --- /dev/null +++ b/.config/quickshell/caelestia/modules/notifications/Notification.qml @@ -0,0 +1,480 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.components.effects +import qs.services +import qs.config +import qs.utils +import Quickshell +import Quickshell.Widgets +import Quickshell.Services.Notifications +import QtQuick +import QtQuick.Layouts + +StyledRect { + id: root + + required property Notifs.Notif modelData + readonly property bool hasImage: modelData.image.length > 0 + readonly property bool hasAppIcon: modelData.appIcon.length > 0 + readonly property int nonAnimHeight: summary.implicitHeight + (root.expanded ? appName.height + body.height + actions.height + actions.anchors.topMargin : bodyPreview.height) + inner.anchors.margins * 2 + property bool expanded: Config.notifs.openExpanded + + color: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3secondaryContainer : Colours.tPalette.m3surfaceContainer + radius: Appearance.rounding.normal + implicitWidth: Config.notifs.sizes.width + implicitHeight: inner.implicitHeight + + x: Config.notifs.sizes.width + Component.onCompleted: { + x = 0; + modelData.lock(this); + } + Component.onDestruction: modelData.unlock(this) + + Behavior on x { + Anim { + easing.bezierCurve: Appearance.anim.curves.emphasizedDecel + } + } + + MouseArea { + property int startY + + anchors.fill: parent + hoverEnabled: true + cursorShape: root.expanded && body.hoveredLink ? Qt.PointingHandCursor : pressed ? Qt.ClosedHandCursor : undefined + acceptedButtons: Qt.LeftButton | Qt.MiddleButton + preventStealing: true + + onEntered: root.modelData.timer.stop() + onExited: { + if (!pressed) + root.modelData.timer.start(); + } + + drag.target: parent + drag.axis: Drag.XAxis + + onPressed: event => { + root.modelData.timer.stop(); + startY = event.y; + if (event.button === Qt.MiddleButton) + root.modelData.close(); + } + onReleased: event => { + if (!containsMouse) + root.modelData.timer.start(); + + if (Math.abs(root.x) < Config.notifs.sizes.width * Config.notifs.clearThreshold) + root.x = 0; + else + root.modelData.popup = false; + } + onPositionChanged: event => { + if (pressed) { + const diffY = event.y - startY; + if (Math.abs(diffY) > Config.notifs.expandThreshold) + root.expanded = diffY > 0; + } + } + onClicked: event => { + if (!Config.notifs.actionOnClick || event.button !== Qt.LeftButton) + return; + + const actions = root.modelData.actions; + if (actions?.length === 1) + actions[0].invoke(); + } + + Item { + id: inner + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Appearance.padding.normal + + implicitHeight: root.nonAnimHeight + + Behavior on implicitHeight { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + + Loader { + id: image + + active: root.hasImage + + anchors.left: parent.left + anchors.top: parent.top + width: Config.notifs.sizes.image + height: Config.notifs.sizes.image + visible: root.hasImage || root.hasAppIcon + + sourceComponent: ClippingRectangle { + radius: Appearance.rounding.full + implicitWidth: Config.notifs.sizes.image + implicitHeight: Config.notifs.sizes.image + + Image { + anchors.fill: parent + source: Qt.resolvedUrl(root.modelData.image) + fillMode: Image.PreserveAspectCrop + cache: false + asynchronous: true + } + } + } + + Loader { + id: appIcon + + active: root.hasAppIcon || !root.hasImage + + anchors.horizontalCenter: root.hasImage ? undefined : image.horizontalCenter + anchors.verticalCenter: root.hasImage ? undefined : image.verticalCenter + anchors.right: root.hasImage ? image.right : undefined + anchors.bottom: root.hasImage ? image.bottom : undefined + + sourceComponent: StyledRect { + radius: Appearance.rounding.full + color: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3error : root.modelData.urgency === NotificationUrgency.Low ? Colours.layer(Colours.palette.m3surfaceContainerHighest, 2) : Colours.palette.m3secondaryContainer + implicitWidth: root.hasImage ? Config.notifs.sizes.badge : Config.notifs.sizes.image + implicitHeight: root.hasImage ? Config.notifs.sizes.badge : Config.notifs.sizes.image + + Loader { + id: icon + + active: root.hasAppIcon + + anchors.centerIn: parent + + width: Math.round(parent.width * 0.6) + height: Math.round(parent.width * 0.6) + + sourceComponent: ColouredIcon { + anchors.fill: parent + source: Quickshell.iconPath(root.modelData.appIcon) + colour: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3onError : root.modelData.urgency === NotificationUrgency.Low ? Colours.palette.m3onSurface : Colours.palette.m3onSecondaryContainer + layer.enabled: root.modelData.appIcon.endsWith("symbolic") + } + } + + Loader { + active: !root.hasAppIcon + anchors.centerIn: parent + anchors.horizontalCenterOffset: -Appearance.font.size.large * 0.02 + anchors.verticalCenterOffset: Appearance.font.size.large * 0.02 + + sourceComponent: MaterialIcon { + text: Icons.getNotifIcon(root.modelData.summary, root.modelData.urgency) + + color: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3onError : root.modelData.urgency === NotificationUrgency.Low ? Colours.palette.m3onSurface : Colours.palette.m3onSecondaryContainer + font.pointSize: Appearance.font.size.large + } + } + } + } + + StyledText { + id: appName + + anchors.top: parent.top + anchors.left: image.right + anchors.leftMargin: Appearance.spacing.smaller + + animate: true + text: appNameMetrics.elidedText + maximumLineCount: 1 + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small + + opacity: root.expanded ? 1 : 0 + + Behavior on opacity { + Anim {} + } + } + + TextMetrics { + id: appNameMetrics + + text: root.modelData.appName + font.family: appName.font.family + font.pointSize: appName.font.pointSize + elide: Text.ElideRight + elideWidth: expandBtn.x - time.width - timeSep.width - summary.x - Appearance.spacing.small * 3 + } + + StyledText { + id: summary + + anchors.top: parent.top + anchors.left: image.right + anchors.leftMargin: Appearance.spacing.smaller + + animate: true + text: summaryMetrics.elidedText + maximumLineCount: 1 + height: implicitHeight + + states: State { + name: "expanded" + when: root.expanded + + PropertyChanges { + summary.maximumLineCount: undefined + } + + AnchorChanges { + target: summary + anchors.top: appName.bottom + } + } + + transitions: Transition { + PropertyAction { + target: summary + property: "maximumLineCount" + } + AnchorAnimation { + duration: Appearance.anim.durations.normal + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.anim.curves.standard + } + } + + Behavior on height { + Anim {} + } + } + + TextMetrics { + id: summaryMetrics + + text: root.modelData.summary + font.family: summary.font.family + font.pointSize: summary.font.pointSize + elide: Text.ElideRight + elideWidth: expandBtn.x - time.width - timeSep.width - summary.x - Appearance.spacing.small * 3 + } + + StyledText { + id: timeSep + + anchors.top: parent.top + anchors.left: summary.right + anchors.leftMargin: Appearance.spacing.small + + text: "•" + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small + + states: State { + name: "expanded" + when: root.expanded + + AnchorChanges { + target: timeSep + anchors.left: appName.right + } + } + + transitions: Transition { + AnchorAnimation { + duration: Appearance.anim.durations.normal + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.anim.curves.standard + } + } + } + + StyledText { + id: time + + anchors.top: parent.top + anchors.left: timeSep.right + anchors.leftMargin: Appearance.spacing.small + + animate: true + horizontalAlignment: Text.AlignLeft + text: root.modelData.timeStr + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small + } + + Item { + id: expandBtn + + anchors.right: parent.right + anchors.top: parent.top + + implicitWidth: expandIcon.height + implicitHeight: expandIcon.height + + StateLayer { + radius: Appearance.rounding.full + color: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface + + function onClicked() { + root.expanded = !root.expanded; + } + } + + MaterialIcon { + id: expandIcon + + anchors.centerIn: parent + + animate: true + text: root.expanded ? "expand_less" : "expand_more" + font.pointSize: Appearance.font.size.normal + } + } + + StyledText { + id: bodyPreview + + anchors.left: summary.left + anchors.right: expandBtn.left + anchors.top: summary.bottom + anchors.rightMargin: Appearance.spacing.small + + animate: true + textFormat: Text.MarkdownText + text: bodyPreviewMetrics.elidedText + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small + + opacity: root.expanded ? 0 : 1 + + Behavior on opacity { + Anim {} + } + } + + TextMetrics { + id: bodyPreviewMetrics + + text: root.modelData.body + font.family: bodyPreview.font.family + font.pointSize: bodyPreview.font.pointSize + elide: Text.ElideRight + elideWidth: bodyPreview.width + } + + StyledText { + id: body + + anchors.left: summary.left + anchors.right: expandBtn.left + anchors.top: summary.bottom + anchors.rightMargin: Appearance.spacing.small + + animate: true + textFormat: Text.MarkdownText + text: root.modelData.body + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + height: text ? implicitHeight : 0 + + onLinkActivated: link => { + if (!root.expanded) + return; + + Quickshell.execDetached(["app2unit", "-O", "--", link]); + root.modelData.popup = false; + } + + opacity: root.expanded ? 1 : 0 + + Behavior on opacity { + Anim {} + } + } + + RowLayout { + id: actions + + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: body.bottom + anchors.topMargin: Appearance.spacing.small + + spacing: Appearance.spacing.smaller + + opacity: root.expanded ? 1 : 0 + + Behavior on opacity { + Anim {} + } + + Action { + modelData: QtObject { + readonly property string text: qsTr("Close") + function invoke(): void { + root.modelData.close(); + } + } + } + + Repeater { + model: root.modelData.actions + + delegate: Component { + Action {} + } + } + } + } + } + + component Action: StyledRect { + id: action + + required property var modelData + + radius: Appearance.rounding.full + color: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3secondary : Colours.layer(Colours.palette.m3surfaceContainerHigh, 2) + + Layout.preferredWidth: actionText.width + Appearance.padding.normal * 2 + Layout.preferredHeight: actionText.height + Appearance.padding.small * 2 + implicitWidth: actionText.width + Appearance.padding.normal * 2 + implicitHeight: actionText.height + Appearance.padding.small * 2 + + StateLayer { + radius: Appearance.rounding.full + color: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3onSecondary : Colours.palette.m3onSurface + + function onClicked(): void { + action.modelData.invoke(); + } + } + + StyledText { + id: actionText + + anchors.centerIn: parent + text: actionTextMetrics.elidedText + color: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3onSecondary : Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small + } + + TextMetrics { + id: actionTextMetrics + + text: action.modelData.text + font.family: actionText.font.family + font.pointSize: actionText.font.pointSize + elide: Text.ElideRight + elideWidth: { + const numActions = root.modelData.actions.length + 1; + return (inner.width - actions.spacing * (numActions - 1)) / numActions - Appearance.padding.normal * 2; + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/notifications/Wrapper.qml b/.config/quickshell/caelestia/modules/notifications/Wrapper.qml new file mode 100644 index 0000000..61acc56 --- /dev/null +++ b/.config/quickshell/caelestia/modules/notifications/Wrapper.qml @@ -0,0 +1,39 @@ +import qs.components +import qs.config +import QtQuick + +Item { + id: root + + required property var visibilities + required property Item panels + + visible: height > 0 + implicitWidth: Math.max(panels.sidebar.width, content.implicitWidth) + implicitHeight: content.implicitHeight + + states: State { + name: "hidden" + when: root.visibilities.sidebar && Config.sidebar.enabled + + PropertyChanges { + root.implicitHeight: 0 + } + } + + transitions: Transition { + Anim { + target: root + property: "implicitHeight" + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + + Content { + id: content + + visibilities: root.visibilities + panels: root.panels + } +} diff --git a/.config/quickshell/caelestia/modules/osd/Background.qml b/.config/quickshell/caelestia/modules/osd/Background.qml new file mode 100644 index 0000000..78955c7 --- /dev/null +++ b/.config/quickshell/caelestia/modules/osd/Background.qml @@ -0,0 +1,60 @@ +import qs.components +import qs.services +import qs.config +import QtQuick +import QtQuick.Shapes + +ShapePath { + id: root + + required property Wrapper wrapper + readonly property real rounding: Config.border.rounding + readonly property bool flatten: wrapper.width < rounding * 2 + readonly property real roundingX: flatten ? wrapper.width / 2 : rounding + + strokeWidth: -1 + fillColor: Colours.palette.m3surface + + PathArc { + relativeX: -root.roundingX + relativeY: root.rounding + radiusX: Math.min(root.rounding, root.wrapper.width) + radiusY: root.rounding + } + PathLine { + relativeX: -(root.wrapper.width - root.roundingX * 2) + relativeY: 0 + } + PathArc { + relativeX: -root.roundingX + relativeY: root.rounding + radiusX: Math.min(root.rounding, root.wrapper.width) + radiusY: root.rounding + direction: PathArc.Counterclockwise + } + PathLine { + relativeX: 0 + relativeY: root.wrapper.height - root.rounding * 2 + } + PathArc { + relativeX: root.roundingX + relativeY: root.rounding + radiusX: Math.min(root.rounding, root.wrapper.width) + radiusY: root.rounding + direction: PathArc.Counterclockwise + } + PathLine { + relativeX: root.wrapper.width - root.roundingX * 2 + relativeY: 0 + } + PathArc { + relativeX: root.roundingX + relativeY: root.rounding + radiusX: Math.min(root.rounding, root.wrapper.width) + radiusY: root.rounding + } + + Behavior on fillColor { + CAnim {} + } +} diff --git a/.config/quickshell/caelestia/modules/osd/Content.qml b/.config/quickshell/caelestia/modules/osd/Content.qml new file mode 100644 index 0000000..770fb69 --- /dev/null +++ b/.config/quickshell/caelestia/modules/osd/Content.qml @@ -0,0 +1,127 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.components.controls +import qs.services +import qs.config +import qs.utils +import QtQuick +import QtQuick.Layouts + +Item { + id: root + + required property Brightness.Monitor monitor + required property var visibilities + + required property real volume + required property bool muted + required property real sourceVolume + required property bool sourceMuted + required property real brightness + + implicitWidth: layout.implicitWidth + Appearance.padding.large * 2 + implicitHeight: layout.implicitHeight + Appearance.padding.large * 2 + + ColumnLayout { + id: layout + + anchors.centerIn: parent + spacing: Appearance.spacing.normal + + // Speaker volume + CustomMouseArea { + implicitWidth: Config.osd.sizes.sliderWidth + implicitHeight: Config.osd.sizes.sliderHeight + + function onWheel(event: WheelEvent) { + if (event.angleDelta.y > 0) + Audio.incrementVolume(); + else if (event.angleDelta.y < 0) + Audio.decrementVolume(); + } + + FilledSlider { + anchors.fill: parent + + icon: Icons.getVolumeIcon(value, root.muted) + value: root.volume + to: Config.services.maxVolume + onMoved: Audio.setVolume(value) + } + } + + // Microphone volume + WrappedLoader { + shouldBeActive: Config.osd.enableMicrophone && (!Config.osd.enableBrightness || !root.visibilities.session) + + sourceComponent: CustomMouseArea { + implicitWidth: Config.osd.sizes.sliderWidth + implicitHeight: Config.osd.sizes.sliderHeight + + function onWheel(event: WheelEvent) { + if (event.angleDelta.y > 0) + Audio.incrementSourceVolume(); + else if (event.angleDelta.y < 0) + Audio.decrementSourceVolume(); + } + + FilledSlider { + anchors.fill: parent + + icon: Icons.getMicVolumeIcon(value, root.sourceMuted) + value: root.sourceVolume + to: Config.services.maxVolume + onMoved: Audio.setSourceVolume(value) + } + } + } + + // Brightness + WrappedLoader { + shouldBeActive: Config.osd.enableBrightness + + sourceComponent: CustomMouseArea { + implicitWidth: Config.osd.sizes.sliderWidth + implicitHeight: Config.osd.sizes.sliderHeight + + function onWheel(event: WheelEvent) { + const monitor = root.monitor; + if (!monitor) + return; + if (event.angleDelta.y > 0) + monitor.setBrightness(monitor.brightness + Config.services.brightnessIncrement); + else if (event.angleDelta.y < 0) + monitor.setBrightness(monitor.brightness - Config.services.brightnessIncrement); + } + + FilledSlider { + anchors.fill: parent + + icon: `brightness_${(Math.round(value * 6) + 1)}` + value: root.brightness + onMoved: root.monitor?.setBrightness(value) + } + } + } + } + + component WrappedLoader: Loader { + required property bool shouldBeActive + + Layout.preferredHeight: shouldBeActive ? Config.osd.sizes.sliderHeight : 0 + opacity: shouldBeActive ? 1 : 0 + active: opacity > 0 + visible: active + + Behavior on Layout.preferredHeight { + Anim { + easing.bezierCurve: Appearance.anim.curves.emphasized + } + } + + Behavior on opacity { + Anim {} + } + } +} diff --git a/.config/quickshell/caelestia/modules/osd/Wrapper.qml b/.config/quickshell/caelestia/modules/osd/Wrapper.qml new file mode 100644 index 0000000..2519609 --- /dev/null +++ b/.config/quickshell/caelestia/modules/osd/Wrapper.qml @@ -0,0 +1,134 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.services +import qs.config +import Quickshell +import QtQuick + +Item { + id: root + + required property ShellScreen screen + required property var visibilities + property bool hovered + readonly property Brightness.Monitor monitor: Brightness.getMonitorForScreen(root.screen) + readonly property bool shouldBeActive: visibilities.osd && Config.osd.enabled && !(visibilities.utilities && Config.utilities.enabled) + + property real volume + property bool muted + property real sourceVolume + property bool sourceMuted + property real brightness + + function show(): void { + visibilities.osd = true; + timer.restart(); + } + + Component.onCompleted: { + volume = Audio.volume; + muted = Audio.muted; + sourceVolume = Audio.sourceVolume; + sourceMuted = Audio.sourceMuted; + brightness = root.monitor?.brightness ?? 0; + } + + visible: width > 0 + implicitWidth: 0 + implicitHeight: content.implicitHeight + + states: State { + name: "visible" + when: root.shouldBeActive + + PropertyChanges { + root.implicitWidth: content.implicitWidth + } + } + + transitions: [ + Transition { + from: "" + to: "visible" + + Anim { + target: root + property: "implicitWidth" + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + }, + Transition { + from: "visible" + to: "" + + Anim { + target: root + property: "implicitWidth" + easing.bezierCurve: Appearance.anim.curves.emphasized + } + } + ] + + Connections { + target: Audio + + function onMutedChanged(): void { + root.show(); + root.muted = Audio.muted; + } + + function onVolumeChanged(): void { + root.show(); + root.volume = Audio.volume; + } + + function onSourceMutedChanged(): void { + root.show(); + root.sourceMuted = Audio.sourceMuted; + } + + function onSourceVolumeChanged(): void { + root.show(); + root.sourceVolume = Audio.sourceVolume; + } + } + + Connections { + target: root.monitor + + function onBrightnessChanged(): void { + root.show(); + root.brightness = root.monitor?.brightness ?? 0; + } + } + + Timer { + id: timer + + interval: Config.osd.hideDelay + onTriggered: { + if (!root.hovered) + root.visibilities.osd = false; + } + } + + Loader { + id: content + + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + + Component.onCompleted: active = Qt.binding(() => root.shouldBeActive || root.visible) + + sourceComponent: Content { + monitor: root.monitor + visibilities: root.visibilities + volume: root.volume + muted: root.muted + sourceVolume: root.sourceVolume + sourceMuted: root.sourceMuted + brightness: root.brightness + } + } +} diff --git a/.config/quickshell/caelestia/modules/session/Background.qml b/.config/quickshell/caelestia/modules/session/Background.qml new file mode 100644 index 0000000..78955c7 --- /dev/null +++ b/.config/quickshell/caelestia/modules/session/Background.qml @@ -0,0 +1,60 @@ +import qs.components +import qs.services +import qs.config +import QtQuick +import QtQuick.Shapes + +ShapePath { + id: root + + required property Wrapper wrapper + readonly property real rounding: Config.border.rounding + readonly property bool flatten: wrapper.width < rounding * 2 + readonly property real roundingX: flatten ? wrapper.width / 2 : rounding + + strokeWidth: -1 + fillColor: Colours.palette.m3surface + + PathArc { + relativeX: -root.roundingX + relativeY: root.rounding + radiusX: Math.min(root.rounding, root.wrapper.width) + radiusY: root.rounding + } + PathLine { + relativeX: -(root.wrapper.width - root.roundingX * 2) + relativeY: 0 + } + PathArc { + relativeX: -root.roundingX + relativeY: root.rounding + radiusX: Math.min(root.rounding, root.wrapper.width) + radiusY: root.rounding + direction: PathArc.Counterclockwise + } + PathLine { + relativeX: 0 + relativeY: root.wrapper.height - root.rounding * 2 + } + PathArc { + relativeX: root.roundingX + relativeY: root.rounding + radiusX: Math.min(root.rounding, root.wrapper.width) + radiusY: root.rounding + direction: PathArc.Counterclockwise + } + PathLine { + relativeX: root.wrapper.width - root.roundingX * 2 + relativeY: 0 + } + PathArc { + relativeX: root.roundingX + relativeY: root.rounding + radiusX: Math.min(root.rounding, root.wrapper.width) + radiusY: root.rounding + } + + Behavior on fillColor { + CAnim {} + } +} diff --git a/.config/quickshell/caelestia/modules/session/Content.qml b/.config/quickshell/caelestia/modules/session/Content.qml new file mode 100644 index 0000000..45152e2 --- /dev/null +++ b/.config/quickshell/caelestia/modules/session/Content.qml @@ -0,0 +1,135 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.services +import qs.config +import qs.utils +import Quickshell +import QtQuick + +Column { + id: root + + required property PersistentProperties visibilities + + padding: Appearance.padding.large + spacing: Appearance.spacing.large + + SessionButton { + id: logout + + icon: Config.session.icons.logout + command: Config.session.commands.logout + + KeyNavigation.down: shutdown + + Component.onCompleted: forceActiveFocus() + + Connections { + target: root.visibilities + + function onLauncherChanged(): void { + if (!root.visibilities.launcher) + logout.forceActiveFocus(); + } + } + } + + SessionButton { + id: shutdown + + icon: Config.session.icons.shutdown + command: Config.session.commands.shutdown + + KeyNavigation.up: logout + KeyNavigation.down: hibernate + } + + AnimatedImage { + width: Config.session.sizes.button + height: Config.session.sizes.button + sourceSize.width: width + sourceSize.height: height + + playing: visible + asynchronous: true + speed: Appearance.anim.sessionGifSpeed + source: Paths.absolutePath(Config.paths.sessionGif) + } + + SessionButton { + id: hibernate + + icon: Config.session.icons.hibernate + command: Config.session.commands.hibernate + + KeyNavigation.up: shutdown + KeyNavigation.down: reboot + } + + SessionButton { + id: reboot + + icon: Config.session.icons.reboot + command: Config.session.commands.reboot + + KeyNavigation.up: hibernate + } + + component SessionButton: StyledRect { + id: button + + required property string icon + required property list command + + implicitWidth: Config.session.sizes.button + implicitHeight: Config.session.sizes.button + + radius: Appearance.rounding.large + color: button.activeFocus ? Colours.palette.m3secondaryContainer : Colours.tPalette.m3surfaceContainer + + Keys.onEnterPressed: Quickshell.execDetached(button.command) + Keys.onReturnPressed: Quickshell.execDetached(button.command) + Keys.onEscapePressed: root.visibilities.session = false + Keys.onPressed: event => { + if (!Config.session.vimKeybinds) + return; + + if (event.modifiers & Qt.ControlModifier) { + if (event.key === Qt.Key_J && KeyNavigation.down) { + KeyNavigation.down.focus = true; + event.accepted = true; + } else if (event.key === Qt.Key_K && KeyNavigation.up) { + KeyNavigation.up.focus = true; + event.accepted = true; + } + } else if (event.key === Qt.Key_Tab && KeyNavigation.down) { + KeyNavigation.down.focus = true; + event.accepted = true; + } else if (event.key === Qt.Key_Backtab || (event.key === Qt.Key_Tab && (event.modifiers & Qt.ShiftModifier))) { + if (KeyNavigation.up) { + KeyNavigation.up.focus = true; + event.accepted = true; + } + } + } + + StateLayer { + radius: parent.radius + color: button.activeFocus ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface + + function onClicked(): void { + Quickshell.execDetached(button.command); + } + } + + MaterialIcon { + anchors.centerIn: parent + + text: button.icon + color: button.activeFocus ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface + font.pointSize: Appearance.font.size.extraLarge + font.weight: 500 + } + } +} diff --git a/.config/quickshell/caelestia/modules/session/Wrapper.qml b/.config/quickshell/caelestia/modules/session/Wrapper.qml new file mode 100644 index 0000000..14b03a8 --- /dev/null +++ b/.config/quickshell/caelestia/modules/session/Wrapper.qml @@ -0,0 +1,63 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.config +import Quickshell +import QtQuick + +Item { + id: root + + required property PersistentProperties visibilities + required property var panels + readonly property real nonAnimWidth: content.implicitWidth + + visible: width > 0 + implicitWidth: 0 + implicitHeight: content.implicitHeight + + states: State { + name: "visible" + when: root.visibilities.session && Config.session.enabled + + PropertyChanges { + root.implicitWidth: root.nonAnimWidth + } + } + + transitions: [ + Transition { + from: "" + to: "visible" + + Anim { + target: root + property: "implicitWidth" + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + }, + Transition { + from: "visible" + to: "" + + Anim { + target: root + property: "implicitWidth" + easing.bezierCurve: root.panels.osd.width > 0 ? Appearance.anim.curves.expressiveDefaultSpatial : Appearance.anim.curves.emphasized + } + } + ] + + Loader { + id: content + + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + + Component.onCompleted: active = Qt.binding(() => (root.visibilities.session && Config.session.enabled) || root.visible) + + sourceComponent: Content { + visibilities: root.visibilities + } + } +} diff --git a/.config/quickshell/caelestia/modules/sidebar/Background.qml b/.config/quickshell/caelestia/modules/sidebar/Background.qml new file mode 100644 index 0000000..beefdf5 --- /dev/null +++ b/.config/quickshell/caelestia/modules/sidebar/Background.qml @@ -0,0 +1,52 @@ +import qs.components +import qs.services +import qs.config +import QtQuick +import QtQuick.Shapes + +ShapePath { + id: root + + required property Wrapper wrapper + required property var panels + + readonly property real rounding: Config.border.rounding + + readonly property real notifsWidthDiff: panels.notifications.width - wrapper.width + readonly property real notifsRoundingX: panels.notifications.height > 0 && notifsWidthDiff < rounding * 2 ? notifsWidthDiff / 2 : rounding + + readonly property real utilsWidthDiff: panels.utilities.width - wrapper.width + readonly property real utilsRoundingX: utilsWidthDiff < rounding * 2 ? utilsWidthDiff / 2 : rounding + + strokeWidth: -1 + fillColor: Colours.palette.m3surface + + PathLine { + relativeX: -root.wrapper.width - root.notifsRoundingX + relativeY: 0 + } + PathArc { + relativeX: root.notifsRoundingX + relativeY: root.rounding + radiusX: root.notifsRoundingX + radiusY: root.rounding + } + PathLine { + relativeX: 0 + relativeY: root.wrapper.height - root.rounding * 2 + } + PathArc { + relativeX: -root.utilsRoundingX + relativeY: root.rounding + radiusX: root.utilsRoundingX + radiusY: root.rounding + } + PathLine { + relativeX: root.wrapper.width + root.utilsRoundingX + relativeY: 0 + } + + Behavior on fillColor { + CAnim {} + } +} diff --git a/.config/quickshell/caelestia/modules/sidebar/Content.qml b/.config/quickshell/caelestia/modules/sidebar/Content.qml new file mode 100644 index 0000000..1b7feed --- /dev/null +++ b/.config/quickshell/caelestia/modules/sidebar/Content.qml @@ -0,0 +1,40 @@ +import qs.components +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +Item { + id: root + + required property Props props + required property var visibilities + + ColumnLayout { + id: layout + + anchors.fill: parent + spacing: Appearance.spacing.normal + + StyledRect { + Layout.fillWidth: true + Layout.fillHeight: true + + radius: Appearance.rounding.normal + color: Colours.tPalette.m3surfaceContainerLow + + NotifDock { + props: root.props + visibilities: root.visibilities + } + } + + StyledRect { + Layout.topMargin: Appearance.padding.large - layout.spacing + Layout.fillWidth: true + implicitHeight: 1 + + color: Colours.tPalette.m3outlineVariant + } + } +} diff --git a/.config/quickshell/caelestia/modules/sidebar/Notif.qml b/.config/quickshell/caelestia/modules/sidebar/Notif.qml new file mode 100644 index 0000000..5a31764 --- /dev/null +++ b/.config/quickshell/caelestia/modules/sidebar/Notif.qml @@ -0,0 +1,164 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.services +import qs.config +import Quickshell +import QtQuick +import QtQuick.Layouts + +StyledRect { + id: root + + required property Notifs.Notif modelData + required property Props props + required property bool expanded + required property var visibilities + + readonly property StyledText body: expandedContent.item?.body ?? null + readonly property real nonAnimHeight: expanded ? summary.implicitHeight + expandedContent.implicitHeight + expandedContent.anchors.topMargin + Appearance.padding.normal * 2 : summaryHeightMetrics.height + + implicitHeight: nonAnimHeight + + radius: Appearance.rounding.small + color: { + const c = root.modelData.urgency === "critical" ? Colours.palette.m3secondaryContainer : Colours.layer(Colours.palette.m3surfaceContainerHigh, 2); + return expanded ? c : Qt.alpha(c, 0); + } + + states: State { + name: "expanded" + when: root.expanded + + PropertyChanges { + summary.anchors.margins: Appearance.padding.normal + dummySummary.anchors.margins: Appearance.padding.normal + compactBody.anchors.margins: Appearance.padding.normal + timeStr.anchors.margins: Appearance.padding.normal + expandedContent.anchors.margins: Appearance.padding.normal + summary.width: root.width - Appearance.padding.normal * 2 - timeStr.implicitWidth - Appearance.spacing.small + summary.maximumLineCount: Number.MAX_SAFE_INTEGER + } + } + + transitions: Transition { + Anim { + properties: "margins,width,maximumLineCount" + } + } + + TextMetrics { + id: summaryHeightMetrics + + font: summary.font + text: " " // Use this height to prevent weird characters from changing the line height + } + + StyledText { + id: summary + + anchors.top: parent.top + anchors.left: parent.left + + width: parent.width + text: root.modelData.summary + color: root.modelData.urgency === "critical" ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface + elide: Text.ElideRight + wrapMode: Text.WordWrap + maximumLineCount: 1 + } + + StyledText { + id: dummySummary + + anchors.top: parent.top + anchors.left: parent.left + + visible: false + text: root.modelData.summary + } + + WrappedLoader { + id: compactBody + + shouldBeActive: !root.expanded + anchors.top: parent.top + anchors.left: dummySummary.right + anchors.right: parent.right + anchors.leftMargin: Appearance.spacing.small + + sourceComponent: StyledText { + text: root.modelData.body.replace(/\n/g, " ") + color: root.modelData.urgency === "critical" ? Colours.palette.m3secondary : Colours.palette.m3outline + elide: Text.ElideRight + } + } + + WrappedLoader { + id: timeStr + + shouldBeActive: root.expanded + anchors.top: parent.top + anchors.right: parent.right + + sourceComponent: StyledText { + animate: true + text: root.modelData.timeStr + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.small + } + } + + WrappedLoader { + id: expandedContent + + shouldBeActive: root.expanded + anchors.top: summary.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: Appearance.spacing.small / 2 + + sourceComponent: ColumnLayout { + readonly property alias body: body + + spacing: Appearance.spacing.smaller + + StyledText { + id: body + + Layout.fillWidth: true + textFormat: Text.MarkdownText + text: root.modelData.body.replace(/(.)\n(?!\n)/g, "$1\n\n") || qsTr("No body here! :/") + color: root.modelData.urgency === "critical" ? Colours.palette.m3secondary : Colours.palette.m3outline + wrapMode: Text.WordWrap + + onLinkActivated: link => { + Quickshell.execDetached(["app2unit", "-O", "--", link]); + root.visibilities.sidebar = false; + } + } + + NotifActionList { + notif: root.modelData + } + } + } + + Behavior on implicitHeight { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + + component WrappedLoader: Loader { + required property bool shouldBeActive + + opacity: shouldBeActive ? 1 : 0 + active: opacity > 0 + + Behavior on opacity { + Anim {} + } + } +} diff --git a/.config/quickshell/caelestia/modules/sidebar/NotifActionList.qml b/.config/quickshell/caelestia/modules/sidebar/NotifActionList.qml new file mode 100644 index 0000000..d1f1e1f --- /dev/null +++ b/.config/quickshell/caelestia/modules/sidebar/NotifActionList.qml @@ -0,0 +1,200 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.components.containers +import qs.components.effects +import qs.services +import qs.config +import Quickshell +import Quickshell.Widgets +import QtQuick +import QtQuick.Layouts + +Item { + id: root + + required property Notifs.Notif notif + + Layout.fillWidth: true + implicitHeight: flickable.contentHeight + + layer.enabled: true + layer.smooth: true + layer.effect: OpacityMask { + maskSource: gradientMask + } + + Item { + id: gradientMask + + anchors.fill: parent + layer.enabled: true + visible: false + + Rectangle { + anchors.fill: parent + + gradient: Gradient { + orientation: Gradient.Horizontal + + GradientStop { + position: 0 + color: Qt.rgba(0, 0, 0, 0) + } + GradientStop { + position: 0.1 + color: Qt.rgba(0, 0, 0, 1) + } + GradientStop { + position: 0.9 + color: Qt.rgba(0, 0, 0, 1) + } + GradientStop { + position: 1 + color: Qt.rgba(0, 0, 0, 0) + } + } + } + + Rectangle { + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.left: parent.left + + implicitWidth: parent.width / 2 + opacity: flickable.contentX > 0 ? 0 : 1 + + Behavior on opacity { + Anim {} + } + } + + Rectangle { + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.right: parent.right + + implicitWidth: parent.width / 2 + opacity: flickable.contentX < flickable.contentWidth - parent.width ? 0 : 1 + + Behavior on opacity { + Anim {} + } + } + } + + StyledFlickable { + id: flickable + + anchors.fill: parent + contentWidth: Math.max(width, actionList.implicitWidth) + contentHeight: actionList.implicitHeight + + RowLayout { + id: actionList + + anchors.fill: parent + spacing: Appearance.spacing.small + + Repeater { + model: [ + { + isClose: true + }, + ...root.notif.actions, + { + isCopy: true + } + ] + + StyledRect { + id: action + + required property var modelData + + Layout.fillWidth: true + Layout.fillHeight: true + implicitWidth: actionInner.implicitWidth + Appearance.padding.normal * 2 + implicitHeight: actionInner.implicitHeight + Appearance.padding.small * 2 + + Layout.preferredWidth: implicitWidth + (actionStateLayer.pressed ? Appearance.padding.large : 0) + radius: actionStateLayer.pressed ? Appearance.rounding.small / 2 : Appearance.rounding.small + color: Colours.layer(Colours.palette.m3surfaceContainerHighest, 4) + + Timer { + id: copyTimer + + interval: 3000 + onTriggered: actionInner.item.text = "content_copy" + } + + StateLayer { + id: actionStateLayer + + function onClicked(): void { + if (action.modelData.isClose) { + root.notif.close(); + } else if (action.modelData.isCopy) { + Quickshell.clipboardText = root.notif.body; + actionInner.item.text = "inventory"; + copyTimer.start(); + } else if (action.modelData.invoke) { + action.modelData.invoke(); + } else if (!root.notif.resident) { + root.notif.close(); + } + } + } + + Loader { + id: actionInner + + anchors.centerIn: parent + sourceComponent: action.modelData.isClose || action.modelData.isCopy ? iconBtn : root.notif.hasActionIcons ? iconComp : textComp + } + + Component { + id: iconBtn + + MaterialIcon { + animate: action.modelData.isCopy ?? false + text: action.modelData.isCopy ? "content_copy" : "close" + color: Colours.palette.m3onSurfaceVariant + } + } + + Component { + id: iconComp + + IconImage { + source: Quickshell.iconPath(action.modelData.identifier) + } + } + + Component { + id: textComp + + StyledText { + text: action.modelData.text + color: Colours.palette.m3onSurfaceVariant + } + } + + Behavior on Layout.preferredWidth { + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + } + } + + Behavior on radius { + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + } + } + } + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/sidebar/NotifDock.qml b/.config/quickshell/caelestia/modules/sidebar/NotifDock.qml new file mode 100644 index 0000000..d039d15 --- /dev/null +++ b/.config/quickshell/caelestia/modules/sidebar/NotifDock.qml @@ -0,0 +1,207 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.components.controls +import qs.components.containers +import qs.components.effects +import qs.services +import qs.config +import Quickshell +import Quickshell.Widgets +import QtQuick +import QtQuick.Layouts + +Item { + id: root + + required property Props props + required property var visibilities + readonly property int notifCount: Notifs.list.reduce((acc, n) => n.closed ? acc : acc + 1, 0) + + anchors.fill: parent + anchors.margins: Appearance.padding.normal + + Component.onCompleted: Notifs.list.forEach(n => n.popup = false) + + Item { + id: title + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: Appearance.padding.small + + implicitHeight: Math.max(count.implicitHeight, titleText.implicitHeight) + + StyledText { + id: count + + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: root.notifCount > 0 ? 0 : -width - titleText.anchors.leftMargin + opacity: root.notifCount > 0 ? 1 : 0 + + text: root.notifCount + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.normal + font.family: Appearance.font.family.mono + font.weight: 500 + + Behavior on anchors.leftMargin { + Anim {} + } + + Behavior on opacity { + Anim {} + } + } + + StyledText { + id: titleText + + anchors.verticalCenter: parent.verticalCenter + anchors.left: count.right + anchors.right: parent.right + anchors.leftMargin: Appearance.spacing.small + + text: root.notifCount > 0 ? qsTr("notification%1").arg(root.notifCount === 1 ? "" : "s") : qsTr("Notifications") + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.normal + font.family: Appearance.font.family.mono + font.weight: 500 + elide: Text.ElideRight + } + } + + ClippingRectangle { + id: clipRect + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: title.bottom + anchors.bottom: parent.bottom + anchors.topMargin: Appearance.spacing.smaller + + radius: Appearance.rounding.small + color: "transparent" + + Loader { + anchors.centerIn: parent + active: opacity > 0 + opacity: root.notifCount > 0 ? 0 : 1 + + sourceComponent: ColumnLayout { + spacing: Appearance.spacing.large + + Image { + asynchronous: true + source: Qt.resolvedUrl(`${Quickshell.shellDir}/assets/dino.png`) + fillMode: Image.PreserveAspectFit + sourceSize.width: clipRect.width * 0.8 + + layer.enabled: true + layer.effect: Colouriser { + colorizationColor: Colours.palette.m3outlineVariant + brightness: 1 + } + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: qsTr("No Notifications") + color: Colours.palette.m3outlineVariant + font.pointSize: Appearance.font.size.large + font.family: Appearance.font.family.mono + font.weight: 500 + } + } + + Behavior on opacity { + Anim { + duration: Appearance.anim.durations.extraLarge + } + } + } + + StyledFlickable { + id: view + + anchors.fill: parent + + flickableDirection: Flickable.VerticalFlick + contentWidth: width + contentHeight: notifList.implicitHeight + + StyledScrollBar.vertical: StyledScrollBar { + flickable: view + } + + NotifDockList { + id: notifList + + props: root.props + visibilities: root.visibilities + container: view + } + } + } + + Timer { + id: clearTimer + + repeat: true + interval: 50 + onTriggered: { + let next = null; + for (let i = 0; i < notifList.repeater.count; i++) { + next = notifList.repeater.itemAt(i); + if (!next?.closed) + break; + } + if (next) + next.closeAll(); + else + stop(); + } + } + + Loader { + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: Appearance.padding.normal + + scale: root.notifCount > 0 ? 1 : 0.5 + opacity: root.notifCount > 0 ? 1 : 0 + active: opacity > 0 + + sourceComponent: IconButton { + id: clearBtn + + icon: "clear_all" + radius: Appearance.rounding.normal + padding: Appearance.padding.normal + font.pointSize: Math.round(Appearance.font.size.large * 1.2) + onClicked: clearTimer.start() + + Elevation { + anchors.fill: parent + radius: parent.radius + z: -1 + level: clearBtn.stateLayer.containsMouse ? 4 : 3 + } + } + + Behavior on scale { + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + } + } + + Behavior on opacity { + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/sidebar/NotifDockList.qml b/.config/quickshell/caelestia/modules/sidebar/NotifDockList.qml new file mode 100644 index 0000000..b927e91 --- /dev/null +++ b/.config/quickshell/caelestia/modules/sidebar/NotifDockList.qml @@ -0,0 +1,167 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.services +import qs.config +import Quickshell +import QtQuick + +Item { + id: root + + required property Props props + required property Flickable container + required property var visibilities + + readonly property alias repeater: repeater + readonly property int spacing: Appearance.spacing.small + property bool flag + + anchors.left: parent.left + anchors.right: parent.right + implicitHeight: { + const item = repeater.itemAt(repeater.count - 1); + return item ? item.y + item.implicitHeight : 0; + } + + Repeater { + id: repeater + + model: ScriptModel { + values: { + const map = new Map(); + for (const n of Notifs.notClosed) + map.set(n.appName, null); + for (const n of Notifs.list) + map.set(n.appName, null); + return [...map.keys()]; + } + onValuesChanged: root.flagChanged() + } + + MouseArea { + id: notif + + required property int index + required property string modelData + + readonly property bool closed: notifInner.notifCount === 0 + readonly property alias nonAnimHeight: notifInner.nonAnimHeight + property int startY + + function closeAll(): void { + for (const n of Notifs.notClosed.filter(n => n.appName === modelData)) + n.close(); + } + + y: { + root.flag; // Force update + let y = 0; + for (let i = 0; i < index; i++) { + const item = repeater.itemAt(i); + if (!item.closed) + y += item.nonAnimHeight + root.spacing; + } + return y; + } + + containmentMask: QtObject { + function contains(p: point): bool { + if (!root.container.contains(notif.mapToItem(root.container, p))) + return false; + return notifInner.contains(p); + } + } + + implicitWidth: root.width + implicitHeight: notifInner.implicitHeight + + hoverEnabled: true + cursorShape: pressed ? Qt.ClosedHandCursor : undefined + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + preventStealing: true + enabled: !closed + + drag.target: this + drag.axis: Drag.XAxis + + onPressed: event => { + startY = event.y; + if (event.button === Qt.RightButton) + notifInner.toggleExpand(!notifInner.expanded); + else if (event.button === Qt.MiddleButton) + closeAll(); + } + onPositionChanged: event => { + if (pressed) { + const diffY = event.y - startY; + if (Math.abs(diffY) > Config.notifs.expandThreshold) + notifInner.toggleExpand(diffY > 0); + } + } + onReleased: event => { + if (Math.abs(x) < width * Config.notifs.clearThreshold) + x = 0; + else + closeAll(); + } + + ParallelAnimation { + running: true + + Anim { + target: notif + property: "opacity" + from: 0 + to: 1 + } + Anim { + target: notif + property: "scale" + from: 0 + to: 1 + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + + ParallelAnimation { + running: notif.closed + + Anim { + target: notif + property: "opacity" + to: 0 + } + Anim { + target: notif + property: "scale" + to: 0.6 + } + } + + NotifGroup { + id: notifInner + + modelData: notif.modelData + props: root.props + container: root.container + visibilities: root.visibilities + } + + Behavior on x { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + + Behavior on y { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/sidebar/NotifGroup.qml b/.config/quickshell/caelestia/modules/sidebar/NotifGroup.qml new file mode 100644 index 0000000..16aac33 --- /dev/null +++ b/.config/quickshell/caelestia/modules/sidebar/NotifGroup.qml @@ -0,0 +1,241 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.components.effects +import qs.services +import qs.config +import qs.utils +import Quickshell +import Quickshell.Services.Notifications +import QtQuick +import QtQuick.Layouts + +StyledRect { + id: root + + required property string modelData + required property Props props + required property Flickable container + required property var visibilities + + readonly property list notifs: Notifs.list.filter(n => n.appName === modelData) + readonly property int notifCount: notifs.reduce((acc, n) => n.closed ? acc : acc + 1, 0) + readonly property string image: notifs.find(n => !n.closed && n.image.length > 0)?.image ?? "" + readonly property string appIcon: notifs.find(n => !n.closed && n.appIcon.length > 0)?.appIcon ?? "" + readonly property int urgency: notifs.some(n => !n.closed && n.urgency === NotificationUrgency.Critical) ? NotificationUrgency.Critical : notifs.some(n => n.urgency === NotificationUrgency.Normal) ? NotificationUrgency.Normal : NotificationUrgency.Low + + readonly property int nonAnimHeight: { + const headerHeight = header.implicitHeight + (root.expanded ? Math.round(Appearance.spacing.small / 2) : 0); + const columnHeight = headerHeight + notifList.nonAnimHeight + column.Layout.topMargin + column.Layout.bottomMargin; + return Math.round(Math.max(Config.notifs.sizes.image, columnHeight) + Appearance.padding.normal * 2); + } + readonly property bool expanded: props.expandedNotifs.includes(modelData) + + function toggleExpand(expand: bool): void { + if (expand) { + if (!expanded) + props.expandedNotifs.push(modelData); + } else if (expanded) { + props.expandedNotifs.splice(props.expandedNotifs.indexOf(modelData), 1); + } + } + + Component.onDestruction: { + if (notifCount === 0 && expanded) + props.expandedNotifs.splice(props.expandedNotifs.indexOf(modelData), 1); + } + + anchors.left: parent?.left + anchors.right: parent?.right + implicitHeight: content.implicitHeight + Appearance.padding.normal * 2 + + clip: true + radius: Appearance.rounding.normal + color: Colours.layer(Colours.palette.m3surfaceContainer, 2) + + RowLayout { + id: content + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Appearance.padding.normal + + spacing: Appearance.spacing.normal + + Item { + Layout.alignment: Qt.AlignLeft | Qt.AlignTop + implicitWidth: Config.notifs.sizes.image + implicitHeight: Config.notifs.sizes.image + + Component { + id: imageComp + + Image { + source: Qt.resolvedUrl(root.image) + fillMode: Image.PreserveAspectCrop + cache: false + asynchronous: true + width: Config.notifs.sizes.image + height: Config.notifs.sizes.image + } + } + + Component { + id: appIconComp + + ColouredIcon { + implicitSize: Math.round(Config.notifs.sizes.image * 0.6) + source: Quickshell.iconPath(root.appIcon) + colour: root.urgency === NotificationUrgency.Critical ? Colours.palette.m3onError : root.urgency === NotificationUrgency.Low ? Colours.palette.m3onSurface : Colours.palette.m3onSecondaryContainer + layer.enabled: root.appIcon.endsWith("symbolic") + } + } + + Component { + id: materialIconComp + + MaterialIcon { + text: Icons.getNotifIcon(root.notifs[0]?.summary, root.urgency) + color: root.urgency === NotificationUrgency.Critical ? Colours.palette.m3onError : root.urgency === NotificationUrgency.Low ? Colours.palette.m3onSurface : Colours.palette.m3onSecondaryContainer + font.pointSize: Appearance.font.size.large + } + } + + StyledClippingRect { + anchors.fill: parent + color: root.urgency === NotificationUrgency.Critical ? Colours.palette.m3error : root.urgency === NotificationUrgency.Low ? Colours.layer(Colours.palette.m3surfaceContainerHigh, 3) : Colours.palette.m3secondaryContainer + radius: Appearance.rounding.full + + Loader { + anchors.centerIn: parent + sourceComponent: root.image ? imageComp : root.appIcon ? appIconComp : materialIconComp + } + } + + Loader { + anchors.right: parent.right + anchors.bottom: parent.bottom + active: root.appIcon && root.image + + sourceComponent: StyledRect { + implicitWidth: Config.notifs.sizes.badge + implicitHeight: Config.notifs.sizes.badge + + color: root.urgency === NotificationUrgency.Critical ? Colours.palette.m3error : root.urgency === NotificationUrgency.Low ? Colours.palette.m3surfaceContainerHigh : Colours.palette.m3secondaryContainer + radius: Appearance.rounding.full + + ColouredIcon { + anchors.centerIn: parent + implicitSize: Math.round(Config.notifs.sizes.badge * 0.6) + source: Quickshell.iconPath(root.appIcon) + colour: root.urgency === NotificationUrgency.Critical ? Colours.palette.m3onError : root.urgency === NotificationUrgency.Low ? Colours.palette.m3onSurface : Colours.palette.m3onSecondaryContainer + layer.enabled: root.appIcon.endsWith("symbolic") + } + } + } + } + + ColumnLayout { + id: column + + Layout.topMargin: -Appearance.padding.small + Layout.bottomMargin: -Appearance.padding.small / 2 + Layout.fillWidth: true + spacing: 0 + + RowLayout { + id: header + + Layout.bottomMargin: root.expanded ? Math.round(Appearance.spacing.small / 2) : 0 + Layout.fillWidth: true + spacing: Appearance.spacing.smaller + + StyledText { + Layout.fillWidth: true + text: root.modelData + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small + elide: Text.ElideRight + } + + StyledText { + animate: true + text: root.notifs.find(n => !n.closed)?.timeStr ?? "" + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.small + } + + StyledRect { + implicitWidth: expandBtn.implicitWidth + Appearance.padding.smaller * 2 + implicitHeight: groupCount.implicitHeight + Appearance.padding.small + + color: root.urgency === NotificationUrgency.Critical ? Colours.palette.m3error : Colours.layer(Colours.palette.m3surfaceContainerHigh, 3) + radius: Appearance.rounding.full + + StateLayer { + color: root.urgency === NotificationUrgency.Critical ? Colours.palette.m3onError : Colours.palette.m3onSurface + + function onClicked(): void { + root.toggleExpand(!root.expanded); + } + } + + RowLayout { + id: expandBtn + + anchors.centerIn: parent + spacing: Appearance.spacing.small / 2 + + StyledText { + id: groupCount + + Layout.leftMargin: Appearance.padding.small / 2 + animate: true + text: root.notifCount + color: root.urgency === NotificationUrgency.Critical ? Colours.palette.m3onError : Colours.palette.m3onSurface + font.pointSize: Appearance.font.size.small + } + + MaterialIcon { + Layout.rightMargin: -Appearance.padding.small / 2 + text: "expand_more" + color: root.urgency === NotificationUrgency.Critical ? Colours.palette.m3onError : Colours.palette.m3onSurface + rotation: root.expanded ? 180 : 0 + Layout.topMargin: root.expanded ? -Math.floor(Appearance.padding.smaller / 2) : 0 + + Behavior on rotation { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + + Behavior on Layout.topMargin { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + } + } + } + + Behavior on Layout.bottomMargin { + Anim {} + } + } + + NotifGroupList { + id: notifList + + props: root.props + notifs: root.notifs + expanded: root.expanded + container: root.container + visibilities: root.visibilities + onRequestToggleExpand: expand => root.toggleExpand(expand) + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/sidebar/NotifGroupList.qml b/.config/quickshell/caelestia/modules/sidebar/NotifGroupList.qml new file mode 100644 index 0000000..e586b5f --- /dev/null +++ b/.config/quickshell/caelestia/modules/sidebar/NotifGroupList.qml @@ -0,0 +1,213 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.services +import qs.config +import Quickshell +import QtQuick +import QtQuick.Layouts + +Item { + id: root + + required property Props props + required property list notifs + required property bool expanded + required property Flickable container + required property var visibilities + + readonly property real nonAnimHeight: { + let h = -root.spacing; + for (let i = 0; i < repeater.count; i++) { + const item = repeater.itemAt(i); + if (!item.modelData.closed && !item.previewHidden) + h += item.nonAnimHeight + root.spacing; + } + return h; + } + + readonly property int spacing: Math.round(Appearance.spacing.small / 2) + property bool showAllNotifs + property bool flag + + signal requestToggleExpand(expand: bool) + + onExpandedChanged: { + if (expanded) { + clearTimer.stop(); + showAllNotifs = true; + } else { + clearTimer.start(); + } + } + + Layout.fillWidth: true + implicitHeight: nonAnimHeight + + Timer { + id: clearTimer + + interval: Appearance.anim.durations.normal + onTriggered: root.showAllNotifs = false + } + + Repeater { + id: repeater + + model: ScriptModel { + values: root.showAllNotifs ? root.notifs : root.notifs.slice(0, Config.notifs.groupPreviewNum + 1) + onValuesChanged: root.flagChanged() + } + + MouseArea { + id: notif + + required property int index + required property Notifs.Notif modelData + + readonly property alias nonAnimHeight: notifInner.nonAnimHeight + readonly property bool previewHidden: { + if (root.expanded) + return false; + + let extraHidden = 0; + for (let i = 0; i < index; i++) + if (root.notifs[i].closed) + extraHidden++; + + return index >= Config.notifs.groupPreviewNum + extraHidden; + } + property int startY + + y: { + root.flag; // Force update + let y = 0; + for (let i = 0; i < index; i++) { + const item = repeater.itemAt(i); + if (!item.modelData.closed && !item.previewHidden) + y += item.nonAnimHeight + root.spacing; + } + return y; + } + + containmentMask: QtObject { + function contains(p: point): bool { + if (!root.container.contains(notif.mapToItem(root.container, p))) + return false; + return notifInner.contains(p); + } + } + + opacity: previewHidden ? 0 : 1 + scale: previewHidden ? 0.7 : 1 + + implicitWidth: root.width + implicitHeight: notifInner.implicitHeight + + hoverEnabled: true + cursorShape: notifInner.body?.hoveredLink ? Qt.PointingHandCursor : pressed ? Qt.ClosedHandCursor : undefined + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + preventStealing: !root.expanded + enabled: !modelData.closed + + drag.target: this + drag.axis: Drag.XAxis + + onPressed: event => { + startY = event.y; + if (event.button === Qt.RightButton) + root.requestToggleExpand(!root.expanded); + else if (event.button === Qt.MiddleButton) + modelData.close(); + } + onPositionChanged: event => { + if (pressed && !root.expanded) { + const diffY = event.y - startY; + if (Math.abs(diffY) > Config.notifs.expandThreshold) + root.requestToggleExpand(diffY > 0); + } + } + onReleased: event => { + if (Math.abs(x) < width * Config.notifs.clearThreshold) + x = 0; + else + modelData.close(); + } + + Component.onCompleted: modelData.lock(this) + Component.onDestruction: modelData.unlock(this) + + ParallelAnimation { + Component.onCompleted: running = !notif.previewHidden + + Anim { + target: notif + property: "opacity" + from: 0 + to: 1 + } + Anim { + target: notif + property: "scale" + from: 0.7 + to: 1 + } + } + + ParallelAnimation { + running: notif.modelData.closed + onFinished: notif.modelData.unlock(notif) + + Anim { + target: notif + property: "opacity" + to: 0 + } + Anim { + target: notif + property: "x" + to: notif.x >= 0 ? notif.width : -notif.width + } + } + + Notif { + id: notifInner + + anchors.fill: parent + modelData: notif.modelData + props: root.props + expanded: root.expanded + visibilities: root.visibilities + } + + Behavior on opacity { + Anim {} + } + + Behavior on scale { + Anim {} + } + + Behavior on x { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + + Behavior on y { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + } + } + + Behavior on implicitHeight { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } +} diff --git a/.config/quickshell/caelestia/modules/sidebar/Props.qml b/.config/quickshell/caelestia/modules/sidebar/Props.qml new file mode 100644 index 0000000..4613942 --- /dev/null +++ b/.config/quickshell/caelestia/modules/sidebar/Props.qml @@ -0,0 +1,7 @@ +import Quickshell + +PersistentProperties { + property list expandedNotifs: [] + + reloadableId: "sidebar" +} diff --git a/.config/quickshell/caelestia/modules/sidebar/Wrapper.qml b/.config/quickshell/caelestia/modules/sidebar/Wrapper.qml new file mode 100644 index 0000000..9303c6b --- /dev/null +++ b/.config/quickshell/caelestia/modules/sidebar/Wrapper.qml @@ -0,0 +1,68 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.config +import QtQuick + +Item { + id: root + + required property var visibilities + required property var panels + readonly property Props props: Props {} + + visible: width > 0 + implicitWidth: 0 + + states: State { + name: "visible" + when: root.visibilities.sidebar && Config.sidebar.enabled + + PropertyChanges { + root.implicitWidth: Config.sidebar.sizes.width + } + } + + transitions: [ + Transition { + from: "" + to: "visible" + + Anim { + target: root + property: "implicitWidth" + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + }, + Transition { + from: "visible" + to: "" + + Anim { + target: root + property: "implicitWidth" + easing.bezierCurve: root.panels.osd.width > 0 || root.panels.session.width > 0 ? Appearance.anim.curves.expressiveDefaultSpatial : Appearance.anim.curves.emphasized + } + } + ] + + Loader { + id: content + + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.margins: Appearance.padding.large + anchors.bottomMargin: 0 + + active: true + Component.onCompleted: active = Qt.binding(() => (root.visibilities.sidebar && Config.sidebar.enabled) || root.visible) + + sourceComponent: Content { + implicitWidth: Config.sidebar.sizes.width - Appearance.padding.large * 2 + props: root.props + visibilities: root.visibilities + } + } +} diff --git a/.config/quickshell/caelestia/modules/utilities/Background.qml b/.config/quickshell/caelestia/modules/utilities/Background.qml new file mode 100644 index 0000000..fbce896 --- /dev/null +++ b/.config/quickshell/caelestia/modules/utilities/Background.qml @@ -0,0 +1,55 @@ +import qs.components +import qs.services +import qs.config +import QtQuick +import QtQuick.Shapes + +ShapePath { + id: root + + required property Wrapper wrapper + required property var sidebar + readonly property real rounding: Config.border.rounding + readonly property bool flatten: wrapper.height < rounding * 2 + readonly property real roundingY: flatten ? wrapper.height / 2 : rounding + + strokeWidth: -1 + fillColor: Colours.palette.m3surface + + PathLine { + relativeX: -(root.wrapper.width + root.rounding) + relativeY: 0 + } + PathArc { + relativeX: root.rounding + relativeY: -root.roundingY + radiusX: root.rounding + radiusY: Math.min(root.rounding, root.wrapper.height) + direction: PathArc.Counterclockwise + } + PathLine { + relativeX: 0 + relativeY: -(root.wrapper.height - root.roundingY * 2) + } + PathArc { + relativeX: root.sidebar.utilsRoundingX + relativeY: -root.roundingY + radiusX: root.sidebar.utilsRoundingX + radiusY: Math.min(root.rounding, root.wrapper.height) + } + PathLine { + relativeX: root.wrapper.height > 0 ? root.wrapper.width - root.rounding - root.sidebar.utilsRoundingX : root.wrapper.width + relativeY: 0 + } + PathArc { + relativeX: root.rounding + relativeY: -root.rounding + radiusX: root.rounding + radiusY: root.rounding + direction: PathArc.Counterclockwise + } + + Behavior on fillColor { + CAnim {} + } +} diff --git a/.config/quickshell/caelestia/modules/utilities/Content.qml b/.config/quickshell/caelestia/modules/utilities/Content.qml new file mode 100644 index 0000000..902656d --- /dev/null +++ b/.config/quickshell/caelestia/modules/utilities/Content.qml @@ -0,0 +1,39 @@ +import "cards" +import qs.config +import QtQuick +import QtQuick.Layouts + +Item { + id: root + + required property var props + required property var visibilities + required property Item popouts + + implicitWidth: layout.implicitWidth + implicitHeight: layout.implicitHeight + + ColumnLayout { + id: layout + + anchors.fill: parent + spacing: Appearance.spacing.normal + + IdleInhibit {} + + Record { + props: root.props + visibilities: root.visibilities + z: 1 + } + + Toggles { + visibilities: root.visibilities + popouts: root.popouts + } + } + + RecordingDeleteModal { + props: root.props + } +} diff --git a/.config/quickshell/caelestia/modules/utilities/RecordingDeleteModal.qml b/.config/quickshell/caelestia/modules/utilities/RecordingDeleteModal.qml new file mode 100644 index 0000000..127afe9 --- /dev/null +++ b/.config/quickshell/caelestia/modules/utilities/RecordingDeleteModal.qml @@ -0,0 +1,207 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.components.controls +import qs.components.effects +import qs.services +import qs.config +import Caelestia +import QtQuick +import QtQuick.Layouts +import QtQuick.Shapes + +Loader { + id: root + + required property var props + + anchors.fill: parent + + opacity: root.props.recordingConfirmDelete ? 1 : 0 + active: opacity > 0 + + sourceComponent: MouseArea { + id: deleteConfirmation + + property string path + + Component.onCompleted: path = root.props.recordingConfirmDelete + + hoverEnabled: true + onClicked: root.props.recordingConfirmDelete = "" + + Item { + anchors.fill: parent + anchors.margins: -Appearance.padding.large + anchors.rightMargin: -Appearance.padding.large - Config.border.thickness + anchors.bottomMargin: -Appearance.padding.large - Config.border.thickness + opacity: 0.5 + + StyledRect { + anchors.fill: parent + topLeftRadius: Config.border.rounding + color: Colours.palette.m3scrim + } + + Shape { + id: shape + + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + asynchronous: true + + ShapePath { + startX: -Config.border.rounding * 2 + startY: shape.height - Config.border.thickness + strokeWidth: 0 + fillGradient: LinearGradient { + orientation: LinearGradient.Horizontal + x1: -Config.border.rounding * 2 + + GradientStop { + position: 0 + color: Qt.alpha(Colours.palette.m3scrim, 0) + } + GradientStop { + position: 1 + color: Colours.palette.m3scrim + } + } + + PathLine { + relativeX: Config.border.rounding + relativeY: 0 + } + PathArc { + relativeY: -Config.border.rounding + radiusX: Config.border.rounding + radiusY: Config.border.rounding + direction: PathArc.Counterclockwise + } + PathLine { + relativeX: 0 + relativeY: Config.border.rounding + Config.border.thickness + } + PathLine { + relativeX: -Config.border.rounding * 2 + relativeY: 0 + } + } + + ShapePath { + startX: shape.width - Config.border.rounding - Config.border.thickness + strokeWidth: 0 + fillGradient: LinearGradient { + orientation: LinearGradient.Vertical + y1: -Config.border.rounding * 2 + + GradientStop { + position: 0 + color: Qt.alpha(Colours.palette.m3scrim, 0) + } + GradientStop { + position: 1 + color: Colours.palette.m3scrim + } + } + + PathArc { + relativeX: Config.border.rounding + relativeY: -Config.border.rounding + radiusX: Config.border.rounding + radiusY: Config.border.rounding + direction: PathArc.Counterclockwise + } + PathLine { + relativeX: 0 + relativeY: -Config.border.rounding + } + PathLine { + relativeX: Config.border.thickness + relativeY: 0 + } + PathLine { + relativeX: 0 + } + } + } + } + + StyledRect { + anchors.centerIn: parent + radius: Appearance.rounding.large + color: Colours.palette.m3surfaceContainerHigh + + scale: 0 + Component.onCompleted: scale = Qt.binding(() => root.props.recordingConfirmDelete ? 1 : 0) + + width: Math.min(parent.width - Appearance.padding.large * 2, implicitWidth) + implicitWidth: deleteConfirmationLayout.implicitWidth + Appearance.padding.large * 3 + implicitHeight: deleteConfirmationLayout.implicitHeight + Appearance.padding.large * 3 + + MouseArea { + anchors.fill: parent + } + + Elevation { + anchors.fill: parent + radius: parent.radius + z: -1 + level: 3 + } + + ColumnLayout { + id: deleteConfirmationLayout + + anchors.fill: parent + anchors.margins: Appearance.padding.large * 1.5 + spacing: Appearance.spacing.normal + + StyledText { + text: qsTr("Delete recording?") + font.pointSize: Appearance.font.size.large + } + + StyledText { + Layout.fillWidth: true + text: qsTr("Recording '%1' will be permanently deleted.").arg(deleteConfirmation.path) + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + } + + RowLayout { + Layout.topMargin: Appearance.spacing.normal + Layout.alignment: Qt.AlignRight + spacing: Appearance.spacing.normal + + TextButton { + text: qsTr("Cancel") + type: TextButton.Text + onClicked: root.props.recordingConfirmDelete = "" + } + + TextButton { + text: qsTr("Delete") + type: TextButton.Text + onClicked: { + CUtils.deleteFile(Qt.resolvedUrl(root.props.recordingConfirmDelete)); + root.props.recordingConfirmDelete = ""; + } + } + } + } + + Behavior on scale { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + } + } + + Behavior on opacity { + Anim {} + } +} diff --git a/.config/quickshell/caelestia/modules/utilities/Wrapper.qml b/.config/quickshell/caelestia/modules/utilities/Wrapper.qml new file mode 100644 index 0000000..77178e3 --- /dev/null +++ b/.config/quickshell/caelestia/modules/utilities/Wrapper.qml @@ -0,0 +1,96 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.config +import Quickshell +import QtQuick + +Item { + id: root + + required property var visibilities + required property Item sidebar + required property Item popouts + + readonly property PersistentProperties props: PersistentProperties { + property bool recordingListExpanded: false + property string recordingConfirmDelete + property string recordingMode + + reloadableId: "utilities" + } + readonly property bool shouldBeActive: visibilities.sidebar || (visibilities.utilities && Config.utilities.enabled && !(visibilities.session && Config.session.enabled)) + + visible: height > 0 + implicitHeight: 0 + implicitWidth: sidebar.visible ? sidebar.width : Config.utilities.sizes.width + + onStateChanged: { + if (state === "visible" && timer.running) { + timer.triggered(); + timer.stop(); + } + } + + states: State { + name: "visible" + when: root.shouldBeActive + + PropertyChanges { + root.implicitHeight: content.implicitHeight + Appearance.padding.large * 2 + } + } + + transitions: [ + Transition { + from: "" + to: "visible" + + Anim { + target: root + property: "implicitHeight" + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + }, + Transition { + from: "visible" + to: "" + + Anim { + target: root + property: "implicitHeight" + easing.bezierCurve: Appearance.anim.curves.emphasized + } + } + ] + + Timer { + id: timer + + running: true + interval: Appearance.anim.durations.extraLarge + onTriggered: { + content.active = Qt.binding(() => root.shouldBeActive || root.visible); + content.visible = true; + } + } + + Loader { + id: content + + anchors.top: parent.top + anchors.left: parent.left + anchors.margins: Appearance.padding.large + + visible: false + active: true + + sourceComponent: Content { + implicitWidth: root.implicitWidth - Appearance.padding.large * 2 + props: root.props + visibilities: root.visibilities + popouts: root.popouts + } + } +} diff --git a/.config/quickshell/caelestia/modules/utilities/cards/IdleInhibit.qml b/.config/quickshell/caelestia/modules/utilities/cards/IdleInhibit.qml new file mode 100644 index 0000000..0344e3a --- /dev/null +++ b/.config/quickshell/caelestia/modules/utilities/cards/IdleInhibit.qml @@ -0,0 +1,125 @@ +import qs.components +import qs.components.controls +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +StyledRect { + id: root + + Layout.fillWidth: true + implicitHeight: layout.implicitHeight + (IdleInhibitor.enabled ? activeChip.implicitHeight + activeChip.anchors.topMargin : 0) + Appearance.padding.large * 2 + + radius: Appearance.rounding.normal + color: Colours.tPalette.m3surfaceContainer + clip: true + + RowLayout { + id: layout + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: Appearance.padding.large + spacing: Appearance.spacing.normal + + StyledRect { + implicitWidth: implicitHeight + implicitHeight: icon.implicitHeight + Appearance.padding.smaller * 2 + + radius: Appearance.rounding.full + color: IdleInhibitor.enabled ? Colours.palette.m3secondary : Colours.palette.m3secondaryContainer + + MaterialIcon { + id: icon + + anchors.centerIn: parent + text: "coffee" + color: IdleInhibitor.enabled ? Colours.palette.m3onSecondary : Colours.palette.m3onSecondaryContainer + font.pointSize: Appearance.font.size.large + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + StyledText { + Layout.fillWidth: true + text: qsTr("Keep Awake") + font.pointSize: Appearance.font.size.normal + elide: Text.ElideRight + } + + StyledText { + Layout.fillWidth: true + text: IdleInhibitor.enabled ? qsTr("Preventing sleep mode") : qsTr("Normal power management") + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small + elide: Text.ElideRight + } + } + + StyledSwitch { + checked: IdleInhibitor.enabled + onToggled: IdleInhibitor.enabled = checked + } + } + + Loader { + id: activeChip + + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.topMargin: Appearance.spacing.larger + anchors.bottomMargin: IdleInhibitor.enabled ? Appearance.padding.large : -implicitHeight + anchors.leftMargin: Appearance.padding.large + + opacity: IdleInhibitor.enabled ? 1 : 0 + scale: IdleInhibitor.enabled ? 1 : 0.5 + + Component.onCompleted: active = Qt.binding(() => opacity > 0) + + sourceComponent: StyledRect { + implicitWidth: activeText.implicitWidth + Appearance.padding.normal * 2 + implicitHeight: activeText.implicitHeight + Appearance.padding.small * 2 + + radius: Appearance.rounding.full + color: Colours.palette.m3primary + + StyledText { + id: activeText + + anchors.centerIn: parent + text: qsTr("Active since %1").arg(Qt.formatTime(IdleInhibitor.enabledSince, Config.services.useTwelveHourClock ? "hh:mm a" : "hh:mm")) + color: Colours.palette.m3onPrimary + font.pointSize: Math.round(Appearance.font.size.small * 0.9) + } + } + + Behavior on anchors.bottomMargin { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + + Behavior on opacity { + Anim { + duration: Appearance.anim.durations.small + } + } + + Behavior on scale { + Anim {} + } + } + + Behavior on implicitHeight { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } +} diff --git a/.config/quickshell/caelestia/modules/utilities/cards/Record.qml b/.config/quickshell/caelestia/modules/utilities/cards/Record.qml new file mode 100644 index 0000000..273c640 --- /dev/null +++ b/.config/quickshell/caelestia/modules/utilities/cards/Record.qml @@ -0,0 +1,277 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.components.controls +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +StyledRect { + id: root + + required property var props + required property var visibilities + + Layout.fillWidth: true + implicitHeight: layout.implicitHeight + layout.anchors.margins * 2 + + radius: Appearance.rounding.normal + color: Colours.tPalette.m3surfaceContainer + + ColumnLayout { + id: layout + + anchors.fill: parent + anchors.margins: Appearance.padding.large + spacing: Appearance.spacing.normal + + RowLayout { + spacing: Appearance.spacing.normal + z: 1 + + StyledRect { + implicitWidth: implicitHeight + implicitHeight: { + const h = icon.implicitHeight + Appearance.padding.smaller * 2; + return h - (h % 2); + } + + radius: Appearance.rounding.full + color: Recorder.running ? Colours.palette.m3secondary : Colours.palette.m3secondaryContainer + + MaterialIcon { + id: icon + + anchors.centerIn: parent + anchors.horizontalCenterOffset: -0.5 + anchors.verticalCenterOffset: 1.5 + text: "screen_record" + color: Recorder.running ? Colours.palette.m3onSecondary : Colours.palette.m3onSecondaryContainer + font.pointSize: Appearance.font.size.large + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + StyledText { + Layout.fillWidth: true + text: qsTr("Screen Recorder") + font.pointSize: Appearance.font.size.normal + elide: Text.ElideRight + } + + StyledText { + Layout.fillWidth: true + text: Recorder.paused ? qsTr("Recording paused") : Recorder.running ? qsTr("Recording running") : qsTr("Recording off") + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small + elide: Text.ElideRight + } + } + + SplitButton { + disabled: Recorder.running + active: menuItems.find(m => root.props.recordingMode === m.icon + m.text) ?? menuItems[0] + menu.onItemSelected: item => root.props.recordingMode = item.icon + item.text + + menuItems: [ + MenuItem { + icon: "fullscreen" + text: qsTr("Record fullscreen") + activeText: qsTr("Fullscreen") + onClicked: Recorder.start() + }, + MenuItem { + icon: "screenshot_region" + text: qsTr("Record region") + activeText: qsTr("Region") + onClicked: Recorder.start(["-r"]) + }, + MenuItem { + icon: "select_to_speak" + text: qsTr("Record fullscreen with sound") + activeText: qsTr("Fullscreen") + onClicked: Recorder.start(["-s"]) + }, + MenuItem { + icon: "volume_up" + text: qsTr("Record region with sound") + activeText: qsTr("Region") + onClicked: Recorder.start(["-sr"]) + } + ] + } + } + + Loader { + id: listOrControls + + property bool running: Recorder.running + + Layout.fillWidth: true + Layout.preferredHeight: implicitHeight + sourceComponent: running ? recordingControls : recordingList + + Behavior on Layout.preferredHeight { + id: locHeightAnim + + enabled: false + + Anim {} + } + + Behavior on running { + SequentialAnimation { + ParallelAnimation { + Anim { + target: listOrControls + property: "scale" + to: 0.7 + duration: Appearance.anim.durations.small + easing.bezierCurve: Appearance.anim.curves.standardAccel + } + Anim { + target: listOrControls + property: "opacity" + to: 0 + duration: Appearance.anim.durations.small + easing.bezierCurve: Appearance.anim.curves.standardAccel + } + } + PropertyAction { + target: locHeightAnim + property: "enabled" + value: true + } + PropertyAction {} + PropertyAction { + target: locHeightAnim + property: "enabled" + value: false + } + ParallelAnimation { + Anim { + target: listOrControls + property: "scale" + to: 1 + duration: Appearance.anim.durations.small + easing.bezierCurve: Appearance.anim.curves.standardDecel + } + Anim { + target: listOrControls + property: "opacity" + to: 1 + duration: Appearance.anim.durations.small + easing.bezierCurve: Appearance.anim.curves.standardDecel + } + } + } + } + } + } + + Component { + id: recordingList + + RecordingList { + props: root.props + visibilities: root.visibilities + } + } + + Component { + id: recordingControls + + RowLayout { + spacing: Appearance.spacing.normal + + StyledRect { + radius: Appearance.rounding.full + color: Recorder.paused ? Colours.palette.m3tertiary : Colours.palette.m3error + + implicitWidth: recText.implicitWidth + Appearance.padding.normal * 2 + implicitHeight: recText.implicitHeight + Appearance.padding.smaller * 2 + + StyledText { + id: recText + + anchors.centerIn: parent + animate: true + text: Recorder.paused ? "PAUSED" : "REC" + color: Recorder.paused ? Colours.palette.m3onTertiary : Colours.palette.m3onError + font.family: Appearance.font.family.mono + } + + Behavior on implicitWidth { + Anim {} + } + + SequentialAnimation on opacity { + running: !Recorder.paused + alwaysRunToEnd: true + loops: Animation.Infinite + + Anim { + from: 1 + to: 0 + duration: Appearance.anim.durations.large + easing.bezierCurve: Appearance.anim.curves.emphasizedAccel + } + Anim { + from: 0 + to: 1 + duration: Appearance.anim.durations.extraLarge + easing.bezierCurve: Appearance.anim.curves.emphasizedDecel + } + } + } + + StyledText { + text: { + const elapsed = Recorder.elapsed; + + const hours = Math.floor(elapsed / 3600); + const mins = Math.floor((elapsed % 3600) / 60); + const secs = Math.floor(elapsed % 60).toString().padStart(2, "0"); + + let time; + if (hours > 0) + time = `${hours}:${mins.toString().padStart(2, "0")}:${secs}`; + else + time = `${mins}:${secs}`; + + return qsTr("Recording for %1").arg(time); + } + font.pointSize: Appearance.font.size.normal + } + + Item { + Layout.fillWidth: true + } + + IconButton { + label.animate: true + icon: Recorder.paused ? "play_arrow" : "pause" + toggle: true + checked: Recorder.paused + type: IconButton.Tonal + font.pointSize: Appearance.font.size.large + onClicked: { + Recorder.togglePause(); + internalChecked = Recorder.paused; + } + } + + IconButton { + icon: "stop" + inactiveColour: Colours.palette.m3error + inactiveOnColour: Colours.palette.m3onError + font.pointSize: Appearance.font.size.large + onClicked: Recorder.stop() + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/utilities/cards/RecordingList.qml b/.config/quickshell/caelestia/modules/utilities/cards/RecordingList.qml new file mode 100644 index 0000000..b9d757a --- /dev/null +++ b/.config/quickshell/caelestia/modules/utilities/cards/RecordingList.qml @@ -0,0 +1,241 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.components.controls +import qs.components.containers +import qs.services +import qs.config +import qs.utils +import Caelestia +import Caelestia.Models +import Quickshell +import Quickshell.Widgets +import QtQuick +import QtQuick.Layouts + +ColumnLayout { + id: root + + required property var props + required property var visibilities + + spacing: 0 + + WrapperMouseArea { + Layout.fillWidth: true + + cursorShape: Qt.PointingHandCursor + onClicked: root.props.recordingListExpanded = !root.props.recordingListExpanded + + RowLayout { + spacing: Appearance.spacing.smaller + + MaterialIcon { + Layout.alignment: Qt.AlignVCenter + text: "list" + font.pointSize: Appearance.font.size.large + } + + StyledText { + Layout.alignment: Qt.AlignVCenter + Layout.fillWidth: true + text: qsTr("Recordings") + font.pointSize: Appearance.font.size.normal + } + + IconButton { + icon: root.props.recordingListExpanded ? "unfold_less" : "unfold_more" + type: IconButton.Text + label.animate: true + onClicked: root.props.recordingListExpanded = !root.props.recordingListExpanded + } + } + } + + StyledListView { + id: list + + model: FileSystemModel { + path: Paths.recsdir + nameFilters: ["recording_*.mp4"] + sortReverse: true + } + + Layout.fillWidth: true + Layout.rightMargin: -Appearance.spacing.small + implicitHeight: (Appearance.font.size.larger + Appearance.padding.small) * (root.props.recordingListExpanded ? 10 : 3) + clip: true + + StyledScrollBar.vertical: StyledScrollBar { + flickable: list + } + + delegate: RowLayout { + id: recording + + required property FileSystemEntry modelData + property string baseName + + anchors.left: list.contentItem.left + anchors.right: list.contentItem.right + anchors.rightMargin: Appearance.spacing.small + spacing: Appearance.spacing.small / 2 + + Component.onCompleted: baseName = modelData.baseName + + StyledText { + Layout.fillWidth: true + Layout.rightMargin: Appearance.spacing.small / 2 + text: { + const time = recording.baseName; + const matches = time.match(/^recording_(\d{4})(\d{2})(\d{2})_(\d{2})-(\d{2})-(\d{2})/); + if (!matches) + return time; + const date = new Date(...matches.slice(1)); + date.setMonth(date.getMonth() - 1); // Woe (months start from 0) + return qsTr("Recording at %1").arg(Qt.formatDateTime(date, Qt.locale())); + } + color: Colours.palette.m3onSurfaceVariant + elide: Text.ElideRight + } + + IconButton { + icon: "play_arrow" + type: IconButton.Text + onClicked: { + root.visibilities.utilities = false; + root.visibilities.sidebar = false; + Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.playback, recording.modelData.path]); + } + } + + IconButton { + icon: "folder" + type: IconButton.Text + onClicked: { + root.visibilities.utilities = false; + root.visibilities.sidebar = false; + Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.explorer, recording.modelData.path]); + } + } + + IconButton { + icon: "delete_forever" + type: IconButton.Text + label.color: Colours.palette.m3error + stateLayer.color: Colours.palette.m3error + onClicked: root.props.recordingConfirmDelete = recording.modelData.path + } + } + + add: Transition { + Anim { + property: "opacity" + from: 0 + to: 1 + } + Anim { + property: "scale" + from: 0.5 + to: 1 + } + } + + remove: Transition { + Anim { + property: "opacity" + to: 0 + } + Anim { + property: "scale" + to: 0.5 + } + } + + displaced: Transition { + Anim { + properties: "opacity,scale" + to: 1 + } + Anim { + property: "y" + } + } + + Loader { + anchors.centerIn: parent + + opacity: list.count === 0 ? 1 : 0 + active: opacity > 0 + + sourceComponent: ColumnLayout { + spacing: Appearance.spacing.small + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + text: "scan_delete" + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.extraLarge + + opacity: root.props.recordingListExpanded ? 1 : 0 + scale: root.props.recordingListExpanded ? 1 : 0 + Layout.preferredHeight: root.props.recordingListExpanded ? implicitHeight : 0 + + Behavior on opacity { + Anim {} + } + + Behavior on scale { + Anim {} + } + + Behavior on Layout.preferredHeight { + Anim {} + } + } + + RowLayout { + spacing: Appearance.spacing.smaller + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + text: "scan_delete" + color: Colours.palette.m3outline + + opacity: !root.props.recordingListExpanded ? 1 : 0 + scale: !root.props.recordingListExpanded ? 1 : 0 + Layout.preferredWidth: !root.props.recordingListExpanded ? implicitWidth : 0 + + Behavior on opacity { + Anim {} + } + + Behavior on scale { + Anim {} + } + + Behavior on Layout.preferredWidth { + Anim {} + } + } + + StyledText { + text: qsTr("No recordings found") + color: Colours.palette.m3outline + } + } + } + + Behavior on opacity { + Anim {} + } + } + + Behavior on implicitHeight { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/utilities/cards/Toggles.qml b/.config/quickshell/caelestia/modules/utilities/cards/Toggles.qml new file mode 100644 index 0000000..dd4a687 --- /dev/null +++ b/.config/quickshell/caelestia/modules/utilities/cards/Toggles.qml @@ -0,0 +1,113 @@ +import qs.components +import qs.components.controls +import qs.services +import qs.config +import qs.modules.controlcenter +import Quickshell +import Quickshell.Bluetooth +import QtQuick +import QtQuick.Layouts + +StyledRect { + id: root + + required property var visibilities + required property Item popouts + + Layout.fillWidth: true + implicitHeight: layout.implicitHeight + Appearance.padding.large * 2 + + radius: Appearance.rounding.normal + color: Colours.tPalette.m3surfaceContainer + + ColumnLayout { + id: layout + + anchors.fill: parent + anchors.margins: Appearance.padding.large + spacing: Appearance.spacing.normal + + StyledText { + text: qsTr("Quick Toggles") + font.pointSize: Appearance.font.size.normal + } + + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: Appearance.spacing.small + + Toggle { + icon: "wifi" + checked: Nmcli.wifiEnabled + onClicked: Nmcli.toggleWifi() + } + + Toggle { + icon: "bluetooth" + checked: Bluetooth.defaultAdapter?.enabled ?? false + onClicked: { + const adapter = Bluetooth.defaultAdapter; + if (adapter) + adapter.enabled = !adapter.enabled; + } + } + + Toggle { + icon: "mic" + checked: !Audio.sourceMuted + onClicked: { + const audio = Audio.source?.audio; + if (audio) + audio.muted = !audio.muted; + } + } + + Toggle { + icon: "settings" + inactiveOnColour: Colours.palette.m3onSurfaceVariant + toggle: false + onClicked: { + root.visibilities.utilities = false; + root.popouts.detach("network"); + } + } + + Toggle { + icon: "gamepad" + checked: GameMode.enabled + onClicked: GameMode.enabled = !GameMode.enabled + } + + Toggle { + icon: "notifications_off" + checked: Notifs.dnd + onClicked: Notifs.dnd = !Notifs.dnd + } + + Toggle { + icon: "vpn_key" + checked: VPN.connected + enabled: !VPN.connecting + visible: Config.utilities.vpn.provider.some(p => typeof p === "object" ? (p.enabled === true) : false) + onClicked: VPN.toggle() + } + } + } + + component Toggle: IconButton { + Layout.fillWidth: true + Layout.preferredWidth: implicitWidth + (stateLayer.pressed ? Appearance.padding.large : internalChecked ? Appearance.padding.smaller : 0) + radius: stateLayer.pressed ? Appearance.rounding.small / 2 : internalChecked ? Appearance.rounding.small : Appearance.rounding.normal + inactiveColour: Colours.layer(Colours.palette.m3surfaceContainerHighest, 2) + toggle: true + radiusAnim.duration: Appearance.anim.durations.expressiveFastSpatial + radiusAnim.easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + + Behavior on Layout.preferredWidth { + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/utilities/toasts/ToastItem.qml b/.config/quickshell/caelestia/modules/utilities/toasts/ToastItem.qml new file mode 100644 index 0000000..f475500 --- /dev/null +++ b/.config/quickshell/caelestia/modules/utilities/toasts/ToastItem.qml @@ -0,0 +1,135 @@ +import qs.components +import qs.components.effects +import qs.services +import qs.config +import Caelestia +import QtQuick +import QtQuick.Layouts + +StyledRect { + id: root + + required property Toast modelData + + anchors.left: parent.left + anchors.right: parent.right + implicitHeight: layout.implicitHeight + Appearance.padding.smaller * 2 + + radius: Appearance.rounding.normal + color: { + if (root.modelData.type === Toast.Success) + return Colours.palette.m3successContainer; + if (root.modelData.type === Toast.Warning) + return Colours.palette.m3secondary; + if (root.modelData.type === Toast.Error) + return Colours.palette.m3errorContainer; + return Colours.palette.m3surface; + } + + border.width: 1 + border.color: { + let colour = Colours.palette.m3outlineVariant; + if (root.modelData.type === Toast.Success) + colour = Colours.palette.m3success; + if (root.modelData.type === Toast.Warning) + colour = Colours.palette.m3secondaryContainer; + if (root.modelData.type === Toast.Error) + colour = Colours.palette.m3error; + return Qt.alpha(colour, 0.3); + } + + Elevation { + anchors.fill: parent + radius: parent.radius + opacity: parent.opacity + z: -1 + level: 3 + } + + RowLayout { + id: layout + + anchors.fill: parent + anchors.margins: Appearance.padding.smaller + anchors.leftMargin: Appearance.padding.normal + anchors.rightMargin: Appearance.padding.normal + spacing: Appearance.spacing.normal + + StyledRect { + radius: Appearance.rounding.normal + color: { + if (root.modelData.type === Toast.Success) + return Colours.palette.m3success; + if (root.modelData.type === Toast.Warning) + return Colours.palette.m3secondaryContainer; + if (root.modelData.type === Toast.Error) + return Colours.palette.m3error; + return Colours.palette.m3surfaceContainerHigh; + } + + implicitWidth: implicitHeight + implicitHeight: icon.implicitHeight + Appearance.padding.smaller * 2 + + MaterialIcon { + id: icon + + anchors.centerIn: parent + text: root.modelData.icon + color: { + if (root.modelData.type === Toast.Success) + return Colours.palette.m3onSuccess; + if (root.modelData.type === Toast.Warning) + return Colours.palette.m3onSecondaryContainer; + if (root.modelData.type === Toast.Error) + return Colours.palette.m3onError; + return Colours.palette.m3onSurfaceVariant; + } + font.pointSize: Math.round(Appearance.font.size.large * 1.2) + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + StyledText { + id: title + + Layout.fillWidth: true + text: root.modelData.title + color: { + if (root.modelData.type === Toast.Success) + return Colours.palette.m3onSuccessContainer; + if (root.modelData.type === Toast.Warning) + return Colours.palette.m3onSecondary; + if (root.modelData.type === Toast.Error) + return Colours.palette.m3onErrorContainer; + return Colours.palette.m3onSurface; + } + font.pointSize: Appearance.font.size.normal + elide: Text.ElideRight + } + + StyledText { + Layout.fillWidth: true + textFormat: Text.StyledText + text: root.modelData.message + color: { + if (root.modelData.type === Toast.Success) + return Colours.palette.m3onSuccessContainer; + if (root.modelData.type === Toast.Warning) + return Colours.palette.m3onSecondary; + if (root.modelData.type === Toast.Error) + return Colours.palette.m3onErrorContainer; + return Colours.palette.m3onSurface; + } + opacity: 0.8 + elide: Text.ElideRight + } + } + } + + Behavior on border.color { + CAnim {} + } +} diff --git a/.config/quickshell/caelestia/modules/utilities/toasts/Toasts.qml b/.config/quickshell/caelestia/modules/utilities/toasts/Toasts.qml new file mode 100644 index 0000000..2915404 --- /dev/null +++ b/.config/quickshell/caelestia/modules/utilities/toasts/Toasts.qml @@ -0,0 +1,143 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.config +import Caelestia +import Quickshell +import QtQuick + +Item { + id: root + + readonly property int spacing: Appearance.spacing.small + property bool flag + + implicitWidth: Config.utilities.sizes.toastWidth - Appearance.padding.normal * 2 + implicitHeight: { + let h = -spacing; + for (let i = 0; i < repeater.count; i++) { + const item = repeater.itemAt(i) as ToastWrapper; + if (!item.modelData.closed && !item.previewHidden) + h += item.implicitHeight + spacing; + } + return h; + } + + Repeater { + id: repeater + + model: ScriptModel { + values: { + const toasts = []; + let count = 0; + for (const toast of Toaster.toasts) { + toasts.push(toast); + if (!toast.closed) { + count++; + if (count > Config.utilities.maxToasts) + break; + } + } + return toasts; + } + onValuesChanged: root.flagChanged() + } + + ToastWrapper {} + } + + component ToastWrapper: MouseArea { + id: toast + + required property int index + required property Toast modelData + + readonly property bool previewHidden: { + let extraHidden = 0; + for (let i = 0; i < index; i++) + if (Toaster.toasts[i].closed) + extraHidden++; + return index >= Config.utilities.maxToasts + extraHidden; + } + + onPreviewHiddenChanged: { + if (initAnim.running && previewHidden) + initAnim.stop(); + } + + opacity: modelData.closed || previewHidden ? 0 : 1 + scale: modelData.closed || previewHidden ? 0.7 : 1 + + anchors.bottomMargin: { + root.flag; // Force update + let y = 0; + for (let i = 0; i < index; i++) { + const item = repeater.itemAt(i) as ToastWrapper; + if (item && !item.modelData.closed && !item.previewHidden) + y += item.implicitHeight + root.spacing; + } + return y; + } + + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + implicitHeight: toastInner.implicitHeight + + acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton + onClicked: modelData.close() + + Component.onCompleted: modelData.lock(this) + + Anim { + id: initAnim + + Component.onCompleted: running = !toast.previewHidden + + target: toast + properties: "opacity,scale" + from: 0 + to: 1 + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + + ParallelAnimation { + running: toast.modelData.closed + onStarted: toast.anchors.bottomMargin = toast.anchors.bottomMargin + onFinished: toast.modelData.unlock(toast) + + Anim { + target: toast + property: "opacity" + to: 0 + } + Anim { + target: toast + property: "scale" + to: 0.7 + } + } + + ToastItem { + id: toastInner + + modelData: toast.modelData + } + + Behavior on opacity { + Anim {} + } + + Behavior on scale { + Anim {} + } + + Behavior on anchors.bottomMargin { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + } +} diff --git a/.config/quickshell/caelestia/modules/windowinfo/Buttons.qml b/.config/quickshell/caelestia/modules/windowinfo/Buttons.qml new file mode 100644 index 0000000..89acfe6 --- /dev/null +++ b/.config/quickshell/caelestia/modules/windowinfo/Buttons.qml @@ -0,0 +1,180 @@ +import qs.components +import qs.services +import qs.config +import Quickshell.Widgets +import QtQuick +import QtQuick.Layouts + +ColumnLayout { + id: root + + required property var client + property bool moveToWsExpanded + + anchors.fill: parent + spacing: Appearance.spacing.small + + RowLayout { + Layout.topMargin: Appearance.padding.large + Layout.leftMargin: Appearance.padding.large + Layout.rightMargin: Appearance.padding.large + + spacing: Appearance.spacing.normal + + StyledText { + Layout.fillWidth: true + text: qsTr("Move to workspace") + elide: Text.ElideRight + } + + StyledRect { + color: Colours.palette.m3primary + radius: Appearance.rounding.small + + implicitWidth: moveToWsIcon.implicitWidth + Appearance.padding.small * 2 + implicitHeight: moveToWsIcon.implicitHeight + Appearance.padding.small + + StateLayer { + color: Colours.palette.m3onPrimary + + function onClicked(): void { + root.moveToWsExpanded = !root.moveToWsExpanded; + } + } + + MaterialIcon { + id: moveToWsIcon + + anchors.centerIn: parent + + animate: true + text: root.moveToWsExpanded ? "expand_more" : "keyboard_arrow_right" + color: Colours.palette.m3onPrimary + font.pointSize: Appearance.font.size.large + } + } + } + + WrapperItem { + Layout.fillWidth: true + Layout.leftMargin: Appearance.padding.large * 2 + Layout.rightMargin: Appearance.padding.large * 2 + + Layout.preferredHeight: root.moveToWsExpanded ? implicitHeight : 0 + clip: true + + topMargin: Appearance.spacing.normal + bottomMargin: Appearance.spacing.normal + + GridLayout { + id: wsGrid + + rowSpacing: Appearance.spacing.smaller + columnSpacing: Appearance.spacing.normal + columns: 5 + + Repeater { + model: 10 + + Button { + required property int index + readonly property int wsId: Math.floor((Hypr.activeWsId - 1) / 10) * 10 + index + 1 + readonly property bool isCurrent: root.client?.workspace.id === wsId + + color: isCurrent ? Colours.tPalette.m3surfaceContainerHighest : Colours.palette.m3tertiaryContainer + onColor: isCurrent ? Colours.palette.m3onSurface : Colours.palette.m3onTertiaryContainer + text: wsId + disabled: isCurrent + + function onClicked(): void { + Hypr.dispatch(`movetoworkspace ${wsId},address:0x${root.client?.address}`); + } + } + } + } + + Behavior on Layout.preferredHeight { + Anim {} + } + } + + RowLayout { + Layout.fillWidth: true + Layout.leftMargin: Appearance.padding.large + Layout.rightMargin: Appearance.padding.large + Layout.bottomMargin: Appearance.padding.large + + spacing: root.client?.lastIpcObject.floating ? Appearance.spacing.normal : Appearance.spacing.small + + Button { + color: Colours.palette.m3secondaryContainer + onColor: Colours.palette.m3onSecondaryContainer + text: root.client?.lastIpcObject.floating ? qsTr("Tile") : qsTr("Float") + + function onClicked(): void { + Hypr.dispatch(`togglefloating address:0x${root.client?.address}`); + } + } + + Loader { + active: root.client?.lastIpcObject.floating + Layout.fillWidth: active + Layout.leftMargin: active ? 0 : -parent.spacing + Layout.rightMargin: active ? 0 : -parent.spacing + + sourceComponent: Button { + color: Colours.palette.m3secondaryContainer + onColor: Colours.palette.m3onSecondaryContainer + text: root.client?.lastIpcObject.pinned ? qsTr("Unpin") : qsTr("Pin") + + function onClicked(): void { + Hypr.dispatch(`pin address:0x${root.client?.address}`); + } + } + } + + Button { + color: Colours.palette.m3errorContainer + onColor: Colours.palette.m3onErrorContainer + text: qsTr("Kill") + + function onClicked(): void { + Hypr.dispatch(`killwindow address:0x${root.client?.address}`); + } + } + } + + component Button: StyledRect { + property color onColor: Colours.palette.m3onSurface + property alias disabled: stateLayer.disabled + property alias text: label.text + + function onClicked(): void { + } + + radius: Appearance.rounding.small + + Layout.fillWidth: true + implicitHeight: label.implicitHeight + Appearance.padding.small * 2 + + StateLayer { + id: stateLayer + + color: parent.onColor + + function onClicked(): void { + parent.onClicked(); + } + } + + StyledText { + id: label + + anchors.centerIn: parent + + animate: true + color: parent.onColor + font.pointSize: Appearance.font.size.normal + } + } +} diff --git a/.config/quickshell/caelestia/modules/windowinfo/Details.qml b/.config/quickshell/caelestia/modules/windowinfo/Details.qml new file mode 100644 index 0000000..f9ee66a --- /dev/null +++ b/.config/quickshell/caelestia/modules/windowinfo/Details.qml @@ -0,0 +1,164 @@ +import qs.components +import qs.services +import qs.config +import Quickshell.Hyprland +import QtQuick +import QtQuick.Layouts + +ColumnLayout { + id: root + + required property HyprlandToplevel client + + anchors.fill: parent + spacing: Appearance.spacing.small + + Label { + Layout.topMargin: Appearance.padding.large * 2 + + text: root.client?.title ?? qsTr("No active client") + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + + font.pointSize: Appearance.font.size.large + font.weight: 500 + } + + Label { + text: root.client?.lastIpcObject.class ?? qsTr("No active client") + color: Colours.palette.m3tertiary + + font.pointSize: Appearance.font.size.larger + } + + StyledRect { + Layout.fillWidth: true + Layout.preferredHeight: 1 + Layout.leftMargin: Appearance.padding.large * 2 + Layout.rightMargin: Appearance.padding.large * 2 + Layout.topMargin: Appearance.spacing.normal + Layout.bottomMargin: Appearance.spacing.large + + color: Colours.palette.m3secondary + } + + Detail { + icon: "location_on" + text: qsTr("Address: %1").arg(`0x${root.client?.address}` ?? "unknown") + color: Colours.palette.m3primary + } + + Detail { + icon: "location_searching" + text: qsTr("Position: %1, %2").arg(root.client?.lastIpcObject.at[0] ?? -1).arg(root.client?.lastIpcObject.at[1] ?? -1) + } + + Detail { + icon: "resize" + text: qsTr("Size: %1 x %2").arg(root.client?.lastIpcObject.size[0] ?? -1).arg(root.client?.lastIpcObject.size[1] ?? -1) + color: Colours.palette.m3tertiary + } + + Detail { + icon: "workspaces" + text: qsTr("Workspace: %1 (%2)").arg(root.client?.workspace.name ?? -1).arg(root.client?.workspace.id ?? -1) + color: Colours.palette.m3secondary + } + + Detail { + icon: "desktop_windows" + text: { + const mon = root.client?.monitor; + if (mon) + return qsTr("Monitor: %1 (%2) at %3, %4").arg(mon.name).arg(mon.id).arg(mon.x).arg(mon.y); + return qsTr("Monitor: unknown"); + } + } + + Detail { + icon: "page_header" + text: qsTr("Initial title: %1").arg(root.client?.lastIpcObject.initialTitle ?? "unknown") + color: Colours.palette.m3tertiary + } + + Detail { + icon: "category" + text: qsTr("Initial class: %1").arg(root.client?.lastIpcObject.initialClass ?? "unknown") + } + + Detail { + icon: "account_tree" + text: qsTr("Process id: %1").arg(root.client?.lastIpcObject.pid ?? -1) + color: Colours.palette.m3primary + } + + Detail { + icon: "picture_in_picture_center" + text: qsTr("Floating: %1").arg(root.client?.lastIpcObject.floating ? "yes" : "no") + color: Colours.palette.m3secondary + } + + Detail { + icon: "gradient" + text: qsTr("Xwayland: %1").arg(root.client?.lastIpcObject.xwayland ? "yes" : "no") + } + + Detail { + icon: "keep" + text: qsTr("Pinned: %1").arg(root.client?.lastIpcObject.pinned ? "yes" : "no") + color: Colours.palette.m3secondary + } + + Detail { + icon: "fullscreen" + text: { + const fs = root.client?.lastIpcObject.fullscreen; + if (fs) + return qsTr("Fullscreen state: %1").arg(fs == 0 ? "off" : fs == 1 ? "maximised" : "on"); + return qsTr("Fullscreen state: unknown"); + } + color: Colours.palette.m3tertiary + } + + Item { + Layout.fillHeight: true + } + + component Detail: RowLayout { + id: detail + + required property string icon + required property string text + property alias color: icon.color + + Layout.leftMargin: Appearance.padding.large + Layout.rightMargin: Appearance.padding.large + Layout.fillWidth: true + + spacing: Appearance.spacing.smaller + + MaterialIcon { + id: icon + + Layout.alignment: Qt.AlignVCenter + text: detail.icon + } + + StyledText { + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + + text: detail.text + elide: Text.ElideRight + font.pointSize: Appearance.font.size.normal + } + } + + component Label: StyledText { + Layout.leftMargin: Appearance.padding.large + Layout.rightMargin: Appearance.padding.large + Layout.fillWidth: true + elide: Text.ElideRight + horizontalAlignment: Text.AlignHCenter + animate: true + } +} diff --git a/.config/quickshell/caelestia/modules/windowinfo/Preview.qml b/.config/quickshell/caelestia/modules/windowinfo/Preview.qml new file mode 100644 index 0000000..4cc0aab --- /dev/null +++ b/.config/quickshell/caelestia/modules/windowinfo/Preview.qml @@ -0,0 +1,96 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.services +import qs.config +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland +import QtQuick +import QtQuick.Layouts + +Item { + id: root + + required property ShellScreen screen + required property HyprlandToplevel client + + Layout.preferredWidth: preview.implicitWidth + Appearance.padding.large * 2 + Layout.fillHeight: true + + StyledClippingRect { + id: preview + + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.bottom: label.top + anchors.topMargin: Appearance.padding.large + anchors.bottomMargin: Appearance.spacing.normal + + implicitWidth: view.implicitWidth + + color: Colours.tPalette.m3surfaceContainer + radius: Appearance.rounding.small + + Loader { + anchors.centerIn: parent + active: !root.client + + sourceComponent: ColumnLayout { + spacing: 0 + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + text: "web_asset_off" + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.extraLarge * 3 + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: qsTr("No active client") + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.extraLarge + font.weight: 500 + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: qsTr("Try switching to a window") + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.large + } + } + } + + ScreencopyView { + id: view + + anchors.centerIn: parent + + captureSource: root.client?.wayland ?? null + live: true + + constraintSize.width: root.client ? parent.height * Math.min(root.screen.width / root.screen.height, root.client?.lastIpcObject.size[0] / root.client?.lastIpcObject.size[1]) : parent.height + constraintSize.height: parent.height + } + } + + StyledText { + id: label + + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: Appearance.padding.large + + animate: true + text: { + const client = root.client; + if (!client) + return qsTr("No active client"); + + const mon = client.monitor; + return qsTr("%1 on monitor %2 at %3, %4").arg(client.title).arg(mon.name).arg(client.lastIpcObject.at[0]).arg(client.lastIpcObject.at[1]); + } + } +} diff --git a/.config/quickshell/caelestia/modules/windowinfo/WindowInfo.qml b/.config/quickshell/caelestia/modules/windowinfo/WindowInfo.qml new file mode 100644 index 0000000..919b3fb --- /dev/null +++ b/.config/quickshell/caelestia/modules/windowinfo/WindowInfo.qml @@ -0,0 +1,64 @@ +import qs.components +import qs.services +import qs.config +import Quickshell +import Quickshell.Hyprland +import QtQuick +import QtQuick.Layouts + +Item { + id: root + + required property ShellScreen screen + required property HyprlandToplevel client + + implicitWidth: child.implicitWidth + implicitHeight: screen.height * Config.winfo.sizes.heightMult + + RowLayout { + id: child + + anchors.fill: parent + anchors.margins: Appearance.padding.large + + spacing: Appearance.spacing.normal + + Preview { + screen: root.screen + client: root.client + } + + ColumnLayout { + spacing: Appearance.spacing.normal + + Layout.preferredWidth: Config.winfo.sizes.detailsWidth + Layout.fillHeight: true + + StyledRect { + Layout.fillWidth: true + Layout.fillHeight: true + + color: Colours.tPalette.m3surfaceContainer + radius: Appearance.rounding.normal + + Details { + client: root.client + } + } + + StyledRect { + Layout.fillWidth: true + Layout.preferredHeight: buttons.implicitHeight + + color: Colours.tPalette.m3surfaceContainer + radius: Appearance.rounding.normal + + Buttons { + id: buttons + + client: root.client + } + } + } + } +} diff --git a/.config/quickshell/caelestia/nix/app2unit.nix b/.config/quickshell/caelestia/nix/app2unit.nix new file mode 100644 index 0000000..51b4241 --- /dev/null +++ b/.config/quickshell/caelestia/nix/app2unit.nix @@ -0,0 +1,14 @@ +{ + pkgs, # To ensure the nixpkgs version of app2unit + fetchFromGitHub, + ... +}: +pkgs.app2unit.overrideAttrs (final: prev: rec { + version = "1.0.3"; # Fix old issue related to missing env var + src = fetchFromGitHub { + owner = "Vladimir-csp"; + repo = "app2unit"; + tag = "v${version}"; + hash = "sha256-7eEVjgs+8k+/NLteSBKgn4gPaPLHC+3Uzlmz6XB0930="; + }; +}) diff --git a/.config/quickshell/caelestia/nix/default.nix b/.config/quickshell/caelestia/nix/default.nix new file mode 100644 index 0000000..67747b2 --- /dev/null +++ b/.config/quickshell/caelestia/nix/default.nix @@ -0,0 +1,158 @@ +{ + rev, + lib, + stdenv, + makeWrapper, + makeFontsConf, + fish, + ddcutil, + brightnessctl, + app2unit, + networkmanager, + lm_sensors, + swappy, + wl-clipboard, + libqalculate, + bash, + hyprland, + material-symbols, + rubik, + nerd-fonts, + qt6, + quickshell, + aubio, + libcava, + fftw, + pipewire, + xkeyboard-config, + cmake, + ninja, + pkg-config, + caelestia-cli, + debug ? false, + withCli ? false, + extraRuntimeDeps ? [], +}: let + version = "1.0.0"; + + runtimeDeps = + [ + fish + ddcutil + brightnessctl + app2unit + networkmanager + lm_sensors + swappy + wl-clipboard + libqalculate + bash + hyprland + ] + ++ extraRuntimeDeps + ++ lib.optional withCli caelestia-cli; + + fontconfig = makeFontsConf { + fontDirectories = [material-symbols rubik nerd-fonts.caskaydia-cove]; + }; + + cmakeBuildType = + if debug + then "Debug" + else "RelWithDebInfo"; + + cmakeVersionFlags = [ + (lib.cmakeFeature "VERSION" version) + (lib.cmakeFeature "GIT_REVISION" rev) + (lib.cmakeFeature "DISTRIBUTOR" "nix-flake") + ]; + + extras = stdenv.mkDerivation { + inherit cmakeBuildType; + name = "caelestia-extras${lib.optionalString debug "-debug"}"; + src = lib.fileset.toSource { + root = ./..; + fileset = lib.fileset.union ./../CMakeLists.txt ./../extras; + }; + + nativeBuildInputs = [cmake ninja]; + + cmakeFlags = + [ + (lib.cmakeFeature "ENABLE_MODULES" "extras") + (lib.cmakeFeature "INSTALL_LIBDIR" "${placeholder "out"}/lib") + ] + ++ cmakeVersionFlags; + }; + + plugin = stdenv.mkDerivation { + inherit cmakeBuildType; + name = "caelestia-qml-plugin${lib.optionalString debug "-debug"}"; + src = lib.fileset.toSource { + root = ./..; + fileset = lib.fileset.union ./../CMakeLists.txt ./../plugin; + }; + + nativeBuildInputs = [cmake ninja pkg-config]; + buildInputs = [qt6.qtbase qt6.qtdeclarative libqalculate pipewire aubio libcava fftw]; + + dontWrapQtApps = true; + cmakeFlags = + [ + (lib.cmakeFeature "ENABLE_MODULES" "plugin") + (lib.cmakeFeature "INSTALL_QMLDIR" qt6.qtbase.qtQmlPrefix) + ] + ++ cmakeVersionFlags; + }; +in + stdenv.mkDerivation { + inherit version cmakeBuildType; + pname = "caelestia-shell${lib.optionalString debug "-debug"}"; + src = ./..; + + nativeBuildInputs = [cmake ninja makeWrapper qt6.wrapQtAppsHook]; + buildInputs = [quickshell extras plugin xkeyboard-config qt6.qtbase]; + propagatedBuildInputs = runtimeDeps; + + cmakeFlags = + [ + (lib.cmakeFeature "ENABLE_MODULES" "shell") + (lib.cmakeFeature "INSTALL_QSCONFDIR" "${placeholder "out"}/share/caelestia-shell") + ] + ++ cmakeVersionFlags; + + dontStrip = debug; + + prePatch = '' + substituteInPlace assets/pam.d/fprint \ + --replace-fail pam_fprintd.so /run/current-system/sw/lib/security/pam_fprintd.so + substituteInPlace shell.qml \ + --replace-fail 'ShellRoot {' 'ShellRoot { settings.watchFiles: false' + ''; + + postInstall = '' + makeWrapper ${quickshell}/bin/qs $out/bin/caelestia-shell \ + --prefix PATH : "${lib.makeBinPath runtimeDeps}" \ + --set FONTCONFIG_FILE "${fontconfig}" \ + --set CAELESTIA_LIB_DIR ${extras}/lib \ + --set CAELESTIA_XKB_RULES_PATH ${xkeyboard-config}/share/xkeyboard-config-2/rules/base.lst \ + --add-flags "-p $out/share/caelestia-shell" + + mkdir -p $out/lib + ln -s ${extras}/lib/* $out/lib/ + + # Ensure wrap_term_launch.sh is executable + chmod 755 $out/share/caelestia-shell/assets/wrap_term_launch.sh + ''; + + passthru = { + inherit plugin extras; + }; + + meta = { + description = "A very segsy desktop shell"; + homepage = "https://github.com/caelestia-dots/shell"; + license = lib.licenses.gpl3Only; + mainProgram = "caelestia-shell"; + }; + } diff --git a/.config/quickshell/caelestia/nix/hm-module.nix b/.config/quickshell/caelestia/nix/hm-module.nix new file mode 100644 index 0000000..2976cf6 --- /dev/null +++ b/.config/quickshell/caelestia/nix/hm-module.nix @@ -0,0 +1,136 @@ +self: { + config, + pkgs, + lib, + ... +}: let + inherit (pkgs.stdenv.hostPlatform) system; + + cli-default = self.inputs.caelestia-cli.packages.${system}.default; + shell-default = self.packages.${system}.with-cli; + + cfg = config.programs.caelestia; +in { + imports = [ + (lib.mkRenamedOptionModule ["programs" "caelestia" "environment"] ["programs" "caelestia" "systemd" "environment"]) + ]; + options = with lib; { + programs.caelestia = { + enable = mkEnableOption "Enable Caelestia shell"; + package = mkOption { + type = types.package; + default = shell-default; + description = "The package of Caelestia shell"; + }; + systemd = { + enable = mkOption { + type = types.bool; + default = true; + description = "Enable the systemd service for Caelestia shell"; + }; + target = mkOption { + type = types.str; + description = '' + The systemd target that will automatically start the Caelestia shell. + ''; + default = config.wayland.systemd.target; + }; + environment = mkOption { + type = types.listOf types.str; + description = "Extra Environment variables to pass to the Caelestia shell systemd service."; + default = []; + example = [ + "QT_QPA_PLATFORMTHEME=gtk3" + ]; + }; + }; + settings = mkOption { + type = types.attrsOf types.anything; + default = {}; + description = "Caelestia shell settings"; + }; + extraConfig = mkOption { + type = types.str; + default = ""; + description = "Caelestia shell extra configs written to shell.json"; + }; + cli = { + enable = mkEnableOption "Enable Caelestia CLI"; + package = mkOption { + type = types.package; + default = cli-default; + description = "The package of Caelestia CLI"; # Doesn't override the shell's CLI, only change from home.packages + }; + settings = mkOption { + type = types.attrsOf types.anything; + default = {}; + description = "Caelestia CLI settings"; + }; + extraConfig = mkOption { + type = types.str; + default = ""; + description = "Caelestia CLI extra configs written to cli.json"; + }; + }; + }; + }; + + config = let + cli = cfg.cli.package; + shell = cfg.package; + in + lib.mkIf cfg.enable { + systemd.user.services.caelestia = lib.mkIf cfg.systemd.enable { + Unit = { + Description = "Caelestia Shell Service"; + After = [cfg.systemd.target]; + PartOf = [cfg.systemd.target]; + X-Restart-Triggers = lib.mkIf (cfg.settings != {}) [ + "${config.xdg.configFile."caelestia/shell.json".source}" + ]; + }; + + Service = { + Type = "exec"; + ExecStart = "${shell}/bin/caelestia-shell"; + Restart = "on-failure"; + RestartSec = "5s"; + TimeoutStopSec = "5s"; + Environment = + [ + "QT_QPA_PLATFORM=wayland" + ] + ++ cfg.systemd.environment; + + Slice = "session.slice"; + }; + + Install = { + WantedBy = [cfg.systemd.target]; + }; + }; + + xdg.configFile = let + mkConfig = c: + lib.pipe ( + if c.extraConfig != "" + then c.extraConfig + else "{}" + ) [ + builtins.fromJSON + (lib.recursiveUpdate c.settings) + builtins.toJSON + ]; + shouldGenerate = c: c.extraConfig != "" || c.settings != {}; + in { + "caelestia/shell.json" = lib.mkIf (shouldGenerate cfg) { + text = mkConfig cfg; + }; + "caelestia/cli.json" = lib.mkIf (shouldGenerate cfg.cli) { + text = mkConfig cfg.cli; + }; + }; + + home.packages = [shell] ++ lib.optional cfg.cli.enable cli; + }; +} diff --git a/.config/quickshell/caelestia/plugin/CMakeLists.txt b/.config/quickshell/caelestia/plugin/CMakeLists.txt new file mode 100644 index 0000000..062959c --- /dev/null +++ b/.config/quickshell/caelestia/plugin/CMakeLists.txt @@ -0,0 +1 @@ +add_subdirectory(src/Caelestia) diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/CMakeLists.txt b/.config/quickshell/caelestia/plugin/src/Caelestia/CMakeLists.txt new file mode 100644 index 0000000..1b7d0e4 --- /dev/null +++ b/.config/quickshell/caelestia/plugin/src/Caelestia/CMakeLists.txt @@ -0,0 +1,62 @@ +find_package(Qt6 REQUIRED COMPONENTS Core Qml Gui Quick Concurrent Sql Network DBus) +find_package(PkgConfig REQUIRED) +pkg_check_modules(Qalculate IMPORTED_TARGET libqalculate REQUIRED) +pkg_check_modules(Pipewire IMPORTED_TARGET libpipewire-0.3 REQUIRED) +pkg_check_modules(Aubio IMPORTED_TARGET aubio REQUIRED) +pkg_check_modules(Cava IMPORTED_TARGET libcava QUIET) +if(NOT Cava_FOUND) + pkg_check_modules(Cava IMPORTED_TARGET cava REQUIRED) +endif() + +set(QT_QML_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qml") +qt_standard_project_setup(REQUIRES 6.9) + +function(qml_module arg_TARGET) + cmake_parse_arguments(PARSE_ARGV 1 arg "" "URI" "SOURCES;LIBRARIES") + + qt_add_qml_module(${arg_TARGET} + URI ${arg_URI} + VERSION ${VERSION} + SOURCES ${arg_SOURCES} + ) + + qt_query_qml_module(${arg_TARGET} + URI module_uri + VERSION module_version + PLUGIN_TARGET module_plugin_target + TARGET_PATH module_target_path + QMLDIR module_qmldir + TYPEINFO module_typeinfo + ) + + message(STATUS "Created QML module ${module_uri}, version ${module_version}") + + set(module_dir "${INSTALL_QMLDIR}/${module_target_path}") + install(TARGETS ${arg_TARGET} LIBRARY DESTINATION "${module_dir}" RUNTIME DESTINATION "${module_dir}") + install(TARGETS "${module_plugin_target}" LIBRARY DESTINATION "${module_dir}" RUNTIME DESTINATION "${module_dir}") + install(FILES "${module_qmldir}" DESTINATION "${module_dir}") + install(FILES "${module_typeinfo}" DESTINATION "${module_dir}") + + target_link_libraries(${arg_TARGET} PRIVATE Qt::Core Qt::Qml ${arg_LIBRARIES}) +endfunction() + +qml_module(caelestia + URI Caelestia + SOURCES + cutils.hpp cutils.cpp + qalculator.hpp qalculator.cpp + appdb.hpp appdb.cpp + requests.hpp requests.cpp + toaster.hpp toaster.cpp + imageanalyser.hpp imageanalyser.cpp + LIBRARIES + Qt::Gui + Qt::Quick + Qt::Concurrent + Qt::Sql + PkgConfig::Qalculate +) + +add_subdirectory(Internal) +add_subdirectory(Models) +add_subdirectory(Services) diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/CMakeLists.txt b/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/CMakeLists.txt new file mode 100644 index 0000000..bdc58db --- /dev/null +++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/CMakeLists.txt @@ -0,0 +1,15 @@ +qml_module(caelestia-internal + URI Caelestia.Internal + SOURCES + cachingimagemanager.hpp cachingimagemanager.cpp + circularindicatormanager.hpp circularindicatormanager.cpp + hyprdevices.hpp hyprdevices.cpp + hyprextras.hpp hyprextras.cpp + logindmanager.hpp logindmanager.cpp + LIBRARIES + Qt::Gui + Qt::Quick + Qt::Concurrent + Qt::Network + Qt::DBus +) diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/cachingimagemanager.cpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/cachingimagemanager.cpp new file mode 100644 index 0000000..1c15cd2 --- /dev/null +++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/cachingimagemanager.cpp @@ -0,0 +1,223 @@ +#include "cachingimagemanager.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace caelestia::internal { + +qreal CachingImageManager::effectiveScale() const { + if (m_item && m_item->window()) { + return m_item->window()->devicePixelRatio(); + } + + return 1.0; +} + +QSize CachingImageManager::effectiveSize() const { + if (!m_item) { + return QSize(); + } + + const qreal scale = effectiveScale(); + const QSize size = QSizeF(m_item->width() * scale, m_item->height() * scale).toSize(); + m_item->setProperty("sourceSize", size); + return size; +} + +QQuickItem* CachingImageManager::item() const { + return m_item; +} + +void CachingImageManager::setItem(QQuickItem* item) { + if (m_item == item) { + return; + } + + if (m_widthConn) { + disconnect(m_widthConn); + } + if (m_heightConn) { + disconnect(m_heightConn); + } + + m_item = item; + emit itemChanged(); + + if (item) { + m_widthConn = connect(item, &QQuickItem::widthChanged, this, [this]() { + updateSource(); + }); + m_heightConn = connect(item, &QQuickItem::heightChanged, this, [this]() { + updateSource(); + }); + updateSource(); + } +} + +QUrl CachingImageManager::cacheDir() const { + return m_cacheDir; +} + +void CachingImageManager::setCacheDir(const QUrl& cacheDir) { + if (m_cacheDir == cacheDir) { + return; + } + + m_cacheDir = cacheDir; + if (!m_cacheDir.path().endsWith("/")) { + m_cacheDir.setPath(m_cacheDir.path() + "/"); + } + emit cacheDirChanged(); +} + +QString CachingImageManager::path() const { + return m_path; +} + +void CachingImageManager::setPath(const QString& path) { + if (m_path == path) { + return; + } + + m_path = path; + emit pathChanged(); + + if (!path.isEmpty()) { + updateSource(path); + } +} + +void CachingImageManager::updateSource() { + updateSource(m_path); +} + +void CachingImageManager::updateSource(const QString& path) { + if (path.isEmpty() || path == m_shaPath) { + // Path is empty or already calculating sha for path + return; + } + + m_shaPath = path; + + const auto future = QtConcurrent::run(&CachingImageManager::sha256sum, path); + + const auto watcher = new QFutureWatcher(this); + + connect(watcher, &QFutureWatcher::finished, this, [watcher, path, this]() { + if (m_path != path) { + // Object is destroyed or path has changed, ignore + watcher->deleteLater(); + return; + } + + const QSize size = effectiveSize(); + + if (!m_item || !size.width() || !size.height()) { + watcher->deleteLater(); + return; + } + + const QString fillMode = m_item->property("fillMode").toString(); + // clang-format off + const QString filename = QString("%1@%2x%3-%4.png") + .arg(watcher->result()).arg(size.width()).arg(size.height()) + .arg(fillMode == "PreserveAspectCrop" ? "crop" : fillMode == "PreserveAspectFit" ? "fit" : "stretch"); + // clang-format on + + const QUrl cache = m_cacheDir.resolved(QUrl(filename)); + if (m_cachePath == cache) { + watcher->deleteLater(); + return; + } + + m_cachePath = cache; + emit cachePathChanged(); + + if (!cache.isLocalFile()) { + qWarning() << "CachingImageManager::updateSource: cachePath" << cache << "is not a local file"; + watcher->deleteLater(); + return; + } + + const QImageReader reader(cache.toLocalFile()); + if (reader.canRead()) { + m_item->setProperty("source", cache); + } else { + m_item->setProperty("source", QUrl::fromLocalFile(path)); + createCache(path, cache.toLocalFile(), fillMode, size); + } + + // Clear current running sha if same + if (m_shaPath == path) { + m_shaPath = QString(); + } + + watcher->deleteLater(); + }); + + watcher->setFuture(future); +} + +QUrl CachingImageManager::cachePath() const { + return m_cachePath; +} + +void CachingImageManager::createCache( + const QString& path, const QString& cache, const QString& fillMode, const QSize& size) const { + QThreadPool::globalInstance()->start([path, cache, fillMode, size] { + QImage image(path); + + if (image.isNull()) { + qWarning() << "CachingImageManager::createCache: failed to read" << path; + return; + } + + image.convertTo(QImage::Format_ARGB32); + + if (fillMode == "PreserveAspectCrop") { + image = image.scaled(size, Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation); + } else if (fillMode == "PreserveAspectFit") { + image = image.scaled(size, Qt::KeepAspectRatio, Qt::SmoothTransformation); + } else { + image = image.scaled(size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + } + + if (fillMode == "PreserveAspectCrop" || fillMode == "PreserveAspectFit") { + QImage canvas(size, QImage::Format_ARGB32); + canvas.fill(Qt::transparent); + + QPainter painter(&canvas); + painter.drawImage((size.width() - image.width()) / 2, (size.height() - image.height()) / 2, image); + painter.end(); + + image = canvas; + } + + const QString parent = QFileInfo(cache).absolutePath(); + if (!QDir().mkpath(parent) || !image.save(cache)) { + qWarning() << "CachingImageManager::createCache: failed to save to" << cache; + } + }); +} + +QString CachingImageManager::sha256sum(const QString& path) { + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + qWarning() << "CachingImageManager::sha256sum: failed to open" << path; + return ""; + } + + QCryptographicHash hash(QCryptographicHash::Sha256); + hash.addData(&file); + file.close(); + + return hash.result().toHex(); +} + +} // namespace caelestia::internal diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/cachingimagemanager.hpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/cachingimagemanager.hpp new file mode 100644 index 0000000..3611699 --- /dev/null +++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/cachingimagemanager.hpp @@ -0,0 +1,65 @@ +#pragma once + +#include +#include +#include + +namespace caelestia::internal { + +class CachingImageManager : public QObject { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(QQuickItem* item READ item WRITE setItem NOTIFY itemChanged REQUIRED) + Q_PROPERTY(QUrl cacheDir READ cacheDir WRITE setCacheDir NOTIFY cacheDirChanged REQUIRED) + + Q_PROPERTY(QString path READ path WRITE setPath NOTIFY pathChanged) + Q_PROPERTY(QUrl cachePath READ cachePath NOTIFY cachePathChanged) + +public: + explicit CachingImageManager(QObject* parent = nullptr) + : QObject(parent) + , m_item(nullptr) {} + + [[nodiscard]] QQuickItem* item() const; + void setItem(QQuickItem* item); + + [[nodiscard]] QUrl cacheDir() const; + void setCacheDir(const QUrl& cacheDir); + + [[nodiscard]] QString path() const; + void setPath(const QString& path); + + [[nodiscard]] QUrl cachePath() const; + + Q_INVOKABLE void updateSource(); + Q_INVOKABLE void updateSource(const QString& path); + +signals: + void itemChanged(); + void cacheDirChanged(); + + void pathChanged(); + void cachePathChanged(); + void usingCacheChanged(); + +private: + QString m_shaPath; + + QQuickItem* m_item; + QUrl m_cacheDir; + + QString m_path; + QUrl m_cachePath; + + QMetaObject::Connection m_widthConn; + QMetaObject::Connection m_heightConn; + + [[nodiscard]] qreal effectiveScale() const; + [[nodiscard]] QSize effectiveSize() const; + + void createCache(const QString& path, const QString& cache, const QString& fillMode, const QSize& size) const; + [[nodiscard]] static QString sha256sum(const QString& path); +}; + +} // namespace caelestia::internal diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/circularindicatormanager.cpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/circularindicatormanager.cpp new file mode 100644 index 0000000..434b756 --- /dev/null +++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/circularindicatormanager.cpp @@ -0,0 +1,211 @@ +#include "circularindicatormanager.hpp" +#include +#include + +namespace { + +namespace advance { + +constexpr qint32 TOTAL_CYCLES = 4; +constexpr qint32 TOTAL_DURATION_IN_MS = 5400; +constexpr qint32 DURATION_TO_EXPAND_IN_MS = 667; +constexpr qint32 DURATION_TO_COLLAPSE_IN_MS = 667; +constexpr qint32 DURATION_TO_COMPLETE_END_IN_MS = 333; +constexpr qint32 TAIL_DEGREES_OFFSET = -20; +constexpr qint32 EXTRA_DEGREES_PER_CYCLE = 250; +constexpr qint32 CONSTANT_ROTATION_DEGREES = 1520; + +constexpr std::array DELAY_TO_EXPAND_IN_MS = { 0, 1350, 2700, 4050 }; +constexpr std::array DELAY_TO_COLLAPSE_IN_MS = { 667, 2017, 3367, 4717 }; + +} // namespace advance + +namespace retreat { + +constexpr qint32 TOTAL_DURATION_IN_MS = 6000; +constexpr qint32 DURATION_SPIN_IN_MS = 500; +constexpr qint32 DURATION_GROW_ACTIVE_IN_MS = 3000; +constexpr qint32 DURATION_SHRINK_ACTIVE_IN_MS = 3000; +constexpr std::array DELAY_SPINS_IN_MS = { 0, 1500, 3000, 4500 }; +constexpr qint32 DELAY_GROW_ACTIVE_IN_MS = 0; +constexpr qint32 DELAY_SHRINK_ACTIVE_IN_MS = 3000; +constexpr qint32 DURATION_TO_COMPLETE_END_IN_MS = 500; + +// Constants for animation values. + +// The total degrees that a constant rotation goes by. +constexpr qint32 CONSTANT_ROTATION_DEGREES = 1080; +// Despite of the constant rotation, there are also 5 extra rotations the entire animation. The +// total degrees that each extra rotation goes by. +constexpr qint32 SPIN_ROTATION_DEGREES = 90; +constexpr std::array END_FRACTION_RANGE = { 0.10, 0.87 }; + +} // namespace retreat + +inline qreal getFractionInRange(qreal playtime, qreal start, qreal duration) { + const auto fraction = (playtime - start) / duration; + return std::clamp(fraction, 0.0, 1.0); +} + +} // namespace + +namespace caelestia::internal { + +CircularIndicatorManager::CircularIndicatorManager(QObject* parent) + : QObject(parent) + , m_type(IndeterminateAnimationType::Advance) + , m_curve(QEasingCurve(QEasingCurve::BezierSpline)) + , m_progress(0) + , m_startFraction(0) + , m_endFraction(0) + , m_rotation(0) + , m_completeEndProgress(0) { + // Fast out slow in + m_curve.addCubicBezierSegment({ 0.4, 0.0 }, { 0.2, 1.0 }, { 1.0, 1.0 }); +} + +qreal CircularIndicatorManager::startFraction() const { + return m_startFraction; +} + +qreal CircularIndicatorManager::endFraction() const { + return m_endFraction; +} + +qreal CircularIndicatorManager::rotation() const { + return m_rotation; +} + +qreal CircularIndicatorManager::progress() const { + return m_progress; +} + +void CircularIndicatorManager::setProgress(qreal progress) { + update(progress); +} + +qreal CircularIndicatorManager::duration() const { + if (m_type == IndeterminateAnimationType::Advance) { + return advance::TOTAL_DURATION_IN_MS; + } else { + return retreat::TOTAL_DURATION_IN_MS; + } +} + +qreal CircularIndicatorManager::completeEndDuration() const { + if (m_type == IndeterminateAnimationType::Advance) { + return advance::DURATION_TO_COMPLETE_END_IN_MS; + } else { + return retreat::DURATION_TO_COMPLETE_END_IN_MS; + } +} + +CircularIndicatorManager::IndeterminateAnimationType CircularIndicatorManager::indeterminateAnimationType() const { + return m_type; +} + +void CircularIndicatorManager::setIndeterminateAnimationType(IndeterminateAnimationType t) { + if (m_type != t) { + m_type = t; + emit indeterminateAnimationTypeChanged(); + } +} + +qreal CircularIndicatorManager::completeEndProgress() const { + return m_completeEndProgress; +} + +void CircularIndicatorManager::setCompleteEndProgress(qreal progress) { + if (qFuzzyCompare(m_completeEndProgress + 1.0, progress + 1.0)) { + return; + } + + m_completeEndProgress = progress; + emit completeEndProgressChanged(); + + update(m_progress); +} + +void CircularIndicatorManager::update(qreal progress) { + if (qFuzzyCompare(m_progress + 1.0, progress + 1.0)) { + return; + } + + if (m_type == IndeterminateAnimationType::Advance) { + updateAdvance(progress); + } else { + updateRetreat(progress); + } + + m_progress = progress; + emit progressChanged(); +} + +void CircularIndicatorManager::updateRetreat(qreal progress) { + using namespace retreat; + const auto playtime = progress * TOTAL_DURATION_IN_MS; + + // Constant rotation. + const qreal constantRotation = CONSTANT_ROTATION_DEGREES * progress; + // Extra rotation for the faster spinning. + qreal spinRotation = 0; + for (const int spinDelay : DELAY_SPINS_IN_MS) { + spinRotation += m_curve.valueForProgress(getFractionInRange(playtime, spinDelay, DURATION_SPIN_IN_MS)) * + SPIN_ROTATION_DEGREES; + } + m_rotation = constantRotation + spinRotation; + emit rotationChanged(); + + // Grow active indicator. + qreal fraction = + m_curve.valueForProgress(getFractionInRange(playtime, DELAY_GROW_ACTIVE_IN_MS, DURATION_GROW_ACTIVE_IN_MS)); + fraction -= + m_curve.valueForProgress(getFractionInRange(playtime, DELAY_SHRINK_ACTIVE_IN_MS, DURATION_SHRINK_ACTIVE_IN_MS)); + + if (!qFuzzyIsNull(m_startFraction)) { + m_startFraction = 0.0; + emit startFractionChanged(); + } + const auto oldEndFrac = m_endFraction; + m_endFraction = std::lerp(END_FRACTION_RANGE[0], END_FRACTION_RANGE[1], fraction); + + // Completing animation. + if (m_completeEndProgress > 0) { + m_endFraction *= 1 - m_completeEndProgress; + } + + if (!qFuzzyCompare(m_endFraction + 1.0, oldEndFrac + 1.0)) { + emit endFractionChanged(); + } +} + +void CircularIndicatorManager::updateAdvance(qreal progress) { + using namespace advance; + const auto playtime = progress * TOTAL_DURATION_IN_MS; + + // Adds constant rotation to segment positions. + m_startFraction = CONSTANT_ROTATION_DEGREES * progress + TAIL_DEGREES_OFFSET; + m_endFraction = CONSTANT_ROTATION_DEGREES * progress; + + // Adds cycle specific rotation to segment positions. + for (size_t cycleIndex = 0; cycleIndex < TOTAL_CYCLES; ++cycleIndex) { + // While expanding. + qreal fraction = getFractionInRange(playtime, DELAY_TO_EXPAND_IN_MS[cycleIndex], DURATION_TO_EXPAND_IN_MS); + m_endFraction += m_curve.valueForProgress(fraction) * EXTRA_DEGREES_PER_CYCLE; + + // While collapsing. + fraction = getFractionInRange(playtime, DELAY_TO_COLLAPSE_IN_MS[cycleIndex], DURATION_TO_COLLAPSE_IN_MS); + m_startFraction += m_curve.valueForProgress(fraction) * EXTRA_DEGREES_PER_CYCLE; + } + + // Closes the gap between head and tail for complete end. + m_startFraction += (m_endFraction - m_startFraction) * m_completeEndProgress; + + m_startFraction /= 360.0; + m_endFraction /= 360.0; + + emit startFractionChanged(); + emit endFractionChanged(); +} + +} // namespace caelestia::internal diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/circularindicatormanager.hpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/circularindicatormanager.hpp new file mode 100644 index 0000000..2dbc9d6 --- /dev/null +++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/circularindicatormanager.hpp @@ -0,0 +1,72 @@ +#pragma once + +#include +#include +#include + +namespace caelestia::internal { + +class CircularIndicatorManager : public QObject { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(qreal startFraction READ startFraction NOTIFY startFractionChanged) + Q_PROPERTY(qreal endFraction READ endFraction NOTIFY endFractionChanged) + Q_PROPERTY(qreal rotation READ rotation NOTIFY rotationChanged) + Q_PROPERTY(qreal progress READ progress WRITE setProgress NOTIFY progressChanged) + Q_PROPERTY(qreal completeEndProgress READ completeEndProgress WRITE setCompleteEndProgress NOTIFY + completeEndProgressChanged) + Q_PROPERTY(qreal duration READ duration NOTIFY indeterminateAnimationTypeChanged) + Q_PROPERTY(qreal completeEndDuration READ completeEndDuration NOTIFY indeterminateAnimationTypeChanged) + Q_PROPERTY(IndeterminateAnimationType indeterminateAnimationType READ indeterminateAnimationType WRITE + setIndeterminateAnimationType NOTIFY indeterminateAnimationTypeChanged) + +public: + explicit CircularIndicatorManager(QObject* parent = nullptr); + + enum IndeterminateAnimationType { + Advance = 0, + Retreat + }; + Q_ENUM(IndeterminateAnimationType) + + [[nodiscard]] qreal startFraction() const; + [[nodiscard]] qreal endFraction() const; + [[nodiscard]] qreal rotation() const; + + [[nodiscard]] qreal progress() const; + void setProgress(qreal progress); + + [[nodiscard]] qreal completeEndProgress() const; + void setCompleteEndProgress(qreal progress); + + [[nodiscard]] qreal duration() const; + [[nodiscard]] qreal completeEndDuration() const; + + [[nodiscard]] IndeterminateAnimationType indeterminateAnimationType() const; + void setIndeterminateAnimationType(IndeterminateAnimationType t); + +signals: + void startFractionChanged(); + void endFractionChanged(); + void rotationChanged(); + void progressChanged(); + void completeEndProgressChanged(); + void indeterminateAnimationTypeChanged(); + +private: + IndeterminateAnimationType m_type; + QEasingCurve m_curve; + + qreal m_progress; + qreal m_startFraction; + qreal m_endFraction; + qreal m_rotation; + qreal m_completeEndProgress; + + void update(qreal progress); + void updateAdvance(qreal progress); + void updateRetreat(qreal progress); +}; + +} // namespace caelestia::internal diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/hyprdevices.cpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/hyprdevices.cpp new file mode 100644 index 0000000..1ac7d25 --- /dev/null +++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/hyprdevices.cpp @@ -0,0 +1,134 @@ +#include "hyprdevices.hpp" + +#include + +namespace caelestia::internal::hypr { + +HyprKeyboard::HyprKeyboard(QJsonObject ipcObject, QObject* parent) + : QObject(parent) + , m_lastIpcObject(ipcObject) {} + +QVariantHash HyprKeyboard::lastIpcObject() const { + return m_lastIpcObject.toVariantHash(); +} + +QString HyprKeyboard::address() const { + return m_lastIpcObject.value("address").toString(); +} + +QString HyprKeyboard::name() const { + return m_lastIpcObject.value("name").toString(); +} + +QString HyprKeyboard::layout() const { + return m_lastIpcObject.value("layout").toString(); +} + +QString HyprKeyboard::activeKeymap() const { + return m_lastIpcObject.value("active_keymap").toString(); +} + +bool HyprKeyboard::capsLock() const { + return m_lastIpcObject.value("capsLock").toBool(); +} + +bool HyprKeyboard::numLock() const { + return m_lastIpcObject.value("numLock").toBool(); +} + +bool HyprKeyboard::main() const { + return m_lastIpcObject.value("main").toBool(); +} + +bool HyprKeyboard::updateLastIpcObject(QJsonObject object) { + if (m_lastIpcObject == object) { + return false; + } + + const auto last = m_lastIpcObject; + + m_lastIpcObject = object; + emit lastIpcObjectChanged(); + + bool dirty = false; + if (last.value("address") != object.value("address")) { + dirty = true; + emit addressChanged(); + } + if (last.value("name") != object.value("name")) { + dirty = true; + emit nameChanged(); + } + if (last.value("layout") != object.value("layout")) { + dirty = true; + emit layoutChanged(); + } + if (last.value("active_keymap") != object.value("active_keymap")) { + dirty = true; + emit activeKeymapChanged(); + } + if (last.value("capsLock") != object.value("capsLock")) { + dirty = true; + emit capsLockChanged(); + } + if (last.value("numLock") != object.value("numLock")) { + dirty = true; + emit numLockChanged(); + } + if (last.value("main") != object.value("main")) { + dirty = true; + emit mainChanged(); + } + return dirty; +} + +HyprDevices::HyprDevices(QObject* parent) + : QObject(parent) {} + +QQmlListProperty HyprDevices::keyboards() { + return QQmlListProperty(this, &m_keyboards); +} + +bool HyprDevices::updateLastIpcObject(QJsonObject object) { + const auto val = object.value("keyboards").toArray(); + bool dirty = false; + + for (auto it = m_keyboards.begin(); it != m_keyboards.end();) { + auto* const keyboard = *it; + const auto inNewValues = std::any_of(val.begin(), val.end(), [keyboard](const QJsonValue& o) { + return o.toObject().value("address").toString() == keyboard->address(); + }); + + if (!inNewValues) { + dirty = true; + it = m_keyboards.erase(it); + keyboard->deleteLater(); + } else { + ++it; + } + } + + for (const auto& o : val) { + const auto obj = o.toObject(); + const auto addr = obj.value("address").toString(); + + auto it = std::find_if(m_keyboards.begin(), m_keyboards.end(), [addr](const HyprKeyboard* kb) { + return kb->address() == addr; + }); + + if (it != m_keyboards.end()) { + dirty |= (*it)->updateLastIpcObject(obj); + } else { + dirty = true; + m_keyboards << new HyprKeyboard(obj, this); + } + } + + if (dirty) { + emit keyboardsChanged(); + } + + return dirty; +} + +} // namespace caelestia::internal::hypr diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/hyprdevices.hpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/hyprdevices.hpp new file mode 100644 index 0000000..da18063 --- /dev/null +++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/hyprdevices.hpp @@ -0,0 +1,74 @@ +#pragma once + +#include +#include +#include +#include + +namespace caelestia::internal::hypr { + +class HyprKeyboard : public QObject { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("HyprKeyboard instances can only be retrieved from a HyprDevices") + + Q_PROPERTY(QVariantHash lastIpcObject READ lastIpcObject NOTIFY lastIpcObjectChanged) + Q_PROPERTY(QString address READ address NOTIFY addressChanged) + Q_PROPERTY(QString name READ name NOTIFY nameChanged) + Q_PROPERTY(QString layout READ layout NOTIFY layoutChanged) + Q_PROPERTY(QString activeKeymap READ activeKeymap NOTIFY activeKeymapChanged) + Q_PROPERTY(bool capsLock READ capsLock NOTIFY capsLockChanged) + Q_PROPERTY(bool numLock READ numLock NOTIFY numLockChanged) + Q_PROPERTY(bool main READ main NOTIFY mainChanged) + +public: + explicit HyprKeyboard(QJsonObject ipcObject, QObject* parent = nullptr); + + [[nodiscard]] QVariantHash lastIpcObject() const; + [[nodiscard]] QString address() const; + [[nodiscard]] QString name() const; + [[nodiscard]] QString layout() const; + [[nodiscard]] QString activeKeymap() const; + [[nodiscard]] bool capsLock() const; + [[nodiscard]] bool numLock() const; + [[nodiscard]] bool main() const; + + bool updateLastIpcObject(QJsonObject object); + +signals: + void lastIpcObjectChanged(); + void addressChanged(); + void nameChanged(); + void layoutChanged(); + void activeKeymapChanged(); + void capsLockChanged(); + void numLockChanged(); + void mainChanged(); + +private: + QJsonObject m_lastIpcObject; +}; + +class HyprDevices : public QObject { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("HyprDevices instances can only be retrieved from a HyprExtras") + + Q_PROPERTY( + QQmlListProperty keyboards READ keyboards NOTIFY keyboardsChanged) + +public: + explicit HyprDevices(QObject* parent = nullptr); + + [[nodiscard]] QQmlListProperty keyboards(); + + bool updateLastIpcObject(QJsonObject object); + +signals: + void keyboardsChanged(); + +private: + QList m_keyboards; +}; + +} // namespace caelestia::internal::hypr diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/hyprextras.cpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/hyprextras.cpp new file mode 100644 index 0000000..5308524 --- /dev/null +++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/hyprextras.cpp @@ -0,0 +1,217 @@ +#include "hyprextras.hpp" + +#include +#include +#include +#include + +namespace caelestia::internal::hypr { + +HyprExtras::HyprExtras(QObject* parent) + : QObject(parent) + , m_requestSocket("") + , m_eventSocket("") + , m_socket(nullptr) + , m_socketValid(false) + , m_devices(new HyprDevices(this)) { + const auto his = qEnvironmentVariable("HYPRLAND_INSTANCE_SIGNATURE"); + if (his.isEmpty()) { + qWarning() + << "HyprExtras::HyprExtras: $HYPRLAND_INSTANCE_SIGNATURE is unset. Unable to connect to Hyprland socket."; + return; + } + + auto hyprDir = QString("%1/hypr/%2").arg(qEnvironmentVariable("XDG_RUNTIME_DIR"), his); + if (!QDir(hyprDir).exists()) { + hyprDir = "/tmp/hypr/" + his; + + if (!QDir(hyprDir).exists()) { + qWarning() << "HyprExtras::HyprExtras: Hyprland socket directory does not exist. Unable to connect to " + "Hyprland socket."; + return; + } + } + + m_requestSocket = hyprDir + "/.socket.sock"; + m_eventSocket = hyprDir + "/.socket2.sock"; + + refreshOptions(); + refreshDevices(); + + m_socket = new QLocalSocket(this); + + QObject::connect(m_socket, &QLocalSocket::errorOccurred, this, &HyprExtras::socketError); + QObject::connect(m_socket, &QLocalSocket::stateChanged, this, &HyprExtras::socketStateChanged); + QObject::connect(m_socket, &QLocalSocket::readyRead, this, &HyprExtras::readEvent); + + m_socket->connectToServer(m_eventSocket, QLocalSocket::ReadOnly); +} + +QVariantHash HyprExtras::options() const { + return m_options; +} + +HyprDevices* HyprExtras::devices() const { + return m_devices; +} + +void HyprExtras::message(const QString& message) { + if (message.isEmpty()) { + return; + } + + makeRequest(message, [](bool success, const QByteArray& res) { + if (!success) { + qWarning() << "HyprExtras::message: request error:" << QString::fromUtf8(res); + } + }); +} + +void HyprExtras::batchMessage(const QStringList& messages) { + if (messages.isEmpty()) { + return; + } + + makeRequest("[[BATCH]]" + messages.join(";"), [](bool success, const QByteArray& res) { + if (!success) { + qWarning() << "HyprExtras::batchMessage: request error:" << QString::fromUtf8(res); + } + }); +} + +void HyprExtras::applyOptions(const QVariantHash& options) { + if (options.isEmpty()) { + return; + } + + QString request = "[[BATCH]]"; + for (auto it = options.constBegin(); it != options.constEnd(); ++it) { + request += QString("keyword %1 %2;").arg(it.key(), it.value().toString()); + } + + makeRequest(request, [this](bool success, const QByteArray& res) { + if (success) { + refreshOptions(); + } else { + qWarning() << "HyprExtras::applyOptions: request error" << QString::fromUtf8(res); + } + }); +} + +void HyprExtras::refreshOptions() { + if (!m_optionsRefresh.isNull()) { + m_optionsRefresh->close(); + } + + m_optionsRefresh = makeRequestJson("descriptions", [this](bool success, const QJsonDocument& response) { + m_optionsRefresh.reset(); + if (!success) { + return; + } + + const auto options = response.array(); + bool dirty = false; + + for (const auto& o : std::as_const(options)) { + const auto obj = o.toObject(); + const auto key = obj.value("value").toString(); + const auto value = obj.value("data").toObject().value("current").toVariant(); + if (m_options.value(key) != value) { + dirty = true; + m_options.insert(key, value); + } + } + + if (dirty) { + emit optionsChanged(); + } + }); +} + +void HyprExtras::refreshDevices() { + if (!m_devicesRefresh.isNull()) { + m_devicesRefresh->close(); + } + + m_devicesRefresh = makeRequestJson("devices", [this](bool success, const QJsonDocument& response) { + m_devicesRefresh.reset(); + if (success) { + m_devices->updateLastIpcObject(response.object()); + } + }); +} + +void HyprExtras::socketError(QLocalSocket::LocalSocketError error) const { + if (!m_socketValid) { + qWarning() << "HyprExtras::socketError: unable to connect to Hyprland event socket:" << error; + } else { + qWarning() << "HyprExtras::socketError: Hyprland event socket error:" << error; + } +} + +void HyprExtras::socketStateChanged(QLocalSocket::LocalSocketState state) { + if (state == QLocalSocket::UnconnectedState && m_socketValid) { + qWarning() << "HyprExtras::socketStateChanged: Hyprland event socket disconnected."; + } + + m_socketValid = state == QLocalSocket::ConnectedState; +} + +void HyprExtras::readEvent() { + while (true) { + auto rawEvent = m_socket->readLine(); + if (rawEvent.isEmpty()) { + break; + } + rawEvent.truncate(rawEvent.length() - 1); // Remove trailing \n + const auto event = QByteArrayView(rawEvent.data(), rawEvent.indexOf(">>")); + handleEvent(QString::fromUtf8(event)); + } +} + +void HyprExtras::handleEvent(const QString& event) { + if (event == "configreloaded") { + refreshOptions(); + } else if (event == "activelayout") { + refreshDevices(); + } +} + +HyprExtras::SocketPtr HyprExtras::makeRequestJson( + const QString& request, const std::function& callback) { + return makeRequest("j/" + request, [callback](bool success, const QByteArray& response) { + callback(success, QJsonDocument::fromJson(response)); + }); +} + +HyprExtras::SocketPtr HyprExtras::makeRequest( + const QString& request, const std::function& callback) { + if (m_requestSocket.isEmpty()) { + return SocketPtr(); + } + + auto socket = SocketPtr::create(this); + + QObject::connect(socket.data(), &QLocalSocket::connected, this, [=, this]() { + QObject::connect(socket.data(), &QLocalSocket::readyRead, this, [socket, callback]() { + const auto response = socket->readAll(); + callback(true, std::move(response)); + socket->close(); + }); + + socket->write(request.toUtf8()); + socket->flush(); + }); + + QObject::connect(socket.data(), &QLocalSocket::errorOccurred, this, [=](QLocalSocket::LocalSocketError err) { + qWarning() << "HyprExtras::makeRequest: error making request:" << err << "| request:" << request; + callback(false, {}); + socket->close(); + }); + + socket->connectToServer(m_requestSocket); + + return socket; +} + +} // namespace caelestia::internal::hypr diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/hyprextras.hpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/hyprextras.hpp new file mode 100644 index 0000000..14563c0 --- /dev/null +++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/hyprextras.hpp @@ -0,0 +1,56 @@ +#pragma once + +#include "hyprdevices.hpp" +#include +#include +#include + +namespace caelestia::internal::hypr { + +class HyprExtras : public QObject { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(QVariantHash options READ options NOTIFY optionsChanged) + Q_PROPERTY(caelestia::internal::hypr::HyprDevices* devices READ devices CONSTANT) + +public: + explicit HyprExtras(QObject* parent = nullptr); + + [[nodiscard]] QVariantHash options() const; + [[nodiscard]] HyprDevices* devices() const; + + Q_INVOKABLE void message(const QString& message); + Q_INVOKABLE void batchMessage(const QStringList& messages); + Q_INVOKABLE void applyOptions(const QVariantHash& options); + + Q_INVOKABLE void refreshOptions(); + Q_INVOKABLE void refreshDevices(); + +signals: + void optionsChanged(); + +private: + using SocketPtr = QSharedPointer; + + QString m_requestSocket; + QString m_eventSocket; + QLocalSocket* m_socket; + bool m_socketValid; + + QVariantHash m_options; + HyprDevices* const m_devices; + + SocketPtr m_optionsRefresh; + SocketPtr m_devicesRefresh; + + void socketError(QLocalSocket::LocalSocketError error) const; + void socketStateChanged(QLocalSocket::LocalSocketState state); + void readEvent(); + void handleEvent(const QString& event); + + SocketPtr makeRequestJson(const QString& request, const std::function& callback); + SocketPtr makeRequest(const QString& request, const std::function& callback); +}; + +} // namespace caelestia::internal::hypr diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/logindmanager.cpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/logindmanager.cpp new file mode 100644 index 0000000..4194ee1 --- /dev/null +++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/logindmanager.cpp @@ -0,0 +1,65 @@ +#include "logindmanager.hpp" + +#include +#include +#include +#include + +namespace caelestia::internal { + +LogindManager::LogindManager(QObject* parent) + : QObject(parent) { + auto bus = QDBusConnection::systemBus(); + if (!bus.isConnected()) { + qWarning() << "LogindManager::LogindManager: failed to connect to system bus:" << bus.lastError().message(); + return; + } + + bool ok = bus.connect("org.freedesktop.login1", "/org/freedesktop/login1", "org.freedesktop.login1.Manager", + "PrepareForSleep", this, SLOT(handlePrepareForSleep(bool))); + + if (!ok) { + qWarning() << "LogindManager::LogindManager: failed to connect to PrepareForSleep signal:" + << bus.lastError().message(); + } + + QDBusInterface login1("org.freedesktop.login1", "/org/freedesktop/login1", "org.freedesktop.login1.Manager", bus); + const QDBusReply reply = login1.call("GetSession", "auto"); + if (!reply.isValid()) { + qWarning() << "LogindManager::LogindManager: failed to get session path"; + return; + } + const auto sessionPath = reply.value().path(); + + ok = bus.connect("org.freedesktop.login1", sessionPath, "org.freedesktop.login1.Session", "Lock", this, + SLOT(handleLockRequested())); + + if (!ok) { + qWarning() << "LogindManager::LogindManager: failed to connect to Lock signal:" << bus.lastError().message(); + } + + ok = bus.connect("org.freedesktop.login1", sessionPath, "org.freedesktop.login1.Session", "Unlock", this, + SLOT(handleUnlockRequested())); + + if (!ok) { + qWarning() << "LogindManager::LogindManager: failed to connect to Unlock signal:" << bus.lastError().message(); + } +} + +void LogindManager::handlePrepareForSleep(bool sleep) { + if (sleep) { + emit aboutToSleep(); + } else { + emit resumed(); + } +} + +void LogindManager::handleLockRequested() { + emit lockRequested(); +} + +void LogindManager::handleUnlockRequested() { + emit unlockRequested(); +} + +} // namespace caelestia::internal diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/logindmanager.hpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/logindmanager.hpp new file mode 100644 index 0000000..72a3401 --- /dev/null +++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Internal/logindmanager.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include +#include + +namespace caelestia::internal { + +class LogindManager : public QObject { + Q_OBJECT + QML_ELEMENT + +public: + explicit LogindManager(QObject* parent = nullptr); + +signals: + void aboutToSleep(); + void resumed(); + void lockRequested(); + void unlockRequested(); + +private slots: + void handlePrepareForSleep(bool sleep); + void handleLockRequested(); + void handleUnlockRequested(); +}; + +} // namespace caelestia::internal diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Models/CMakeLists.txt b/.config/quickshell/caelestia/plugin/src/Caelestia/Models/CMakeLists.txt new file mode 100644 index 0000000..640e29e --- /dev/null +++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Models/CMakeLists.txt @@ -0,0 +1,8 @@ +qml_module(caelestia-models + URI Caelestia.Models + SOURCES + filesystemmodel.hpp filesystemmodel.cpp + LIBRARIES + Qt::Gui + Qt::Concurrent +) diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Models/filesystemmodel.cpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Models/filesystemmodel.cpp new file mode 100644 index 0000000..e387ecd --- /dev/null +++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Models/filesystemmodel.cpp @@ -0,0 +1,479 @@ +#include "filesystemmodel.hpp" + +#include +#include +#include + +namespace caelestia::models { + +FileSystemEntry::FileSystemEntry(const QString& path, const QString& relativePath, QObject* parent) + : QObject(parent) + , m_fileInfo(path) + , m_path(path) + , m_relativePath(relativePath) + , m_isImageInitialised(false) + , m_mimeTypeInitialised(false) {} + +QString FileSystemEntry::path() const { + return m_path; +}; + +QString FileSystemEntry::relativePath() const { + return m_relativePath; +}; + +QString FileSystemEntry::name() const { + return m_fileInfo.fileName(); +}; + +QString FileSystemEntry::baseName() const { + return m_fileInfo.baseName(); +}; + +QString FileSystemEntry::parentDir() const { + return m_fileInfo.absolutePath(); +}; + +QString FileSystemEntry::suffix() const { + return m_fileInfo.completeSuffix(); +}; + +qint64 FileSystemEntry::size() const { + return m_fileInfo.size(); +}; + +bool FileSystemEntry::isDir() const { + return m_fileInfo.isDir(); +}; + +bool FileSystemEntry::isImage() const { + if (!m_isImageInitialised) { + QImageReader reader(m_path); + m_isImage = reader.canRead(); + m_isImageInitialised = true; + } + return m_isImage; +} + +QString FileSystemEntry::mimeType() const { + if (!m_mimeTypeInitialised) { + const QMimeDatabase db; + m_mimeType = db.mimeTypeForFile(m_path).name(); + m_mimeTypeInitialised = true; + } + return m_mimeType; +} + +void FileSystemEntry::updateRelativePath(const QDir& dir) { + const auto relPath = dir.relativeFilePath(m_path); + if (m_relativePath != relPath) { + m_relativePath = relPath; + emit relativePathChanged(); + } +} + +FileSystemModel::FileSystemModel(QObject* parent) + : QAbstractListModel(parent) + , m_recursive(false) + , m_watchChanges(true) + , m_showHidden(false) + , m_filter(NoFilter) { + connect(&m_watcher, &QFileSystemWatcher::directoryChanged, this, &FileSystemModel::watchDirIfRecursive); + connect(&m_watcher, &QFileSystemWatcher::directoryChanged, this, &FileSystemModel::updateEntriesForDir); +} + +int FileSystemModel::rowCount(const QModelIndex& parent) const { + if (parent != QModelIndex()) { + return 0; + } + return static_cast(m_entries.size()); +} + +QVariant FileSystemModel::data(const QModelIndex& index, int role) const { + if (role != Qt::UserRole || !index.isValid() || index.row() >= m_entries.size()) { + return QVariant(); + } + return QVariant::fromValue(m_entries.at(index.row())); +} + +QHash FileSystemModel::roleNames() const { + return { { Qt::UserRole, "modelData" } }; +} + +QString FileSystemModel::path() const { + return m_path; +} + +void FileSystemModel::setPath(const QString& path) { + if (m_path == path) { + return; + } + + m_path = path; + emit pathChanged(); + + m_dir.setPath(m_path); + + for (const auto& entry : std::as_const(m_entries)) { + entry->updateRelativePath(m_dir); + } + + update(); +} + +bool FileSystemModel::recursive() const { + return m_recursive; +} + +void FileSystemModel::setRecursive(bool recursive) { + if (m_recursive == recursive) { + return; + } + + m_recursive = recursive; + emit recursiveChanged(); + + update(); +} + +bool FileSystemModel::watchChanges() const { + return m_watchChanges; +} + +void FileSystemModel::setWatchChanges(bool watchChanges) { + if (m_watchChanges == watchChanges) { + return; + } + + m_watchChanges = watchChanges; + emit watchChangesChanged(); + + update(); +} + +bool FileSystemModel::showHidden() const { + return m_showHidden; +} + +void FileSystemModel::setShowHidden(bool showHidden) { + if (m_showHidden == showHidden) { + return; + } + + m_showHidden = showHidden; + emit showHiddenChanged(); + + update(); +} + +bool FileSystemModel::sortReverse() const { + return m_sortReverse; +} + +void FileSystemModel::setSortReverse(bool sortReverse) { + if (m_sortReverse == sortReverse) { + return; + } + + m_sortReverse = sortReverse; + emit sortReverseChanged(); + + update(); +} + +FileSystemModel::Filter FileSystemModel::filter() const { + return m_filter; +} + +void FileSystemModel::setFilter(Filter filter) { + if (m_filter == filter) { + return; + } + + m_filter = filter; + emit filterChanged(); + + update(); +} + +QStringList FileSystemModel::nameFilters() const { + return m_nameFilters; +} + +void FileSystemModel::setNameFilters(const QStringList& nameFilters) { + if (m_nameFilters == nameFilters) { + return; + } + + m_nameFilters = nameFilters; + emit nameFiltersChanged(); + + update(); +} + +QQmlListProperty FileSystemModel::entries() { + return QQmlListProperty(this, &m_entries); +} + +void FileSystemModel::watchDirIfRecursive(const QString& path) { + if (m_recursive && m_watchChanges) { + const auto currentDir = m_dir; + const bool showHidden = m_showHidden; + const auto future = QtConcurrent::run([showHidden, path]() { + QDir::Filters filters = QDir::Dirs | QDir::NoDotAndDotDot; + if (showHidden) { + filters |= QDir::Hidden; + } + + QDirIterator iter(path, filters, QDirIterator::Subdirectories); + QStringList dirs; + while (iter.hasNext()) { + dirs << iter.next(); + } + return dirs; + }); + const auto watcher = new QFutureWatcher(this); + connect(watcher, &QFutureWatcher::finished, this, [currentDir, showHidden, watcher, this]() { + const auto paths = watcher->result(); + if (currentDir == m_dir && showHidden == m_showHidden && !paths.isEmpty()) { + // Ignore if dir or showHidden has changed + m_watcher.addPaths(paths); + } + watcher->deleteLater(); + }); + watcher->setFuture(future); + } +} + +void FileSystemModel::update() { + updateWatcher(); + updateEntries(); +} + +void FileSystemModel::updateWatcher() { + if (!m_watcher.directories().isEmpty()) { + m_watcher.removePaths(m_watcher.directories()); + } + + if (!m_watchChanges || m_path.isEmpty()) { + return; + } + + m_watcher.addPath(m_path); + watchDirIfRecursive(m_path); +} + +void FileSystemModel::updateEntries() { + if (m_path.isEmpty()) { + if (!m_entries.isEmpty()) { + beginResetModel(); + qDeleteAll(m_entries); + m_entries.clear(); + endResetModel(); + emit entriesChanged(); + } + + return; + } + + for (auto& future : m_futures) { + future.cancel(); + } + m_futures.clear(); + + updateEntriesForDir(m_path); +} + +void FileSystemModel::updateEntriesForDir(const QString& dir) { + const auto recursive = m_recursive; + const auto showHidden = m_showHidden; + const auto filter = m_filter; + const auto nameFilters = m_nameFilters; + + QSet oldPaths; + for (const auto& entry : std::as_const(m_entries)) { + oldPaths << entry->path(); + } + + const auto future = QtConcurrent::run([=](QPromise, QSet>>& promise) { + const auto flags = recursive ? QDirIterator::Subdirectories : QDirIterator::NoIteratorFlags; + + std::optional iter; + + if (filter == Images) { + QStringList extraNameFilters = nameFilters; + const auto formats = QImageReader::supportedImageFormats(); + for (const auto& format : formats) { + extraNameFilters << "*." + format; + } + + QDir::Filters filters = QDir::Files; + if (showHidden) { + filters |= QDir::Hidden; + } + + iter.emplace(dir, extraNameFilters, filters, flags); + } else { + QDir::Filters filters; + + if (filter == Files) { + filters = QDir::Files; + } else if (filter == Dirs) { + filters = QDir::Dirs | QDir::NoDotAndDotDot; + } else { + filters = QDir::Dirs | QDir::Files | QDir::NoDotAndDotDot; + } + + if (showHidden) { + filters |= QDir::Hidden; + } + + if (nameFilters.isEmpty()) { + iter.emplace(dir, filters, flags); + } else { + iter.emplace(dir, nameFilters, filters, flags); + } + } + + QSet newPaths; + while (iter->hasNext()) { + if (promise.isCanceled()) { + return; + } + + QString path = iter->next(); + + if (filter == Images) { + QImageReader reader(path); + if (!reader.canRead()) { + continue; + } + } + + newPaths.insert(path); + } + + if (promise.isCanceled() || newPaths == oldPaths) { + return; + } + + promise.addResult(qMakePair(oldPaths - newPaths, newPaths - oldPaths)); + }); + + if (m_futures.contains(dir)) { + m_futures[dir].cancel(); + } + m_futures.insert(dir, future); + + const auto watcher = new QFutureWatcher, QSet>>(this); + + connect(watcher, &QFutureWatcher, QSet>>::finished, this, [dir, watcher, this]() { + m_futures.remove(dir); + + if (!watcher->future().isResultReadyAt(0)) { + watcher->deleteLater(); + return; + } + + const auto result = watcher->result(); + applyChanges(result.first, result.second); + + watcher->deleteLater(); + }); + + watcher->setFuture(future); +} + +void FileSystemModel::applyChanges(const QSet& removedPaths, const QSet& addedPaths) { + QList removedIndices; + for (int i = 0; i < m_entries.size(); ++i) { + if (removedPaths.contains(m_entries[i]->path())) { + removedIndices << i; + } + } + std::sort(removedIndices.begin(), removedIndices.end(), std::greater()); + + // Batch remove old entries + int start = -1; + int end = -1; + for (int idx : std::as_const(removedIndices)) { + if (start == -1) { + start = idx; + end = idx; + } else if (idx == end - 1) { + end = idx; + } else { + beginRemoveRows(QModelIndex(), end, start); + for (int i = start; i >= end; --i) { + m_entries.takeAt(i)->deleteLater(); + } + endRemoveRows(); + + start = idx; + end = idx; + } + } + if (start != -1) { + beginRemoveRows(QModelIndex(), end, start); + for (int i = start; i >= end; --i) { + m_entries.takeAt(i)->deleteLater(); + } + endRemoveRows(); + } + + // Create new entries + QList newEntries; + for (const auto& path : addedPaths) { + newEntries << new FileSystemEntry(path, m_dir.relativeFilePath(path), this); + } + std::sort(newEntries.begin(), newEntries.end(), [this](const FileSystemEntry* a, const FileSystemEntry* b) { + return compareEntries(a, b); + }); + + // Batch insert new entries + int insertStart = -1; + QList batchItems; + for (const auto& entry : std::as_const(newEntries)) { + const auto it = std::lower_bound( + m_entries.begin(), m_entries.end(), entry, [this](const FileSystemEntry* a, const FileSystemEntry* b) { + return compareEntries(a, b); + }); + const auto row = static_cast(it - m_entries.begin()); + + if (insertStart == -1) { + insertStart = row; + batchItems << entry; + } else if (row == insertStart + batchItems.size()) { + batchItems << entry; + } else { + beginInsertRows(QModelIndex(), insertStart, insertStart + static_cast(batchItems.size()) - 1); + for (int i = 0; i < batchItems.size(); ++i) { + m_entries.insert(insertStart + i, batchItems[i]); + } + endInsertRows(); + + insertStart = row; + batchItems.clear(); + batchItems << entry; + } + } + if (!batchItems.isEmpty()) { + beginInsertRows(QModelIndex(), insertStart, insertStart + static_cast(batchItems.size()) - 1); + for (int i = 0; i < batchItems.size(); ++i) { + m_entries.insert(insertStart + i, batchItems[i]); + } + endInsertRows(); + } + + emit entriesChanged(); +} + +bool FileSystemModel::compareEntries(const FileSystemEntry* a, const FileSystemEntry* b) const { + if (a->isDir() != b->isDir()) { + return m_sortReverse ^ a->isDir(); + } + const auto cmp = a->relativePath().localeAwareCompare(b->relativePath()); + return m_sortReverse ? cmp > 0 : cmp < 0; +} + +} // namespace caelestia::models diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Models/filesystemmodel.hpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Models/filesystemmodel.hpp new file mode 100644 index 0000000..cf8eae8 --- /dev/null +++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Models/filesystemmodel.hpp @@ -0,0 +1,148 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace caelestia::models { + +class FileSystemEntry : public QObject { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("FileSystemEntry instances can only be retrieved from a FileSystemModel") + + Q_PROPERTY(QString path READ path CONSTANT) + Q_PROPERTY(QString relativePath READ relativePath NOTIFY relativePathChanged) + Q_PROPERTY(QString name READ name CONSTANT) + Q_PROPERTY(QString baseName READ baseName CONSTANT) + Q_PROPERTY(QString parentDir READ parentDir CONSTANT) + Q_PROPERTY(QString suffix READ suffix CONSTANT) + Q_PROPERTY(qint64 size READ size CONSTANT) + Q_PROPERTY(bool isDir READ isDir CONSTANT) + Q_PROPERTY(bool isImage READ isImage CONSTANT) + Q_PROPERTY(QString mimeType READ mimeType CONSTANT) + +public: + explicit FileSystemEntry(const QString& path, const QString& relativePath, QObject* parent = nullptr); + + [[nodiscard]] QString path() const; + [[nodiscard]] QString relativePath() const; + [[nodiscard]] QString name() const; + [[nodiscard]] QString baseName() const; + [[nodiscard]] QString parentDir() const; + [[nodiscard]] QString suffix() const; + [[nodiscard]] qint64 size() const; + [[nodiscard]] bool isDir() const; + [[nodiscard]] bool isImage() const; + [[nodiscard]] QString mimeType() const; + + void updateRelativePath(const QDir& dir); + +signals: + void relativePathChanged(); + +private: + const QFileInfo m_fileInfo; + + const QString m_path; + QString m_relativePath; + + mutable bool m_isImage; + mutable bool m_isImageInitialised; + + mutable QString m_mimeType; + mutable bool m_mimeTypeInitialised; +}; + +class FileSystemModel : public QAbstractListModel { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(QString path READ path WRITE setPath NOTIFY pathChanged) + Q_PROPERTY(bool recursive READ recursive WRITE setRecursive NOTIFY recursiveChanged) + Q_PROPERTY(bool watchChanges READ watchChanges WRITE setWatchChanges NOTIFY watchChangesChanged) + Q_PROPERTY(bool showHidden READ showHidden WRITE setShowHidden NOTIFY showHiddenChanged) + Q_PROPERTY(bool sortReverse READ sortReverse WRITE setSortReverse NOTIFY sortReverseChanged) + Q_PROPERTY(Filter filter READ filter WRITE setFilter NOTIFY filterChanged) + Q_PROPERTY(QStringList nameFilters READ nameFilters WRITE setNameFilters NOTIFY nameFiltersChanged) + + Q_PROPERTY(QQmlListProperty entries READ entries NOTIFY entriesChanged) + +public: + enum Filter { + NoFilter, + Images, + Files, + Dirs + }; + Q_ENUM(Filter) + + explicit FileSystemModel(QObject* parent = nullptr); + + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + [[nodiscard]] QString path() const; + void setPath(const QString& path); + + [[nodiscard]] bool recursive() const; + void setRecursive(bool recursive); + + [[nodiscard]] bool watchChanges() const; + void setWatchChanges(bool watchChanges); + + [[nodiscard]] bool showHidden() const; + void setShowHidden(bool showHidden); + + [[nodiscard]] bool sortReverse() const; + void setSortReverse(bool sortReverse); + + [[nodiscard]] Filter filter() const; + void setFilter(Filter filter); + + [[nodiscard]] QStringList nameFilters() const; + void setNameFilters(const QStringList& nameFilters); + + [[nodiscard]] QQmlListProperty entries(); + +signals: + void pathChanged(); + void recursiveChanged(); + void watchChangesChanged(); + void showHiddenChanged(); + void sortReverseChanged(); + void filterChanged(); + void nameFiltersChanged(); + void entriesChanged(); + +private: + QDir m_dir; + QFileSystemWatcher m_watcher; + QList m_entries; + QHash, QSet>>> m_futures; + + QString m_path; + bool m_recursive; + bool m_watchChanges; + bool m_showHidden; + bool m_sortReverse; + Filter m_filter; + QStringList m_nameFilters; + + void watchDirIfRecursive(const QString& path); + void update(); + void updateWatcher(); + void updateEntries(); + void updateEntriesForDir(const QString& dir); + void applyChanges(const QSet& removedPaths, const QSet& addedPaths); + [[nodiscard]] bool compareEntries(const FileSystemEntry* a, const FileSystemEntry* b) const; +}; + +} // namespace caelestia::models diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Services/CMakeLists.txt b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/CMakeLists.txt new file mode 100644 index 0000000..8ce868b --- /dev/null +++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/CMakeLists.txt @@ -0,0 +1,14 @@ +qml_module(caelestia-services + URI Caelestia.Services + SOURCES + service.hpp service.cpp + serviceref.hpp serviceref.cpp + beattracker.hpp beattracker.cpp + audiocollector.hpp audiocollector.cpp + audioprovider.hpp audioprovider.cpp + cavaprovider.hpp cavaprovider.cpp + LIBRARIES + PkgConfig::Pipewire + PkgConfig::Aubio + PkgConfig::Cava +) diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Services/audiocollector.cpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/audiocollector.cpp new file mode 100644 index 0000000..1563405 --- /dev/null +++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/audiocollector.cpp @@ -0,0 +1,246 @@ +#include "audiocollector.hpp" + +#include "service.hpp" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace caelestia::services { + +PipeWireWorker::PipeWireWorker(std::stop_token token, AudioCollector* collector) + : m_loop(nullptr) + , m_stream(nullptr) + , m_timer(nullptr) + , m_idle(true) + , m_token(token) + , m_collector(collector) { + pw_init(nullptr, nullptr); + + m_loop = pw_main_loop_new(nullptr); + if (!m_loop) { + qWarning() << "PipeWireWorker::init: failed to create PipeWire main loop"; + pw_deinit(); + return; + } + + timespec timeout = { 0, 10 * SPA_NSEC_PER_MSEC }; + m_timer = pw_loop_add_timer(pw_main_loop_get_loop(m_loop), handleTimeout, this); + pw_loop_update_timer(pw_main_loop_get_loop(m_loop), m_timer, &timeout, &timeout, false); + + auto props = pw_properties_new( + PW_KEY_MEDIA_TYPE, "Audio", PW_KEY_MEDIA_CATEGORY, "Capture", PW_KEY_MEDIA_ROLE, "Music", nullptr); + pw_properties_set(props, PW_KEY_STREAM_CAPTURE_SINK, "true"); + pw_properties_setf( + props, PW_KEY_NODE_LATENCY, "%u/%u", nextPowerOf2(512 * ac::SAMPLE_RATE / 48000), ac::SAMPLE_RATE); + pw_properties_set(props, PW_KEY_NODE_PASSIVE, "true"); + pw_properties_set(props, PW_KEY_NODE_VIRTUAL, "true"); + pw_properties_set(props, PW_KEY_STREAM_DONT_REMIX, "false"); + pw_properties_set(props, "channelmix.upmix", "true"); + + std::vector buffer(ac::CHUNK_SIZE); + spa_pod_builder b; + spa_pod_builder_init(&b, buffer.data(), static_cast(buffer.size())); + + spa_audio_info_raw info{}; + info.format = SPA_AUDIO_FORMAT_S16; + info.rate = ac::SAMPLE_RATE; + info.channels = 1; + + const spa_pod* params[1]; + params[0] = spa_format_audio_raw_build(&b, SPA_PARAM_EnumFormat, &info); + + pw_stream_events events{}; + events.state_changed = [](void* data, pw_stream_state, pw_stream_state state, const char*) { + auto* self = static_cast(data); + self->streamStateChanged(state); + }; + events.process = [](void* data) { + auto* self = static_cast(data); + self->processStream(); + }; + + m_stream = pw_stream_new_simple(pw_main_loop_get_loop(m_loop), "caelestia-shell", props, &events, this); + + const int success = pw_stream_connect(m_stream, PW_DIRECTION_INPUT, PW_ID_ANY, + static_cast( + PW_STREAM_FLAG_AUTOCONNECT | PW_STREAM_FLAG_MAP_BUFFERS | PW_STREAM_FLAG_RT_PROCESS), + params, 1); + if (success < 0) { + qWarning() << "PipeWireWorker::init: failed to connect stream"; + pw_stream_destroy(m_stream); + pw_main_loop_destroy(m_loop); + pw_deinit(); + return; + } + + pw_main_loop_run(m_loop); + + pw_stream_destroy(m_stream); + pw_main_loop_destroy(m_loop); + pw_deinit(); +} + +void PipeWireWorker::handleTimeout(void* data, uint64_t expirations) { + auto* self = static_cast(data); + + if (self->m_token.stop_requested()) { + pw_main_loop_quit(self->m_loop); + return; + } + + if (!self->m_idle) { + if (expirations < 10) { + self->m_collector->clearBuffer(); + } else { + self->m_idle = true; + timespec timeout = { 0, 500 * SPA_NSEC_PER_MSEC }; + pw_loop_update_timer(pw_main_loop_get_loop(self->m_loop), self->m_timer, &timeout, &timeout, false); + } + } +} + +void PipeWireWorker::streamStateChanged(pw_stream_state state) { + m_idle = false; + switch (state) { + case PW_STREAM_STATE_PAUSED: { + timespec timeout = { 0, 10 * SPA_NSEC_PER_MSEC }; + pw_loop_update_timer(pw_main_loop_get_loop(m_loop), m_timer, &timeout, &timeout, false); + break; + } + case PW_STREAM_STATE_STREAMING: + pw_loop_update_timer(pw_main_loop_get_loop(m_loop), m_timer, nullptr, nullptr, false); + break; + case PW_STREAM_STATE_ERROR: + pw_main_loop_quit(m_loop); + break; + default: + break; + } +} + +void PipeWireWorker::processStream() { + if (m_token.stop_requested()) { + pw_main_loop_quit(m_loop); + return; + } + + pw_buffer* buffer = pw_stream_dequeue_buffer(m_stream); + if (buffer == nullptr) { + return; + } + + const spa_buffer* buf = buffer->buffer; + const qint16* samples = reinterpret_cast(buf->datas[0].data); + if (samples == nullptr) { + return; + } + + const quint32 count = buf->datas[0].chunk->size / 2; + m_collector->loadChunk(samples, count); + + pw_stream_queue_buffer(m_stream, buffer); +} + +unsigned int PipeWireWorker::nextPowerOf2(unsigned int n) { + if (n == 0) { + return 1; + } + + n--; + n |= n >> 1; + n |= n >> 2; + n |= n >> 4; + n |= n >> 8; + n |= n >> 16; + n++; + + return n; +} + +AudioCollector& AudioCollector::instance() { + static AudioCollector instance; + return instance; +} + +void AudioCollector::clearBuffer() { + auto* writeBuffer = m_writeBuffer.load(std::memory_order_relaxed); + std::fill(writeBuffer->begin(), writeBuffer->end(), 0.0f); + + auto* oldRead = m_readBuffer.exchange(writeBuffer, std::memory_order_acq_rel); + m_writeBuffer.store(oldRead, std::memory_order_release); +} + +void AudioCollector::loadChunk(const qint16* samples, quint32 count) { + if (count > ac::CHUNK_SIZE) { + count = ac::CHUNK_SIZE; + } + + auto* writeBuffer = m_writeBuffer.load(std::memory_order_relaxed); + std::transform(samples, samples + count, writeBuffer->begin(), [](qint16 sample) { + return sample / 32768.0f; + }); + + auto* oldRead = m_readBuffer.exchange(writeBuffer, std::memory_order_acq_rel); + m_writeBuffer.store(oldRead, std::memory_order_release); +} + +quint32 AudioCollector::readChunk(float* out, quint32 count) { + if (count == 0 || count > ac::CHUNK_SIZE) { + count = ac::CHUNK_SIZE; + } + + auto* readBuffer = m_readBuffer.load(std::memory_order_acquire); + std::memcpy(out, readBuffer->data(), count * sizeof(float)); + + return count; +} + +quint32 AudioCollector::readChunk(double* out, quint32 count) { + if (count == 0 || count > ac::CHUNK_SIZE) { + count = ac::CHUNK_SIZE; + } + + auto* readBuffer = m_readBuffer.load(std::memory_order_acquire); + std::transform(readBuffer->begin(), readBuffer->begin() + count, out, [](float sample) { + return static_cast(sample); + }); + + return count; +} + +AudioCollector::AudioCollector(QObject* parent) + : Service(parent) + , m_buffer1(ac::CHUNK_SIZE) + , m_buffer2(ac::CHUNK_SIZE) + , m_readBuffer(&m_buffer1) + , m_writeBuffer(&m_buffer2) {} + +AudioCollector::~AudioCollector() { + stop(); +} + +void AudioCollector::start() { + if (m_thread.joinable()) { + return; + } + + clearBuffer(); + + m_thread = std::jthread([this](std::stop_token token) { + PipeWireWorker worker(token, this); + }); +} + +void AudioCollector::stop() { + if (m_thread.joinable()) { + m_thread.request_stop(); + m_thread.join(); + } +} + +} // namespace caelestia::services diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Services/audiocollector.hpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/audiocollector.hpp new file mode 100644 index 0000000..cd63afa --- /dev/null +++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/audiocollector.hpp @@ -0,0 +1,76 @@ +#pragma once + +#include "service.hpp" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace caelestia::services { + +namespace ac { + +constexpr quint32 SAMPLE_RATE = 44100; +constexpr quint32 CHUNK_SIZE = 512; + +} // namespace ac + +class AudioCollector; + +class PipeWireWorker { +public: + explicit PipeWireWorker(std::stop_token token, AudioCollector* collector); + + void run(); + +private: + pw_main_loop* m_loop; + pw_stream* m_stream; + spa_source* m_timer; + bool m_idle; + + std::stop_token m_token; + AudioCollector* m_collector; + + static void handleTimeout(void* data, uint64_t expirations); + void streamStateChanged(pw_stream_state state); + void processStream(); + + [[nodiscard]] unsigned int nextPowerOf2(unsigned int n); +}; + +class AudioCollector : public Service { + Q_OBJECT + +public: + AudioCollector(const AudioCollector&) = delete; + AudioCollector& operator=(const AudioCollector&) = delete; + + static AudioCollector& instance(); + + void clearBuffer(); + void loadChunk(const qint16* samples, quint32 count); + quint32 readChunk(float* out, quint32 count = 0); + quint32 readChunk(double* out, quint32 count = 0); + +private: + explicit AudioCollector(QObject* parent = nullptr); + ~AudioCollector(); + + std::jthread m_thread; + std::vector m_buffer1; + std::vector m_buffer2; + std::atomic*> m_readBuffer; + std::atomic*> m_writeBuffer; + quint32 m_sampleCount; + + void reload(); + void start() override; + void stop() override; +}; + +} // namespace caelestia::services diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Services/audioprovider.cpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/audioprovider.cpp new file mode 100644 index 0000000..1fac9ee --- /dev/null +++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/audioprovider.cpp @@ -0,0 +1,78 @@ +#include "audioprovider.hpp" + +#include "audiocollector.hpp" +#include "service.hpp" +#include +#include + +namespace caelestia::services { + +AudioProcessor::AudioProcessor(QObject* parent) + : QObject(parent) {} + +AudioProcessor::~AudioProcessor() { + stop(); +} + +void AudioProcessor::init() { + m_timer = new QTimer(this); + m_timer->setInterval(static_cast(ac::CHUNK_SIZE * 1000.0 / ac::SAMPLE_RATE)); + connect(m_timer, &QTimer::timeout, this, &AudioProcessor::process); +} + +void AudioProcessor::start() { + QMetaObject::invokeMethod(&AudioCollector::instance(), &AudioCollector::ref, Qt::QueuedConnection, this); + if (m_timer) { + m_timer->start(); + } +} + +void AudioProcessor::stop() { + if (m_timer) { + m_timer->stop(); + } + QMetaObject::invokeMethod(&AudioCollector::instance(), &AudioCollector::unref, Qt::QueuedConnection, this); +} + +AudioProvider::AudioProvider(QObject* parent) + : Service(parent) + , m_processor(nullptr) + , m_thread(nullptr) {} + +AudioProvider::~AudioProvider() { + if (m_thread) { + m_thread->quit(); + m_thread->wait(); + } +} + +void AudioProvider::init() { + if (!m_processor) { + qWarning() << "AudioProvider::init: attempted to init with no processor set"; + return; + } + + m_thread = new QThread(this); + m_processor->moveToThread(m_thread); + + connect(m_thread, &QThread::started, m_processor, &AudioProcessor::init); + connect(m_thread, &QThread::finished, m_processor, &AudioProcessor::deleteLater); + connect(m_thread, &QThread::finished, m_thread, &QThread::deleteLater); + + m_thread->start(); +} + +void AudioProvider::start() { + if (m_processor) { + AudioCollector::instance(); // Create instance on main thread + QMetaObject::invokeMethod(m_processor, &AudioProcessor::start); + } +} + +void AudioProvider::stop() { + if (m_processor) { + QMetaObject::invokeMethod(m_processor, &AudioProcessor::stop); + } +} + +} // namespace caelestia::services diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Services/audioprovider.hpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/audioprovider.hpp new file mode 100644 index 0000000..5bf9bb0 --- /dev/null +++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/audioprovider.hpp @@ -0,0 +1,48 @@ +#pragma once + +#include "service.hpp" +#include +#include + +namespace caelestia::services { + +class AudioProcessor : public QObject { + Q_OBJECT + +public: + explicit AudioProcessor(QObject* parent = nullptr); + ~AudioProcessor(); + + void init(); + +public slots: + void start(); + void stop(); + +protected: + virtual void process() = 0; + +private: + QTimer* m_timer; +}; + +class AudioProvider : public Service { + Q_OBJECT + +public: + explicit AudioProvider(QObject* parent = nullptr); + ~AudioProvider(); + +protected: + AudioProcessor* m_processor; + + void init(); + +private: + QThread* m_thread; + + void start() override; + void stop() override; +}; + +} // namespace caelestia::services diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Services/beattracker.cpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/beattracker.cpp new file mode 100644 index 0000000..93addc6 --- /dev/null +++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/beattracker.cpp @@ -0,0 +1,58 @@ +#include "beattracker.hpp" + +#include "audiocollector.hpp" +#include "audioprovider.hpp" +#include + +namespace caelestia::services { + +BeatProcessor::BeatProcessor(QObject* parent) + : AudioProcessor(parent) + , m_tempo(new_aubio_tempo("default", 1024, ac::CHUNK_SIZE, ac::SAMPLE_RATE)) + , m_in(new_fvec(ac::CHUNK_SIZE)) + , m_out(new_fvec(2)) {}; + +BeatProcessor::~BeatProcessor() { + if (m_tempo) { + del_aubio_tempo(m_tempo); + } + if (m_in) { + del_fvec(m_in); + } + del_fvec(m_out); +} + +void BeatProcessor::process() { + if (!m_tempo || !m_in) { + return; + } + + AudioCollector::instance().readChunk(m_in->data); + + aubio_tempo_do(m_tempo, m_in, m_out); + if (!qFuzzyIsNull(m_out->data[0])) { + emit beat(aubio_tempo_get_bpm(m_tempo)); + } +} + +BeatTracker::BeatTracker(QObject* parent) + : AudioProvider(parent) + , m_bpm(120) { + m_processor = new BeatProcessor(); + init(); + + connect(static_cast(m_processor), &BeatProcessor::beat, this, &BeatTracker::updateBpm); +} + +smpl_t BeatTracker::bpm() const { + return m_bpm; +} + +void BeatTracker::updateBpm(smpl_t bpm) { + if (!qFuzzyCompare(bpm + 1.0f, m_bpm + 1.0f)) { + m_bpm = bpm; + emit bpmChanged(); + } +} + +} // namespace caelestia::services diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Services/beattracker.hpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/beattracker.hpp new file mode 100644 index 0000000..94738ce --- /dev/null +++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/beattracker.hpp @@ -0,0 +1,49 @@ +#pragma once + +#include "audioprovider.hpp" +#include +#include + +namespace caelestia::services { + +class BeatProcessor : public AudioProcessor { + Q_OBJECT + +public: + explicit BeatProcessor(QObject* parent = nullptr); + ~BeatProcessor(); + +signals: + void beat(smpl_t bpm); + +protected: + void process() override; + +private: + aubio_tempo_t* m_tempo; + fvec_t* m_in; + fvec_t* m_out; +}; + +class BeatTracker : public AudioProvider { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(smpl_t bpm READ bpm NOTIFY bpmChanged) + +public: + explicit BeatTracker(QObject* parent = nullptr); + + [[nodiscard]] smpl_t bpm() const; + +signals: + void bpmChanged(); + void beat(smpl_t bpm); + +private: + smpl_t m_bpm; + + void updateBpm(smpl_t bpm); +}; + +} // namespace caelestia::services diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Services/cavaprovider.cpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/cavaprovider.cpp new file mode 100644 index 0000000..7b6cc1f --- /dev/null +++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/cavaprovider.cpp @@ -0,0 +1,140 @@ +#include "cavaprovider.hpp" + +#include "audiocollector.hpp" +#include "audioprovider.hpp" +#include +#include +#include + +namespace caelestia::services { + +CavaProcessor::CavaProcessor(QObject* parent) + : AudioProcessor(parent) + , m_plan(nullptr) + , m_in(new double[ac::CHUNK_SIZE]) + , m_out(nullptr) + , m_bars(0) {}; + +CavaProcessor::~CavaProcessor() { + cleanup(); + delete[] m_in; +} + +void CavaProcessor::process() { + if (!m_plan || m_bars == 0 || !m_out) { + return; + } + + const int count = static_cast(AudioCollector::instance().readChunk(m_in)); + + // Process in data via cava + cava_execute(m_in, count, m_out, m_plan); + + // Apply monstercat filter + QVector values(m_bars); + + // Left to right pass + const double inv = 1.0 / 1.5; + double carry = 0.0; + for (int i = 0; i < m_bars; ++i) { + carry = std::max(m_out[i], carry * inv); + values[i] = carry; + } + + // Right to left pass and combine + carry = 0.0; + for (int i = m_bars - 1; i >= 0; --i) { + carry = std::max(m_out[i], carry * inv); + values[i] = std::max(values[i], carry); + } + + // Update values + if (values != m_values) { + m_values = std::move(values); + emit valuesChanged(m_values); + } +} + +void CavaProcessor::setBars(int bars) { + if (bars < 0) { + qWarning() << "CavaProcessor::setBars: bars must be greater than 0. Setting to 0."; + bars = 0; + } + + if (m_bars != bars) { + m_bars = bars; + reload(); + } +} + +void CavaProcessor::reload() { + cleanup(); + initCava(); +} + +void CavaProcessor::cleanup() { + if (m_plan) { + cava_destroy(m_plan); + m_plan = nullptr; + } + + if (m_out) { + delete[] m_out; + m_out = nullptr; + } +} + +void CavaProcessor::initCava() { + if (m_plan || m_bars == 0) { + return; + } + + m_plan = cava_init(m_bars, ac::SAMPLE_RATE, 1, 1, 0.85, 50, 10000); + m_out = new double[static_cast(m_bars)]; +} + +CavaProvider::CavaProvider(QObject* parent) + : AudioProvider(parent) + , m_bars(0) + , m_values(m_bars, 0.0) { + m_processor = new CavaProcessor(); + init(); + + connect(static_cast(m_processor), &CavaProcessor::valuesChanged, this, &CavaProvider::updateValues); +} + +int CavaProvider::bars() const { + return m_bars; +} + +void CavaProvider::setBars(int bars) { + if (bars < 0) { + qWarning() << "CavaProvider::setBars: bars must be greater than 0. Setting to 0."; + bars = 0; + } + + if (m_bars == bars) { + return; + } + + m_values.resize(bars, 0.0); + m_bars = bars; + emit barsChanged(); + emit valuesChanged(); + + QMetaObject::invokeMethod( + static_cast(m_processor), &CavaProcessor::setBars, Qt::QueuedConnection, bars); +} + +QVector CavaProvider::values() const { + return m_values; +} + +void CavaProvider::updateValues(QVector values) { + if (values != m_values) { + m_values = values; + emit valuesChanged(); + } +} + +} // namespace caelestia::services diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Services/cavaprovider.hpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/cavaprovider.hpp new file mode 100644 index 0000000..c45e33f --- /dev/null +++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/cavaprovider.hpp @@ -0,0 +1,64 @@ +#pragma once + +#include "audioprovider.hpp" +#include +#include + +namespace caelestia::services { + +class CavaProcessor : public AudioProcessor { + Q_OBJECT + +public: + explicit CavaProcessor(QObject* parent = nullptr); + ~CavaProcessor(); + + void setBars(int bars); + +signals: + void valuesChanged(QVector values); + +protected: + void process() override; + +private: + struct cava_plan* m_plan; + double* m_in; + double* m_out; + + int m_bars; + QVector m_values; + + void reload(); + void initCava(); + void cleanup(); +}; + +class CavaProvider : public AudioProvider { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(int bars READ bars WRITE setBars NOTIFY barsChanged) + + Q_PROPERTY(QVector values READ values NOTIFY valuesChanged) + +public: + explicit CavaProvider(QObject* parent = nullptr); + + [[nodiscard]] int bars() const; + void setBars(int bars); + + [[nodiscard]] QVector values() const; + +signals: + void barsChanged(); + void valuesChanged(); + +private: + int m_bars; + QVector m_values; + + void updateValues(QVector values); +}; + +} // namespace caelestia::services diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Services/service.cpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/service.cpp new file mode 100644 index 0000000..bc21567 --- /dev/null +++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/service.cpp @@ -0,0 +1,26 @@ +#include "service.hpp" + +#include +#include + +namespace caelestia::services { + +Service::Service(QObject* parent) + : QObject(parent) {} + +void Service::ref(QObject* sender) { + if (m_refs.isEmpty()) { + start(); + } + + QObject::connect(sender, &QObject::destroyed, this, &Service::unref); + m_refs << sender; +} + +void Service::unref(QObject* sender) { + if (m_refs.remove(sender) && m_refs.isEmpty()) { + stop(); + } +} + +} // namespace caelestia::services diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Services/service.hpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/service.hpp new file mode 100644 index 0000000..f8af03a --- /dev/null +++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/service.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include +#include + +namespace caelestia::services { + +class Service : public QObject { + Q_OBJECT + +public: + explicit Service(QObject* parent = nullptr); + + void ref(QObject* sender); + void unref(QObject* sender); + +private: + QSet m_refs; + + virtual void start() = 0; + virtual void stop() = 0; +}; + +} // namespace caelestia::services diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Services/serviceref.cpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/serviceref.cpp new file mode 100644 index 0000000..db1a3f2 --- /dev/null +++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/serviceref.cpp @@ -0,0 +1,36 @@ +#include "serviceref.hpp" + +#include "service.hpp" + +namespace caelestia::services { + +ServiceRef::ServiceRef(Service* service, QObject* parent) + : QObject(parent) + , m_service(service) { + if (m_service) { + m_service->ref(this); + } +} + +Service* ServiceRef::service() const { + return m_service; +} + +void ServiceRef::setService(Service* service) { + if (m_service == service) { + return; + } + + if (m_service) { + m_service->unref(this); + } + + m_service = service; + emit serviceChanged(); + + if (m_service) { + m_service->ref(this); + } +} + +} // namespace caelestia::services diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/Services/serviceref.hpp b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/serviceref.hpp new file mode 100644 index 0000000..d4d305c --- /dev/null +++ b/.config/quickshell/caelestia/plugin/src/Caelestia/Services/serviceref.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include "service.hpp" +#include +#include + +namespace caelestia::services { + +class ServiceRef : public QObject { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(caelestia::services::Service* service READ service WRITE setService NOTIFY serviceChanged) + +public: + explicit ServiceRef(Service* service = nullptr, QObject* parent = nullptr); + + [[nodiscard]] Service* service() const; + void setService(Service* service); + +signals: + void serviceChanged(); + +private: + QPointer m_service; +}; + +} // namespace caelestia::services diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/appdb.cpp b/.config/quickshell/caelestia/plugin/src/Caelestia/appdb.cpp new file mode 100644 index 0000000..b074cf4 --- /dev/null +++ b/.config/quickshell/caelestia/plugin/src/Caelestia/appdb.cpp @@ -0,0 +1,312 @@ +#include "appdb.hpp" + +#include +#include +#include + +namespace caelestia { + +AppEntry::AppEntry(QObject* entry, unsigned int frequency, QObject* parent) + : QObject(parent) + , m_entry(entry) + , m_frequency(frequency) { + const auto mo = m_entry->metaObject(); + const auto tmo = metaObject(); + + for (const auto& prop : + { "name", "comment", "execString", "startupClass", "genericName", "categories", "keywords" }) { + const auto metaProp = mo->property(mo->indexOfProperty(prop)); + const auto thisMetaProp = tmo->property(tmo->indexOfProperty(prop)); + QObject::connect(m_entry, metaProp.notifySignal(), this, thisMetaProp.notifySignal()); + } + + QObject::connect(m_entry, &QObject::destroyed, this, [this]() { + m_entry = nullptr; + deleteLater(); + }); +} + +QObject* AppEntry::entry() const { + return m_entry; +} + +quint32 AppEntry::frequency() const { + return m_frequency; +} + +void AppEntry::setFrequency(unsigned int frequency) { + if (m_frequency != frequency) { + m_frequency = frequency; + emit frequencyChanged(); + } +} + +void AppEntry::incrementFrequency() { + m_frequency++; + emit frequencyChanged(); +} + +QString AppEntry::id() const { + if (!m_entry) { + return ""; + } + return m_entry->property("id").toString(); +} + +QString AppEntry::name() const { + if (!m_entry) { + return ""; + } + return m_entry->property("name").toString(); +} + +QString AppEntry::comment() const { + if (!m_entry) { + return ""; + } + return m_entry->property("comment").toString(); +} + +QString AppEntry::execString() const { + if (!m_entry) { + return ""; + } + return m_entry->property("execString").toString(); +} + +QString AppEntry::startupClass() const { + if (!m_entry) { + return ""; + } + return m_entry->property("startupClass").toString(); +} + +QString AppEntry::genericName() const { + if (!m_entry) { + return ""; + } + return m_entry->property("genericName").toString(); +} + +QString AppEntry::categories() const { + if (!m_entry) { + return ""; + } + return m_entry->property("categories").toStringList().join(" "); +} + +QString AppEntry::keywords() const { + if (!m_entry) { + return ""; + } + return m_entry->property("keywords").toStringList().join(" "); +} + +AppDb::AppDb(QObject* parent) + : QObject(parent) + , m_timer(new QTimer(this)) + , m_uuid(QUuid::createUuid().toString()) { + m_timer->setSingleShot(true); + m_timer->setInterval(300); + QObject::connect(m_timer, &QTimer::timeout, this, &AppDb::updateApps); + + auto db = QSqlDatabase::addDatabase("QSQLITE", m_uuid); + db.setDatabaseName(":memory:"); + db.open(); + + QSqlQuery query(db); + query.exec("CREATE TABLE IF NOT EXISTS frequencies (id TEXT PRIMARY KEY, frequency INTEGER)"); +} + +QString AppDb::uuid() const { + return m_uuid; +} + +QString AppDb::path() const { + return m_path; +} + +void AppDb::setPath(const QString& path) { + auto newPath = path.isEmpty() ? ":memory:" : path; + + if (m_path == newPath) { + return; + } + + m_path = newPath; + emit pathChanged(); + + auto db = QSqlDatabase::database(m_uuid, false); + db.close(); + db.setDatabaseName(newPath); + db.open(); + + QSqlQuery query(db); + query.exec("CREATE TABLE IF NOT EXISTS frequencies (id TEXT PRIMARY KEY, frequency INTEGER)"); + + updateAppFrequencies(); +} + +QObjectList AppDb::entries() const { + return m_entries; +} + +void AppDb::setEntries(const QObjectList& entries) { + if (m_entries == entries) { + return; + } + + m_entries = entries; + emit entriesChanged(); + + m_timer->start(); +} + +QStringList AppDb::favouriteApps() const { + return m_favouriteApps; +} + +void AppDb::setFavouriteApps(const QStringList& favApps) { + if (m_favouriteApps == favApps) { + return; + } + + m_favouriteApps = favApps; + emit favouriteAppsChanged(); + m_favouriteAppsRegex.clear(); + m_favouriteAppsRegex.reserve(m_favouriteApps.size()); + for (const QString& item : std::as_const(m_favouriteApps)) { + const QRegularExpression re(regexifyString(item)); + if (re.isValid()) { + m_favouriteAppsRegex << re; + } else { + qWarning() << "AppDb::setFavouriteApps: Regular expression is not valid: " << re.pattern(); + } + } + + emit appsChanged(); +} + +QString AppDb::regexifyString(const QString& original) const { + if (original.startsWith('^') && original.endsWith('$')) + return original; + + const QString escaped = QRegularExpression::escape(original); + return QStringLiteral("^%1$").arg(escaped); +} + +QQmlListProperty AppDb::apps() { + return QQmlListProperty(this, &getSortedApps()); +} + +void AppDb::incrementFrequency(const QString& id) { + auto db = QSqlDatabase::database(m_uuid); + QSqlQuery query(db); + + query.prepare("INSERT INTO frequencies (id, frequency) " + "VALUES (:id, 1) " + "ON CONFLICT (id) DO UPDATE SET frequency = frequency + 1"); + query.bindValue(":id", id); + query.exec(); + + auto* app = m_apps.value(id); + if (app) { + const auto before = getSortedApps(); + + app->incrementFrequency(); + + if (before != getSortedApps()) { + emit appsChanged(); + } + } else { + qWarning() << "AppDb::incrementFrequency: could not find app with id" << id; + } +} + +QList& AppDb::getSortedApps() const { + m_sortedApps = m_apps.values(); + std::sort(m_sortedApps.begin(), m_sortedApps.end(), [this](AppEntry* a, AppEntry* b) { + bool aIsFav = isFavourite(a); + bool bIsFav = isFavourite(b); + if (aIsFav != bIsFav) { + return aIsFav; + } + if (a->frequency() != b->frequency()) { + return a->frequency() > b->frequency(); + } + return a->name().localeAwareCompare(b->name()) < 0; + }); + return m_sortedApps; +} + +bool AppDb::isFavourite(const AppEntry* app) const { + for (const QRegularExpression& re : m_favouriteAppsRegex) { + if (re.match(app->id()).hasMatch()) { + return true; + } + } + return false; +} + +quint32 AppDb::getFrequency(const QString& id) const { + auto db = QSqlDatabase::database(m_uuid); + QSqlQuery query(db); + + query.prepare("SELECT frequency FROM frequencies WHERE id = :id"); + query.bindValue(":id", id); + + if (query.exec() && query.next()) { + return query.value(0).toUInt(); + } + + return 0; +} + +void AppDb::updateAppFrequencies() { + const auto before = getSortedApps(); + + for (auto* app : std::as_const(m_apps)) { + app->setFrequency(getFrequency(app->id())); + } + + if (before != getSortedApps()) { + emit appsChanged(); + } +} + +void AppDb::updateApps() { + bool dirty = false; + + for (const auto& entry : std::as_const(m_entries)) { + const auto id = entry->property("id").toString(); + if (!m_apps.contains(id)) { + dirty = true; + auto* const newEntry = new AppEntry(entry, getFrequency(id), this); + QObject::connect(newEntry, &QObject::destroyed, this, [id, this]() { + if (m_apps.remove(id)) { + emit appsChanged(); + } + }); + m_apps.insert(id, newEntry); + } + } + + QSet newIds; + for (const auto& entry : std::as_const(m_entries)) { + newIds.insert(entry->property("id").toString()); + } + + for (auto it = m_apps.keyBegin(); it != m_apps.keyEnd(); ++it) { + const auto& id = *it; + if (!newIds.contains(id)) { + dirty = true; + m_apps.take(id)->deleteLater(); + } + } + + if (dirty) { + emit appsChanged(); + } +} + +} // namespace caelestia diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/appdb.hpp b/.config/quickshell/caelestia/plugin/src/Caelestia/appdb.hpp new file mode 100644 index 0000000..ce5f270 --- /dev/null +++ b/.config/quickshell/caelestia/plugin/src/Caelestia/appdb.hpp @@ -0,0 +1,116 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace caelestia { + +class AppEntry : public QObject { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("AppEntry instances can only be retrieved from an AppDb") + + // The actual DesktopEntry, but we don't have access to the type so it's a QObject + Q_PROPERTY(QObject* entry READ entry CONSTANT) + + Q_PROPERTY(quint32 frequency READ frequency NOTIFY frequencyChanged) + Q_PROPERTY(QString id READ id CONSTANT) + Q_PROPERTY(QString name READ name NOTIFY nameChanged) + Q_PROPERTY(QString comment READ comment NOTIFY commentChanged) + Q_PROPERTY(QString execString READ execString NOTIFY execStringChanged) + Q_PROPERTY(QString startupClass READ startupClass NOTIFY startupClassChanged) + Q_PROPERTY(QString genericName READ genericName NOTIFY genericNameChanged) + Q_PROPERTY(QString categories READ categories NOTIFY categoriesChanged) + Q_PROPERTY(QString keywords READ keywords NOTIFY keywordsChanged) + +public: + explicit AppEntry(QObject* entry, quint32 frequency, QObject* parent = nullptr); + + [[nodiscard]] QObject* entry() const; + + [[nodiscard]] quint32 frequency() const; + void setFrequency(quint32 frequency); + void incrementFrequency(); + + [[nodiscard]] QString id() const; + [[nodiscard]] QString name() const; + [[nodiscard]] QString comment() const; + [[nodiscard]] QString execString() const; + [[nodiscard]] QString startupClass() const; + [[nodiscard]] QString genericName() const; + [[nodiscard]] QString categories() const; + [[nodiscard]] QString keywords() const; + +signals: + void frequencyChanged(); + void nameChanged(); + void commentChanged(); + void execStringChanged(); + void startupClassChanged(); + void genericNameChanged(); + void categoriesChanged(); + void keywordsChanged(); + +private: + QObject* m_entry; + quint32 m_frequency; +}; + +class AppDb : public QObject { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(QString uuid READ uuid CONSTANT) + Q_PROPERTY(QString path READ path WRITE setPath NOTIFY pathChanged REQUIRED) + Q_PROPERTY(QObjectList entries READ entries WRITE setEntries NOTIFY entriesChanged REQUIRED) + Q_PROPERTY(QStringList favouriteApps READ favouriteApps WRITE setFavouriteApps NOTIFY favouriteAppsChanged REQUIRED) + Q_PROPERTY(QQmlListProperty apps READ apps NOTIFY appsChanged) + +public: + explicit AppDb(QObject* parent = nullptr); + + [[nodiscard]] QString uuid() const; + + [[nodiscard]] QString path() const; + void setPath(const QString& path); + + [[nodiscard]] QObjectList entries() const; + void setEntries(const QObjectList& entries); + + [[nodiscard]] QStringList favouriteApps() const; + void setFavouriteApps(const QStringList& favApps); + + [[nodiscard]] QQmlListProperty apps(); + + Q_INVOKABLE void incrementFrequency(const QString& id); + +signals: + void pathChanged(); + void entriesChanged(); + void favouriteAppsChanged(); + void appsChanged(); + +private: + QTimer* m_timer; + + const QString m_uuid; + QString m_path; + QObjectList m_entries; + QStringList m_favouriteApps; // unedited string list from qml + QList m_favouriteAppsRegex; // pre-regexified m_favouriteApps list + QHash m_apps; + mutable QList m_sortedApps; + + QString regexifyString(const QString& original) const; + QList& getSortedApps() const; + bool isFavourite(const AppEntry* app) const; + quint32 getFrequency(const QString& id) const; + void updateAppFrequencies(); + void updateApps(); +}; + +} // namespace caelestia diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/cutils.cpp b/.config/quickshell/caelestia/plugin/src/Caelestia/cutils.cpp new file mode 100644 index 0000000..6e3bfa9 --- /dev/null +++ b/.config/quickshell/caelestia/plugin/src/Caelestia/cutils.cpp @@ -0,0 +1,131 @@ +#include "cutils.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace caelestia { + +void CUtils::saveItem(QQuickItem* target, const QUrl& path) { + this->saveItem(target, path, QRect(), QJSValue(), QJSValue()); +} + +void CUtils::saveItem(QQuickItem* target, const QUrl& path, const QRect& rect) { + this->saveItem(target, path, rect, QJSValue(), QJSValue()); +} + +void CUtils::saveItem(QQuickItem* target, const QUrl& path, QJSValue onSaved) { + this->saveItem(target, path, QRect(), onSaved, QJSValue()); +} + +void CUtils::saveItem(QQuickItem* target, const QUrl& path, QJSValue onSaved, QJSValue onFailed) { + this->saveItem(target, path, QRect(), onSaved, onFailed); +} + +void CUtils::saveItem(QQuickItem* target, const QUrl& path, const QRect& rect, QJSValue onSaved) { + this->saveItem(target, path, rect, onSaved, QJSValue()); +} + +void CUtils::saveItem(QQuickItem* target, const QUrl& path, const QRect& rect, QJSValue onSaved, QJSValue onFailed) { + if (!target) { + qWarning() << "CUtils::saveItem: a target is required"; + return; + } + + if (!path.isLocalFile()) { + qWarning() << "CUtils::saveItem:" << path << "is not a local file"; + return; + } + + if (!target->window()) { + qWarning() << "CUtils::saveItem: unable to save target" << target << "without a window"; + return; + } + + auto scaledRect = rect; + const qreal scale = target->window()->devicePixelRatio(); + if (rect.isValid() && !qFuzzyCompare(scale + 1.0, 2.0)) { + scaledRect = + QRectF(rect.left() * scale, rect.top() * scale, rect.width() * scale, rect.height() * scale).toRect(); + } + + const QSharedPointer grabResult = target->grabToImage(); + + QObject::connect(grabResult.data(), &QQuickItemGrabResult::ready, this, + [grabResult, scaledRect, path, onSaved, onFailed, this]() { + const auto future = QtConcurrent::run([=]() { + QImage image = grabResult->image(); + + if (scaledRect.isValid()) { + image = image.copy(scaledRect); + } + + const QString file = path.toLocalFile(); + const QString parent = QFileInfo(file).absolutePath(); + return QDir().mkpath(parent) && image.save(file); + }); + + auto* watcher = new QFutureWatcher(this); + auto* engine = qmlEngine(this); + + QObject::connect(watcher, &QFutureWatcher::finished, this, [=]() { + if (watcher->result()) { + if (onSaved.isCallable()) { + onSaved.call( + { QJSValue(path.toLocalFile()), engine->toScriptValue(QVariant::fromValue(path)) }); + } + } else { + qWarning() << "CUtils::saveItem: failed to save" << path; + if (onFailed.isCallable()) { + onFailed.call({ engine->toScriptValue(QVariant::fromValue(path)) }); + } + } + watcher->deleteLater(); + }); + watcher->setFuture(future); + }); +} + +bool CUtils::copyFile(const QUrl& source, const QUrl& target, bool overwrite) const { + if (!source.isLocalFile()) { + qWarning() << "CUtils::copyFile: source" << source << "is not a local file"; + return false; + } + if (!target.isLocalFile()) { + qWarning() << "CUtils::copyFile: target" << target << "is not a local file"; + return false; + } + + if (overwrite && QFile::exists(target.toLocalFile())) { + if (!QFile::remove(target.toLocalFile())) { + qWarning() << "CUtils::copyFile: overwrite was specified but failed to remove" << target.toLocalFile(); + return false; + } + } + + return QFile::copy(source.toLocalFile(), target.toLocalFile()); +} + +bool CUtils::deleteFile(const QUrl& path) const { + if (!path.isLocalFile()) { + qWarning() << "CUtils::deleteFile: path" << path << "is not a local file"; + return false; + } + + return QFile::remove(path.toLocalFile()); +} + +QString CUtils::toLocalFile(const QUrl& url) const { + if (!url.isLocalFile()) { + qWarning() << "CUtils::toLocalFile: given url is not a local file" << url; + return QString(); + } + + return url.toLocalFile(); +} + +} // namespace caelestia diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/cutils.hpp b/.config/quickshell/caelestia/plugin/src/Caelestia/cutils.hpp new file mode 100644 index 0000000..027226d --- /dev/null +++ b/.config/quickshell/caelestia/plugin/src/Caelestia/cutils.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include +#include +#include + +namespace caelestia { + +class CUtils : public QObject { + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + +public: + // clang-format off + Q_INVOKABLE void saveItem(QQuickItem* target, const QUrl& path); + Q_INVOKABLE void saveItem(QQuickItem* target, const QUrl& path, const QRect& rect); + Q_INVOKABLE void saveItem(QQuickItem* target, const QUrl& path, QJSValue onSaved); + Q_INVOKABLE void saveItem(QQuickItem* target, const QUrl& path, QJSValue onSaved, QJSValue onFailed); + Q_INVOKABLE void saveItem(QQuickItem* target, const QUrl& path, const QRect& rect, QJSValue onSaved); + Q_INVOKABLE void saveItem(QQuickItem* target, const QUrl& path, const QRect& rect, QJSValue onSaved, QJSValue onFailed); + // clang-format on + + Q_INVOKABLE bool copyFile(const QUrl& source, const QUrl& target, bool overwrite = true) const; + Q_INVOKABLE bool deleteFile(const QUrl& path) const; + Q_INVOKABLE QString toLocalFile(const QUrl& url) const; +}; + +} // namespace caelestia diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/imageanalyser.cpp b/.config/quickshell/caelestia/plugin/src/Caelestia/imageanalyser.cpp new file mode 100644 index 0000000..880b078 --- /dev/null +++ b/.config/quickshell/caelestia/plugin/src/Caelestia/imageanalyser.cpp @@ -0,0 +1,223 @@ +#include "imageanalyser.hpp" + +#include +#include +#include +#include +#include + +namespace caelestia { + +ImageAnalyser::ImageAnalyser(QObject* parent) + : QObject(parent) + , m_futureWatcher(new QFutureWatcher(this)) + , m_source("") + , m_sourceItem(nullptr) + , m_rescaleSize(128) + , m_dominantColour(0, 0, 0) + , m_luminance(0) { + QObject::connect(m_futureWatcher, &QFutureWatcher::finished, this, [this]() { + if (!m_futureWatcher->future().isResultReadyAt(0)) { + return; + } + + const auto result = m_futureWatcher->result(); + if (m_dominantColour != result.first) { + m_dominantColour = result.first; + emit dominantColourChanged(); + } + if (!qFuzzyCompare(m_luminance + 1.0, result.second + 1.0)) { + m_luminance = result.second; + emit luminanceChanged(); + } + }); +} + +QString ImageAnalyser::source() const { + return m_source; +} + +void ImageAnalyser::setSource(const QString& source) { + if (m_source == source) { + return; + } + + m_source = source; + emit sourceChanged(); + + if (m_sourceItem) { + m_sourceItem = nullptr; + emit sourceItemChanged(); + } + + requestUpdate(); +} + +QQuickItem* ImageAnalyser::sourceItem() const { + return m_sourceItem; +} + +void ImageAnalyser::setSourceItem(QQuickItem* sourceItem) { + if (m_sourceItem == sourceItem) { + return; + } + + m_sourceItem = sourceItem; + emit sourceItemChanged(); + + if (!m_source.isEmpty()) { + m_source = ""; + emit sourceChanged(); + } + + requestUpdate(); +} + +int ImageAnalyser::rescaleSize() const { + return m_rescaleSize; +} + +void ImageAnalyser::setRescaleSize(int rescaleSize) { + if (m_rescaleSize == rescaleSize) { + return; + } + + m_rescaleSize = rescaleSize; + emit rescaleSizeChanged(); + + requestUpdate(); +} + +QColor ImageAnalyser::dominantColour() const { + return m_dominantColour; +} + +qreal ImageAnalyser::luminance() const { + return m_luminance; +} + +void ImageAnalyser::requestUpdate() { + if (m_source.isEmpty() && !m_sourceItem) { + return; + } + + if (!m_sourceItem || (m_sourceItem->window() && m_sourceItem->window()->isVisible() && m_sourceItem->width() > 0 && + m_sourceItem->height() > 0)) { + update(); + } else if (m_sourceItem) { + if (!m_sourceItem->window()) { + QObject::connect(m_sourceItem, &QQuickItem::windowChanged, this, &ImageAnalyser::requestUpdate, + Qt::SingleShotConnection); + } else if (!m_sourceItem->window()->isVisible()) { + QObject::connect(m_sourceItem->window(), &QQuickWindow::visibleChanged, this, &ImageAnalyser::requestUpdate, + Qt::SingleShotConnection); + } + if (m_sourceItem->width() <= 0) { + QObject::connect( + m_sourceItem, &QQuickItem::widthChanged, this, &ImageAnalyser::requestUpdate, Qt::SingleShotConnection); + } + if (m_sourceItem->height() <= 0) { + QObject::connect(m_sourceItem, &QQuickItem::heightChanged, this, &ImageAnalyser::requestUpdate, + Qt::SingleShotConnection); + } + } +} + +void ImageAnalyser::update() { + if (m_source.isEmpty() && !m_sourceItem) { + return; + } + + if (m_futureWatcher->isRunning()) { + m_futureWatcher->cancel(); + } + + if (m_sourceItem) { + const QSharedPointer grabResult = m_sourceItem->grabToImage(); + QObject::connect(grabResult.data(), &QQuickItemGrabResult::ready, this, [grabResult, this]() { + m_futureWatcher->setFuture(QtConcurrent::run(&ImageAnalyser::analyse, grabResult->image(), m_rescaleSize)); + }); + } else { + m_futureWatcher->setFuture(QtConcurrent::run([=, this](QPromise& promise) { + const QImage image(m_source); + analyse(promise, image, m_rescaleSize); + })); + } +} + +void ImageAnalyser::analyse(QPromise& promise, const QImage& image, int rescaleSize) { + if (image.isNull()) { + qWarning() << "ImageAnalyser::analyse: image is null"; + return; + } + + QImage img = image; + + if (rescaleSize > 0 && (img.width() > rescaleSize || img.height() > rescaleSize)) { + img = img.scaled(rescaleSize, rescaleSize, Qt::KeepAspectRatio, Qt::FastTransformation); + } + + if (promise.isCanceled()) { + return; + } + + if (img.format() != QImage::Format_ARGB32) { + img = img.convertToFormat(QImage::Format_ARGB32); + } + + if (promise.isCanceled()) { + return; + } + + const uchar* data = img.bits(); + const int width = img.width(); + const int height = img.height(); + const qsizetype bytesPerLine = img.bytesPerLine(); + + std::unordered_map colours; + qreal totalLuminance = 0.0; + int count = 0; + + for (int y = 0; y < height; ++y) { + const uchar* line = data + y * bytesPerLine; + for (int x = 0; x < width; ++x) { + if (promise.isCanceled()) { + return; + } + + const uchar* pixel = line + x * 4; + + if (pixel[3] == 0) { + continue; + } + + const quint32 mr = static_cast(pixel[0] & 0xF8); + const quint32 mg = static_cast(pixel[1] & 0xF8); + const quint32 mb = static_cast(pixel[2] & 0xF8); + ++colours[(mr << 16) | (mg << 8) | mb]; + + const qreal r = pixel[0] / 255.0; + const qreal g = pixel[1] / 255.0; + const qreal b = pixel[2] / 255.0; + totalLuminance += std::sqrt(0.299 * r * r + 0.587 * g * g + 0.114 * b * b); + ++count; + } + } + + quint32 dominantColour = 0; + int maxCount = 0; + for (const auto& [colour, colourCount] : colours) { + if (promise.isCanceled()) { + return; + } + + if (colourCount > maxCount) { + dominantColour = colour; + maxCount = colourCount; + } + } + + promise.addResult(qMakePair(QColor((0xFFu << 24) | dominantColour), count == 0 ? 0.0 : totalLuminance / count)); +} + +} // namespace caelestia diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/imageanalyser.hpp b/.config/quickshell/caelestia/plugin/src/Caelestia/imageanalyser.hpp new file mode 100644 index 0000000..bbea2b3 --- /dev/null +++ b/.config/quickshell/caelestia/plugin/src/Caelestia/imageanalyser.hpp @@ -0,0 +1,61 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace caelestia { + +class ImageAnalyser : public QObject { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(QString source READ source WRITE setSource NOTIFY sourceChanged) + Q_PROPERTY(QQuickItem* sourceItem READ sourceItem WRITE setSourceItem NOTIFY sourceItemChanged) + Q_PROPERTY(int rescaleSize READ rescaleSize WRITE setRescaleSize NOTIFY rescaleSizeChanged) + Q_PROPERTY(QColor dominantColour READ dominantColour NOTIFY dominantColourChanged) + Q_PROPERTY(qreal luminance READ luminance NOTIFY luminanceChanged) + +public: + explicit ImageAnalyser(QObject* parent = nullptr); + + [[nodiscard]] QString source() const; + void setSource(const QString& source); + + [[nodiscard]] QQuickItem* sourceItem() const; + void setSourceItem(QQuickItem* sourceItem); + + [[nodiscard]] int rescaleSize() const; + void setRescaleSize(int rescaleSize); + + [[nodiscard]] QColor dominantColour() const; + [[nodiscard]] qreal luminance() const; + + Q_INVOKABLE void requestUpdate(); + +signals: + void sourceChanged(); + void sourceItemChanged(); + void rescaleSizeChanged(); + void dominantColourChanged(); + void luminanceChanged(); + +private: + using AnalyseResult = QPair; + + QFutureWatcher* const m_futureWatcher; + + QString m_source; + QQuickItem* m_sourceItem; + int m_rescaleSize; + + QColor m_dominantColour; + qreal m_luminance; + + void update(); + static void analyse(QPromise& promise, const QImage& image, int rescaleSize); +}; + +} // namespace caelestia diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/qalculator.cpp b/.config/quickshell/caelestia/plugin/src/Caelestia/qalculator.cpp new file mode 100644 index 0000000..44e8d21 --- /dev/null +++ b/.config/quickshell/caelestia/plugin/src/Caelestia/qalculator.cpp @@ -0,0 +1,52 @@ +#include "qalculator.hpp" + +#include + +namespace caelestia { + +Qalculator::Qalculator(QObject* parent) + : QObject(parent) { + if (!CALCULATOR) { + new Calculator(); + CALCULATOR->loadExchangeRates(); + CALCULATOR->loadGlobalDefinitions(); + CALCULATOR->loadLocalDefinitions(); + } +} + +QString Qalculator::eval(const QString& expr, bool printExpr) const { + if (expr.isEmpty()) { + return QString(); + } + + 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()) { + return QString::fromStdString(error); + } + + if (printExpr) { + return QString("%1 = %2").arg(parsed).arg(result); + } + + return QString::fromStdString(result); +} + +} // namespace caelestia diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/qalculator.hpp b/.config/quickshell/caelestia/plugin/src/Caelestia/qalculator.hpp new file mode 100644 index 0000000..a07a8a2 --- /dev/null +++ b/.config/quickshell/caelestia/plugin/src/Caelestia/qalculator.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include +#include + +namespace caelestia { + +class Qalculator : public QObject { + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + +public: + explicit Qalculator(QObject* parent = nullptr); + + Q_INVOKABLE QString eval(const QString& expr, bool printExpr = true) const; +}; + +} // namespace caelestia diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/requests.cpp b/.config/quickshell/caelestia/plugin/src/Caelestia/requests.cpp new file mode 100644 index 0000000..2ceddb3 --- /dev/null +++ b/.config/quickshell/caelestia/plugin/src/Caelestia/requests.cpp @@ -0,0 +1,35 @@ +#include "requests.hpp" + +#include +#include +#include + +namespace caelestia { + +Requests::Requests(QObject* parent) + : QObject(parent) + , m_manager(new QNetworkAccessManager(this)) {} + +void Requests::get(const QUrl& url, QJSValue onSuccess, QJSValue onError) const { + if (!onSuccess.isCallable()) { + qWarning() << "Requests::get: onSuccess is not callable"; + return; + } + + QNetworkRequest request(url); + auto reply = m_manager->get(request); + + QObject::connect(reply, &QNetworkReply::finished, [reply, onSuccess, onError]() { + if (reply->error() == QNetworkReply::NoError) { + onSuccess.call({ QString(reply->readAll()) }); + } else if (onError.isCallable()) { + onError.call({ reply->errorString() }); + } else { + qWarning() << "Requests::get: request failed with error" << reply->errorString(); + } + + reply->deleteLater(); + }); +} + +} // namespace caelestia diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/requests.hpp b/.config/quickshell/caelestia/plugin/src/Caelestia/requests.hpp new file mode 100644 index 0000000..1db2f4c --- /dev/null +++ b/.config/quickshell/caelestia/plugin/src/Caelestia/requests.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include +#include +#include + +namespace caelestia { + +class Requests : public QObject { + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + +public: + explicit Requests(QObject* parent = nullptr); + + Q_INVOKABLE void get(const QUrl& url, QJSValue callback, QJSValue onError = QJSValue()) const; + +private: + QNetworkAccessManager* m_manager; +}; + +} // namespace caelestia diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/toaster.cpp b/.config/quickshell/caelestia/plugin/src/Caelestia/toaster.cpp new file mode 100644 index 0000000..978805d --- /dev/null +++ b/.config/quickshell/caelestia/plugin/src/Caelestia/toaster.cpp @@ -0,0 +1,116 @@ +#include "toaster.hpp" + +#include +#include +#include + +namespace caelestia { + +Toast::Toast(const QString& title, const QString& message, const QString& icon, Type type, int timeout, QObject* parent) + : QObject(parent) + , m_closed(false) + , m_title(title) + , m_message(message) + , m_icon(icon) + , m_type(type) + , m_timeout(timeout) { + QTimer::singleShot(timeout, this, &Toast::close); + + if (m_icon.isEmpty()) { + switch (m_type) { + case Type::Success: + m_icon = "check_circle_unread"; + break; + case Type::Warning: + m_icon = "warning"; + break; + case Type::Error: + m_icon = "error"; + break; + default: + m_icon = "info"; + break; + } + } + + if (timeout <= 0) { + switch (m_type) { + case Type::Warning: + m_timeout = 7000; + break; + case Type::Error: + m_timeout = 10000; + break; + default: + m_timeout = 5000; + break; + } + } +} + +bool Toast::closed() const { + return m_closed; +} + +QString Toast::title() const { + return m_title; +} + +QString Toast::message() const { + return m_message; +} + +QString Toast::icon() const { + return m_icon; +} + +int Toast::timeout() const { + return m_timeout; +} + +Toast::Type Toast::type() const { + return m_type; +} + +void Toast::close() { + if (!m_closed) { + m_closed = true; + emit closedChanged(); + } + + if (m_locks.isEmpty()) { + emit finishedClose(); + } +} + +void Toast::lock(QObject* sender) { + m_locks << sender; + QObject::connect(sender, &QObject::destroyed, this, &Toast::unlock); +} + +void Toast::unlock(QObject* sender) { + if (m_locks.remove(sender) && m_closed) { + close(); + } +} + +Toaster::Toaster(QObject* parent) + : QObject(parent) {} + +QQmlListProperty Toaster::toasts() { + return QQmlListProperty(this, &m_toasts); +} + +void Toaster::toast(const QString& title, const QString& message, const QString& icon, Toast::Type type, int timeout) { + auto* toast = new Toast(title, message, icon, type, timeout, this); + QObject::connect(toast, &Toast::finishedClose, this, [toast, this]() { + if (m_toasts.removeOne(toast)) { + emit toastsChanged(); + toast->deleteLater(); + } + }); + m_toasts.push_front(toast); + emit toastsChanged(); +} + +} // namespace caelestia diff --git a/.config/quickshell/caelestia/plugin/src/Caelestia/toaster.hpp b/.config/quickshell/caelestia/plugin/src/Caelestia/toaster.hpp new file mode 100644 index 0000000..1f61734 --- /dev/null +++ b/.config/quickshell/caelestia/plugin/src/Caelestia/toaster.hpp @@ -0,0 +1,82 @@ +#pragma once + +#include +#include +#include +#include + +namespace caelestia { + +class Toast : public QObject { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("Toast instances can only be retrieved from a Toaster") + + Q_PROPERTY(bool closed READ closed NOTIFY closedChanged) + Q_PROPERTY(QString title READ title CONSTANT) + Q_PROPERTY(QString message READ message CONSTANT) + Q_PROPERTY(QString icon READ icon CONSTANT) + Q_PROPERTY(int timeout READ timeout CONSTANT) + Q_PROPERTY(Type type READ type CONSTANT) + +public: + enum class Type { + Info = 0, + Success, + Warning, + Error + }; + Q_ENUM(Type) + + explicit Toast(const QString& title, const QString& message, const QString& icon, Type type, int timeout, + QObject* parent = nullptr); + + [[nodiscard]] bool closed() const; + [[nodiscard]] QString title() const; + [[nodiscard]] QString message() const; + [[nodiscard]] QString icon() const; + [[nodiscard]] int timeout() const; + [[nodiscard]] Type type() const; + + Q_INVOKABLE void close(); + Q_INVOKABLE void lock(QObject* sender); + Q_INVOKABLE void unlock(QObject* sender); + +signals: + void closedChanged(); + void finishedClose(); + +private: + QSet m_locks; + + bool m_closed; + QString m_title; + QString m_message; + QString m_icon; + Type m_type; + int m_timeout; +}; + +class Toaster : public QObject { + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + Q_PROPERTY(QQmlListProperty toasts READ toasts NOTIFY toastsChanged) + +public: + explicit Toaster(QObject* parent = nullptr); + + [[nodiscard]] QQmlListProperty toasts(); + + Q_INVOKABLE void toast(const QString& title, const QString& message, const QString& icon = QString(), + caelestia::Toast::Type type = Toast::Type::Info, int timeout = 5000); + +signals: + void toastsChanged(); + +private: + QList m_toasts; +}; + +} // namespace caelestia diff --git a/.config/quickshell/caelestia/services/Audio.qml b/.config/quickshell/caelestia/services/Audio.qml new file mode 100644 index 0000000..908d156 --- /dev/null +++ b/.config/quickshell/caelestia/services/Audio.qml @@ -0,0 +1,157 @@ +pragma Singleton + +import qs.config +import Caelestia.Services +import Caelestia +import Quickshell +import Quickshell.Services.Pipewire +import QtQuick + +Singleton { + id: root + + 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 + + readonly property PwNode sink: Pipewire.defaultAudioSink + readonly property PwNode source: Pipewire.defaultAudioSource + + readonly property bool muted: !!sink?.audio?.muted + readonly property real volume: sink?.audio?.volume ?? 0 + + readonly property bool sourceMuted: !!source?.audio?.muted + readonly property real sourceVolume: source?.audio?.volume ?? 0 + + readonly property alias cava: cava + readonly property alias beatTracker: beatTracker + + 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)); + } + } + + function incrementVolume(amount: real): void { + setVolume(volume + (amount || Config.services.audioIncrement)); + } + + function decrementVolume(amount: real): void { + setVolume(volume - (amount || Config.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)); + } + } + + function incrementSourceVolume(amount: real): void { + setSourceVolume(sourceVolume + (amount || Config.services.audioIncrement)); + } + + function decrementSourceVolume(amount: real): void { + setSourceVolume(sourceVolume - (amount || Config.services.audioIncrement)); + } + + function setAudioSink(newSink: PwNode): void { + Pipewire.preferredDefaultAudioSink = newSink; + } + + function setAudioSource(newSource: PwNode): void { + Pipewire.preferredDefaultAudioSource = newSource; + } + + 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)); + } + } + + function setStreamMuted(stream: PwNode, muted: bool): void { + if (stream?.ready && stream?.audio) { + stream.audio.muted = muted; + } + } + + function getStreamVolume(stream: PwNode): real { + return stream?.audio?.volume ?? 0; + } + + function getStreamMuted(stream: PwNode): bool { + return !!stream?.audio?.muted; + } + + function getStreamName(stream: PwNode): string { + if (!stream) + return qsTr("Unknown"); + // Try application name first, then description, then name + return stream.applicationName || stream.description || stream.name || qsTr("Unknown Application"); + } + + onSinkChanged: { + if (!sink?.ready) + return; + + const newSinkName = sink.description || sink.name || qsTr("Unknown Device"); + + if (previousSinkName && previousSinkName !== newSinkName && Config.utilities.toasts.audioOutputChanged) + Toaster.toast(qsTr("Audio output changed"), qsTr("Now using: %1").arg(newSinkName), "volume_up"); + + previousSinkName = newSinkName; + } + + onSourceChanged: { + if (!source?.ready) + return; + + const newSourceName = source.description || source.name || qsTr("Unknown Device"); + + if (previousSourceName && previousSourceName !== newSourceName && Config.utilities.toasts.audioInputChanged) + Toaster.toast(qsTr("Audio input changed"), qsTr("Now using: %1").arg(newSourceName), "mic"); + + previousSourceName = newSourceName; + } + + Component.onCompleted: { + previousSinkName = sink?.description || sink?.name || qsTr("Unknown Device"); + previousSourceName = source?.description || source?.name || qsTr("Unknown Device"); + } + + PwObjectTracker { + objects: [...root.sinks, ...root.sources, ...root.streams] + } + + CavaProvider { + id: cava + + bars: Config.services.visualiserBars + } + + BeatTracker { + id: beatTracker + } +} diff --git a/.config/quickshell/caelestia/services/Brightness.qml b/.config/quickshell/caelestia/services/Brightness.qml new file mode 100644 index 0000000..12920ee --- /dev/null +++ b/.config/quickshell/caelestia/services/Brightness.qml @@ -0,0 +1,226 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.config +import qs.components.misc +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + property list ddcMonitors: [] + readonly property list monitors: variants.instances + property bool appleDisplayPresent: false + + function getMonitorForScreen(screen: ShellScreen): var { + return monitors.find(m => m.modelData === screen); + } + + function getMonitor(query: string): var { + if (query === "active") { + return monitors.find(m => Hypr.monitorFor(m.modelData)?.focused); + } + + if (query.startsWith("model:")) { + const model = query.slice(6); + return monitors.find(m => m.modelData.model === model); + } + + if (query.startsWith("serial:")) { + const serial = query.slice(7); + return monitors.find(m => m.modelData.serialNumber === serial); + } + + 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 => m.modelData.name === query); + } + + function increaseBrightness(): void { + const monitor = getMonitor("active"); + if (monitor) + monitor.setBrightness(monitor.brightness + Config.services.brightnessIncrement); + } + + function decreaseBrightness(): void { + const monitor = getMonitor("active"); + if (monitor) + monitor.setBrightness(monitor.brightness - Config.services.brightnessIncrement); + } + + onMonitorsChanged: { + ddcMonitors = []; + ddcProc.running = true; + } + + Variants { + id: variants + + model: Quickshell.screens + + Monitor {} + } + + Process { + running: true + command: ["sh", "-c", "asdbctl get"] // To avoid warnings if asdbctl is not installed + stdout: StdioCollector { + onStreamFinished: root.appleDisplayPresent = text.trim().length > 0 + } + } + + Process { + id: ddcProc + + command: ["ddcutil", "detect", "--brief"] + stdout: StdioCollector { + onStreamFinished: root.ddcMonitors = text.trim().split("\n\n").filter(d => d.startsWith("Display ")).map(d => ({ + busNum: d.match(/I2C bus:[ ]*\/dev\/i2c-([0-9]+)/)[1], + connector: d.match(/DRM connector:\s+(.*)/)[1].replace(/^card\d+-/, "") // strip "card1-" + })) + } + } + + CustomShortcut { + name: "brightnessUp" + description: "Increase brightness" + onPressed: root.increaseBrightness() + } + + CustomShortcut { + name: "brightnessDown" + description: "Decrease brightness" + onPressed: root.decreaseBrightness() + } + + IpcHandler { + target: "brightness" + + function get(): real { + return getFor("active"); + } + + // Allows searching by active/model/serial/id/name + function getFor(query: string): real { + return root.getMonitor(query)?.brightness ?? -1; + } + + function set(value: string): string { + return setFor("active", value); + } + + // Handles brightness value like brightnessctl: 0.1, +0.1, 0.1-, 10%, +10%, 10%- + function setFor(query: string, value: string): string { + const monitor = root.getMonitor(query); + if (!monitor) + return "Invalid monitor: " + query; + + let targetBrightness; + if (value.endsWith("%-")) { + const percent = parseFloat(value.slice(0, -2)); + targetBrightness = monitor.brightness - (percent / 100); + } else if (value.startsWith("+") && value.endsWith("%")) { + const percent = parseFloat(value.slice(1, -1)); + targetBrightness = monitor.brightness + (percent / 100); + } else if (value.endsWith("%")) { + const percent = parseFloat(value.slice(0, -1)); + targetBrightness = percent / 100; + } else if (value.startsWith("+")) { + const increment = parseFloat(value.slice(1)); + targetBrightness = monitor.brightness + increment; + } else if (value.endsWith("-")) { + const decrement = parseFloat(value.slice(0, -1)); + targetBrightness = monitor.brightness - decrement; + } else if (value.includes("%") || value.includes("-") || value.includes("+")) { + return `Invalid brightness format: ${value}\nExpected: 0.1, +0.1, 0.1-, 10%, +10%, 10%-`; + } else { + targetBrightness = parseFloat(value); + } + + if (isNaN(targetBrightness)) + return `Failed to parse value: ${value}\nExpected: 0.1, +0.1, 0.1-, 10%, +10%, 10%-`; + + monitor.setBrightness(targetBrightness); + + return `Set monitor ${monitor.modelData.name} brightness to ${+monitor.brightness.toFixed(2)}`; + } + } + + 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 bool isAppleDisplay: root.appleDisplayPresent && modelData.model.startsWith("StudioDisplay") + property real brightness + property real queuedBrightness: NaN + + readonly property Process initProc: Process { + stdout: StdioCollector { + onStreamFinished: { + if (monitor.isAppleDisplay) { + const val = parseInt(text.trim()); + monitor.brightness = val / 101; + } else { + const [, , , cur, max] = text.split(" "); + monitor.brightness = parseInt(cur) / parseInt(max); + } + } + } + } + + readonly property Timer timer: Timer { + interval: 500 + onTriggered: { + if (!isNaN(monitor.queuedBrightness)) { + monitor.setBrightness(monitor.queuedBrightness); + monitor.queuedBrightness = NaN; + } + } + } + + function setBrightness(value: real): void { + value = Math.max(0, Math.min(1, value)); + const rounded = Math.round(value * 100); + if (Math.round(brightness * 100) === rounded) + return; + + if (isDdc && timer.running) { + queuedBrightness = value; + return; + } + + brightness = value; + + if (isAppleDisplay) + Quickshell.execDetached(["asdbctl", "set", rounded]); + else if (isDdc) + Quickshell.execDetached(["ddcutil", "-b", busNum, "setvcp", "10", rounded]); + else + Quickshell.execDetached(["brightnessctl", "s", `${rounded}%`]); + + if (isDdc) + timer.restart(); + } + + function initBrightness(): void { + if (isAppleDisplay) + initProc.command = ["asdbctl", "get"]; + else if (isDdc) + initProc.command = ["ddcutil", "-b", busNum, "getvcp", "10", "--brief"]; + else + initProc.command = ["sh", "-c", "echo a b c $(brightnessctl g) $(brightnessctl m)"]; + + initProc.running = true; + } + + onBusNumChanged: initBrightness() + Component.onCompleted: initBrightness() + } +} diff --git a/.config/quickshell/caelestia/services/Colours.qml b/.config/quickshell/caelestia/services/Colours.qml new file mode 100644 index 0000000..cd86c8f --- /dev/null +++ b/.config/quickshell/caelestia/services/Colours.qml @@ -0,0 +1,237 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.config +import qs.utils +import Caelestia +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + property bool showPreview + property string scheme + property string flavour + readonly property bool light: showPreview ? previewLight : currentLight + property bool currentLight + property bool previewLight + readonly property M3Palette palette: showPreview ? preview : current + readonly property M3TPalette tPalette: M3TPalette {} + readonly property M3Palette current: M3Palette {} + readonly property M3Palette preview: M3Palette {} + readonly property Transparency transparency: Transparency {} + readonly property alias wallLuminance: analyser.luminance + + function getLuminance(c: color): real { + if (c.r == 0 && c.g == 0 && c.b == 0) + return 0; + return Math.sqrt(0.299 * (c.r ** 2) + 0.587 * (c.g ** 2) + 0.114 * (c.b ** 2)); + } + + function alterColour(c: color, a: real, layer: int): color { + const luminance = getLuminance(c); + + const offset = (!light || layer == 1 ? 1 : -layer / 2) * (light ? 0.2 : 0.3) * (1 - transparency.base) * (1 + wallLuminance * (light ? (layer == 1 ? 3 : 1) : 2.5)); + const scale = (luminance + offset) / luminance; + const r = Math.max(0, Math.min(1, c.r * scale)); + const g = Math.max(0, Math.min(1, c.g * scale)); + const b = Math.max(0, Math.min(1, c.b * scale)); + + return Qt.rgba(r, g, b, a); + } + + function layer(c: color, layer: var): color { + if (!transparency.enabled) + return c; + + return layer === 0 ? Qt.alpha(c, transparency.base) : alterColour(c, transparency.layers, layer ?? 1); + } + + function on(c: color): color { + if (c.hslLightness < 0.5) + return Qt.hsla(c.hslHue, c.hslSaturation, 0.9, 1); + return Qt.hsla(c.hslHue, c.hslSaturation, 0.1, 1); + } + + function load(data: string, isPreview: bool): void { + const colours = isPreview ? preview : current; + const scheme = JSON.parse(data); + + if (!isPreview) { + root.scheme = scheme.name; + flavour = scheme.flavour; + currentLight = scheme.mode === "light"; + } else { + previewLight = scheme.mode === "light"; + } + + for (const [name, colour] of Object.entries(scheme.colours)) { + const propName = name.startsWith("term") ? name : `m3${name}`; + if (colours.hasOwnProperty(propName)) + colours[propName] = `#${colour}`; + } + } + + function setMode(mode: string): void { + Quickshell.execDetached(["caelestia", "scheme", "set", "--notify", "-m", mode]); + } + + FileView { + path: `${Paths.state}/scheme.json` + watchChanges: true + onFileChanged: reload() + onLoaded: root.load(text(), false) + } + + ImageAnalyser { + id: analyser + + source: Wallpapers.current + } + + 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 + } + + component M3TPalette: QtObject { + readonly property color m3primary_paletteKeyColor: root.layer(root.palette.m3primary_paletteKeyColor) + readonly property color m3secondary_paletteKeyColor: root.layer(root.palette.m3secondary_paletteKeyColor) + readonly property color m3tertiary_paletteKeyColor: root.layer(root.palette.m3tertiary_paletteKeyColor) + readonly property color m3neutral_paletteKeyColor: root.layer(root.palette.m3neutral_paletteKeyColor) + readonly property color m3neutral_variant_paletteKeyColor: root.layer(root.palette.m3neutral_variant_paletteKeyColor) + readonly property color m3background: root.layer(root.palette.m3background, 0) + readonly property color m3onBackground: root.layer(root.palette.m3onBackground) + readonly property color m3surface: root.layer(root.palette.m3surface, 0) + readonly property color m3surfaceDim: root.layer(root.palette.m3surfaceDim, 0) + readonly property color m3surfaceBright: root.layer(root.palette.m3surfaceBright, 0) + readonly property color m3surfaceContainerLowest: root.layer(root.palette.m3surfaceContainerLowest) + readonly property color m3surfaceContainerLow: root.layer(root.palette.m3surfaceContainerLow) + readonly property color m3surfaceContainer: root.layer(root.palette.m3surfaceContainer) + readonly property color m3surfaceContainerHigh: root.layer(root.palette.m3surfaceContainerHigh) + readonly property color m3surfaceContainerHighest: root.layer(root.palette.m3surfaceContainerHighest) + readonly property color m3onSurface: root.layer(root.palette.m3onSurface) + readonly property color m3surfaceVariant: root.layer(root.palette.m3surfaceVariant, 0) + readonly property color m3onSurfaceVariant: root.layer(root.palette.m3onSurfaceVariant) + readonly property color m3inverseSurface: root.layer(root.palette.m3inverseSurface, 0) + readonly property color m3inverseOnSurface: root.layer(root.palette.m3inverseOnSurface) + readonly property color m3outline: root.layer(root.palette.m3outline) + readonly property color m3outlineVariant: root.layer(root.palette.m3outlineVariant) + readonly property color m3shadow: root.layer(root.palette.m3shadow) + readonly property color m3scrim: root.layer(root.palette.m3scrim) + readonly property color m3surfaceTint: root.layer(root.palette.m3surfaceTint) + readonly property color m3primary: root.layer(root.palette.m3primary) + readonly property color m3onPrimary: root.layer(root.palette.m3onPrimary) + readonly property color m3primaryContainer: root.layer(root.palette.m3primaryContainer) + readonly property color m3onPrimaryContainer: root.layer(root.palette.m3onPrimaryContainer) + readonly property color m3inversePrimary: root.layer(root.palette.m3inversePrimary) + readonly property color m3secondary: root.layer(root.palette.m3secondary) + readonly property color m3onSecondary: root.layer(root.palette.m3onSecondary) + readonly property color m3secondaryContainer: root.layer(root.palette.m3secondaryContainer) + readonly property color m3onSecondaryContainer: root.layer(root.palette.m3onSecondaryContainer) + readonly property color m3tertiary: root.layer(root.palette.m3tertiary) + readonly property color m3onTertiary: root.layer(root.palette.m3onTertiary) + readonly property color m3tertiaryContainer: root.layer(root.palette.m3tertiaryContainer) + readonly property color m3onTertiaryContainer: root.layer(root.palette.m3onTertiaryContainer) + readonly property color m3error: root.layer(root.palette.m3error) + readonly property color m3onError: root.layer(root.palette.m3onError) + readonly property color m3errorContainer: root.layer(root.palette.m3errorContainer) + readonly property color m3onErrorContainer: root.layer(root.palette.m3onErrorContainer) + readonly property color m3success: root.layer(root.palette.m3success) + readonly property color m3onSuccess: root.layer(root.palette.m3onSuccess) + readonly property color m3successContainer: root.layer(root.palette.m3successContainer) + readonly property color m3onSuccessContainer: root.layer(root.palette.m3onSuccessContainer) + readonly property color m3primaryFixed: root.layer(root.palette.m3primaryFixed) + readonly property color m3primaryFixedDim: root.layer(root.palette.m3primaryFixedDim) + readonly property color m3onPrimaryFixed: root.layer(root.palette.m3onPrimaryFixed) + readonly property color m3onPrimaryFixedVariant: root.layer(root.palette.m3onPrimaryFixedVariant) + readonly property color m3secondaryFixed: root.layer(root.palette.m3secondaryFixed) + readonly property color m3secondaryFixedDim: root.layer(root.palette.m3secondaryFixedDim) + readonly property color m3onSecondaryFixed: root.layer(root.palette.m3onSecondaryFixed) + readonly property color m3onSecondaryFixedVariant: root.layer(root.palette.m3onSecondaryFixedVariant) + readonly property color m3tertiaryFixed: root.layer(root.palette.m3tertiaryFixed) + readonly property color m3tertiaryFixedDim: root.layer(root.palette.m3tertiaryFixedDim) + readonly property color m3onTertiaryFixed: root.layer(root.palette.m3onTertiaryFixed) + readonly property color m3onTertiaryFixedVariant: root.layer(root.palette.m3onTertiaryFixedVariant) + } + + component M3Palette: QtObject { + property color m3primary_paletteKeyColor: "#a8627b" + property color m3secondary_paletteKeyColor: "#8e6f78" + property color m3tertiary_paletteKeyColor: "#986e4c" + property color m3neutral_paletteKeyColor: "#807477" + property color m3neutral_variant_paletteKeyColor: "#837377" + property color m3background: "#191114" + property color m3onBackground: "#efdfe2" + property color m3surface: "#191114" + property color m3surfaceDim: "#191114" + property color m3surfaceBright: "#403739" + property color m3surfaceContainerLowest: "#130c0e" + property color m3surfaceContainerLow: "#22191c" + property color m3surfaceContainer: "#261d20" + property color m3surfaceContainerHigh: "#31282a" + property color m3surfaceContainerHighest: "#3c3235" + property color m3onSurface: "#efdfe2" + property color m3surfaceVariant: "#514347" + property color m3onSurfaceVariant: "#d5c2c6" + property color m3inverseSurface: "#efdfe2" + property color m3inverseOnSurface: "#372e30" + property color m3outline: "#9e8c91" + property color m3outlineVariant: "#514347" + property color m3shadow: "#000000" + property color m3scrim: "#000000" + property color m3surfaceTint: "#ffb0ca" + property color m3primary: "#ffb0ca" + property color m3onPrimary: "#541d34" + property color m3primaryContainer: "#6f334a" + property color m3onPrimaryContainer: "#ffd9e3" + property color m3inversePrimary: "#8b4a62" + property color m3secondary: "#e2bdc7" + property color m3onSecondary: "#422932" + property color m3secondaryContainer: "#5a3f48" + property color m3onSecondaryContainer: "#ffd9e3" + property color m3tertiary: "#f0bc95" + property color m3onTertiary: "#48290c" + property color m3tertiaryContainer: "#b58763" + property color m3onTertiaryContainer: "#000000" + property color m3error: "#ffb4ab" + property color m3onError: "#690005" + property color m3errorContainer: "#93000a" + property color m3onErrorContainer: "#ffdad6" + property color m3success: "#B5CCBA" + property color m3onSuccess: "#213528" + property color m3successContainer: "#374B3E" + property color m3onSuccessContainer: "#D1E9D6" + property color m3primaryFixed: "#ffd9e3" + property color m3primaryFixedDim: "#ffb0ca" + property color m3onPrimaryFixed: "#39071f" + property color m3onPrimaryFixedVariant: "#6f334a" + property color m3secondaryFixed: "#ffd9e3" + property color m3secondaryFixedDim: "#e2bdc7" + property color m3onSecondaryFixed: "#2b151d" + property color m3onSecondaryFixedVariant: "#5a3f48" + property color m3tertiaryFixed: "#ffdcc3" + property color m3tertiaryFixedDim: "#f0bc95" + property color m3onTertiaryFixed: "#2f1500" + property color m3onTertiaryFixedVariant: "#623f21" + property color term0: "#353434" + property color term1: "#ff4c8a" + property color term2: "#ffbbb7" + property color term3: "#ffdedf" + property color term4: "#b3a2d5" + property color term5: "#e98fb0" + property color term6: "#ffba93" + property color term7: "#eed1d2" + property color term8: "#b39e9e" + property color term9: "#ff80a3" + property color term10: "#ffd3d0" + property color term11: "#fff1f0" + property color term12: "#dcbc93" + property color term13: "#f9a8c2" + property color term14: "#ffd1c0" + property color term15: "#ffffff" + } +} diff --git a/.config/quickshell/caelestia/services/GameMode.qml b/.config/quickshell/caelestia/services/GameMode.qml new file mode 100644 index 0000000..83770b7 --- /dev/null +++ b/.config/quickshell/caelestia/services/GameMode.qml @@ -0,0 +1,76 @@ +pragma Singleton + +import qs.services +import qs.config +import Caelestia +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + property alias enabled: props.enabled + + function setDynamicConfs(): void { + Hypr.extras.applyOptions({ + "animations:enabled": 0, + "decoration:shadow:enabled": 0, + "decoration:blur:enabled": 0, + "general:gaps_in": 0, + "general:gaps_out": 0, + "general:border_size": 1, + "decoration:rounding": 0, + "general:allow_tearing": 1 + }); + } + + onEnabledChanged: { + if (enabled) { + setDynamicConfs(); + if (Config.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) + Toaster.toast(qsTr("Game mode disabled"), qsTr("Hyprland settings restored"), "gamepad"); + } + } + + PersistentProperties { + id: props + + property bool enabled: Hypr.options["animations:enabled"] === 0 + + reloadableId: "gameMode" + } + + Connections { + target: Hypr + + function onConfigReloaded(): void { + if (props.enabled) + root.setDynamicConfs(); + } + } + + IpcHandler { + target: "gameMode" + + function isEnabled(): bool { + return props.enabled; + } + + function toggle(): void { + props.enabled = !props.enabled; + } + + function enable(): void { + props.enabled = true; + } + + function disable(): void { + props.enabled = false; + } + } +} diff --git a/.config/quickshell/caelestia/services/Hypr.qml b/.config/quickshell/caelestia/services/Hypr.qml new file mode 100644 index 0000000..a26c24d --- /dev/null +++ b/.config/quickshell/caelestia/services/Hypr.qml @@ -0,0 +1,213 @@ +pragma Singleton + +import qs.components.misc +import qs.config +import Caelestia +import Caelestia.Internal +import Quickshell +import Quickshell.Hyprland +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + readonly property var toplevels: Hyprland.toplevels + 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 HyprlandWorkspace focusedWorkspace: Hyprland.focusedWorkspace + readonly property HyprlandMonitor focusedMonitor: Hyprland.focusedMonitor + readonly property int activeWsId: focusedWorkspace?.id ?? 1 + + readonly property HyprKeyboard keyboard: extras.devices.keyboards.find(kb => kb.main) ?? null + readonly property bool capsLock: keyboard?.capsLock ?? false + readonly property bool numLock: keyboard?.numLock ?? false + readonly property string defaultKbLayout: keyboard?.layout.split(",")[0] ?? "??" + readonly property string kbLayoutFull: keyboard?.activeKeymap ?? "Unknown" + readonly property string kbLayout: kbMap.get(kbLayoutFull) ?? "??" + readonly property var kbMap: new Map() + + readonly property alias extras: extras + readonly property alias options: extras.options + readonly property alias devices: extras.devices + + property bool hadKeyboard + property string lastSpecialWorkspace: "" + + signal configReloaded + + function dispatch(request: string): void { + 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 monitorFor(screen: ShellScreen): HyprlandMonitor { + return Hyprland.monitorFor(screen); + } + + function reloadDynamicConfs(): void { + extras.batchMessage(["keyword bindlni ,Caps_Lock,global,caelestia:refreshDevices", "keyword bindlni ,Num_Lock,global,caelestia:refreshDevices"]); + } + + Component.onCompleted: reloadDynamicConfs() + + onCapsLockChanged: { + if (!Config.utilities.toasts.capsLockChanged) + return; + + if (capsLock) + Toaster.toast(qsTr("Caps lock enabled"), qsTr("Caps lock is currently enabled"), "keyboard_capslock_badge"); + else + Toaster.toast(qsTr("Caps lock disabled"), qsTr("Caps lock is currently disabled"), "keyboard_capslock"); + } + + onNumLockChanged: { + if (!Config.utilities.toasts.numLockChanged) + return; + + if (numLock) + Toaster.toast(qsTr("Num lock enabled"), qsTr("Num lock is currently enabled"), "looks_one"); + else + Toaster.toast(qsTr("Num lock disabled"), qsTr("Num lock is currently disabled"), "timer_1"); + } + + onKbLayoutFullChanged: { + if (hadKeyboard && Config.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")) + return; + + if (n === "configreloaded") { + root.configReloaded(); + root.reloadDynamicConfs(); + } else if (["workspace", "moveworkspace", "activespecial", "focusedmon"].includes(n)) { + Hyprland.refreshWorkspaces(); + Hyprland.refreshMonitors(); + } else if (["openwindow", "closewindow", "movewindow"].includes(n)) { + Hyprland.refreshToplevels(); + Hyprland.refreshWorkspaces(); + } else if (n.includes("mon")) { + Hyprland.refreshMonitors(); + } else if (n.includes("workspace")) { + Hyprland.refreshWorkspaces(); + } else if (n.includes("window") || n.includes("group") || ["pin", "fullscreen", "changefloatingmode", "minimize"].includes(n)) { + Hyprland.refreshToplevels(); + } + } + } + + Connections { + target: root.focusedMonitor + + function onLastIpcObjectChanged(): void { + const specialName = root.focusedMonitor.lastIpcObject.specialWorkspace.name; + + if (specialName && specialName.startsWith("special:")) { + root.lastSpecialWorkspace = specialName; + } + } + } + + FileView { + id: kbLayoutFile + + path: Quickshell.env("CAELESTIA_XKB_RULES_PATH") || "/usr/share/X11/xkb/rules/base.lst" + onLoaded: { + const layoutMatch = text().match(/! layout\n([\s\S]*?)\n\n/); + if (layoutMatch) { + const lines = layoutMatch[1].split("\n"); + for (const line of lines) { + if (!line.trim() || line.trim().startsWith("!")) + continue; + + const match = line.match(/^\s*([a-z]{2,})\s+([a-zA-Z() ]+)$/); + if (match) + root.kbMap.set(match[2], match[1]); + } + } + + const variantMatch = text().match(/! variant\n([\s\S]*?)\n\n/); + if (variantMatch) { + const lines = variantMatch[1].split("\n"); + for (const line of lines) { + if (!line.trim() || line.trim().startsWith("!")) + continue; + + const match = line.match(/^\s*([a-zA-Z0-9_-]+)\s+([a-z]{2,}): (.+)$/); + if (match) + root.kbMap.set(match[3], match[2]); + } + } + } + } + + 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"); + } + } + + CustomShortcut { + name: "refreshDevices" + description: "Reload devices" + onPressed: extras.refreshDevices() + onReleased: extras.refreshDevices() + } + + HyprExtras { + id: extras + } +} diff --git a/.config/quickshell/caelestia/services/IdleInhibitor.qml b/.config/quickshell/caelestia/services/IdleInhibitor.qml new file mode 100644 index 0000000..29409ab --- /dev/null +++ b/.config/quickshell/caelestia/services/IdleInhibitor.qml @@ -0,0 +1,56 @@ +pragma Singleton + +import Quickshell +import Quickshell.Io +import Quickshell.Wayland + +Singleton { + id: root + + property alias enabled: props.enabled + readonly property alias enabledSince: props.enabledSince + + onEnabledChanged: { + if (enabled) + props.enabledSince = new Date(); + } + + PersistentProperties { + id: props + + property bool enabled + property date enabledSince + + reloadableId: "idleInhibitor" + } + + IdleInhibitor { + enabled: props.enabled + window: PanelWindow { + implicitWidth: 0 + implicitHeight: 0 + color: "transparent" + mask: Region {} + } + } + + IpcHandler { + target: "idleInhibitor" + + function isEnabled(): bool { + return props.enabled; + } + + function toggle(): void { + props.enabled = !props.enabled; + } + + function enable(): void { + props.enabled = true; + } + + function disable(): void { + props.enabled = false; + } + } +} diff --git a/.config/quickshell/caelestia/services/Network.qml b/.config/quickshell/caelestia/services/Network.qml new file mode 100644 index 0000000..f3dfc3e --- /dev/null +++ b/.config/quickshell/caelestia/services/Network.qml @@ -0,0 +1,324 @@ +pragma Singleton + +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 + + function enableWifi(enabled: bool): void { + Nmcli.enableWifi(enabled, result => { + if (result.success) { + root.getWifiStatus(); + Nmcli.getNetworks(() => { + syncNetworksFromNmcli(); + }); + } + }); + } + + function toggleWifi(): void { + Nmcli.toggleWifi(result => { + if (result.success) { + root.getWifiStatus(); + Nmcli.getNetworks(() => { + syncNetworksFromNmcli(); + }); + } + }); + } + + function rescanWifi(): void { + 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) { + const hasBssid = bssid !== undefined && bssid !== null && bssid.length > 0; + root.pendingConnection = { + ssid: ssid, + bssid: hasBssid ? bssid : "", + callback: callback + }; + } + + Nmcli.connectToNetwork(ssid, password, bssid, result => { + if (result && result.success) { + // Connection successful + if (callback) + callback(result); + root.pendingConnection = null; + } else if (result && result.needsPassword) { + // Password needed - callback will handle showing dialog + if (callback) + callback(result); + } else { + // Connection failed + if (result && result.error) { + root.connectionFailed(ssid); + } + if (callback) + callback(result); + root.pendingConnection = null; + } + }); + } + + function connectToNetworkWithPasswordCheck(ssid: string, isSecure: bool, callback: var, bssid: string): void { + // Set up pending connection tracking + const hasBssid = bssid !== undefined && bssid !== null && bssid.length > 0; + root.pendingConnection = { + ssid: ssid, + bssid: hasBssid ? bssid : "", + callback: callback + }; + + Nmcli.connectToNetworkWithPasswordCheck(ssid, isSecure, result => { + if (result && result.success) { + // Connection successful + if (callback) + callback(result); + root.pendingConnection = null; + } else if (result && result.needsPassword) { + // Password needed - callback will handle showing dialog + if (callback) + callback(result); + } else { + // Connection failed + if (result && result.error) { + root.connectionFailed(ssid); + } + if (callback) + callback(result); + root.pendingConnection = null; + } + }, bssid); + } + + function disconnectFromNetwork(): void { + // Try to disconnect - use connection name if available, otherwise use device + Nmcli.disconnectFromNetwork(); + // Refresh network list after disconnection + Qt.callLater(() => { + Nmcli.getNetworks(() => { + syncNetworksFromNmcli(); + }); + }, 500); + } + + function forgetNetwork(ssid: string): void { + // Delete the connection profile for this network + // This will remove the saved password and connection settings + Nmcli.forgetNetwork(ssid, result => { + if (result.success) { + // Refresh network list after deletion + Qt.callLater(() => { + Nmcli.getNetworks(() => { + syncNetworksFromNmcli(); + }); + }, 500); + } + }); + } + + 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; + + // Build a map of existing networks by key + const existingMap = new Map(); + for (const rn of rNetworks) { + const key = `${rn.frequency}:${rn.ssid}:${rn.bssid}`; + existingMap.set(key, rn); + } + + // Build a map of new networks by key + const newMap = new Map(); + for (const nn of nNetworks) { + const key = `${nn.frequency}:${nn.ssid}:${nn.bssid}`; + newMap.set(key, nn); + } + + // Remove networks that no longer exist + for (const [key, network] of existingMap) { + if (!newMap.has(key)) { + const index = rNetworks.indexOf(network); + if (index >= 0) { + rNetworks.splice(index, 1); + network.destroy(); + } + } + } + + // Add or update networks from Nmcli + for (const [key, nNetwork] of newMap) { + const existing = existingMap.get(key); + if (existing) { + // Update existing network's lastIpcObject + existing.lastIpcObject = nNetwork.lastIpcObject; + } else { + // Create new AccessPoint from Nmcli's data + rNetworks.push(apComp.createObject(root, { + lastIpcObject: nNetwork.lastIpcObject + })); + } + } + } + + 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); + } + + function getWifiStatus(): void { + Nmcli.getWifiStatus(enabled => { + root.wifiEnabled = enabled; + }); + } + + function getEthernetDevices(): void { + root.ethernetProcessRunning = true; + Nmcli.getEthernetInterfaces(interfaces => { + root.ethernetDevices = Nmcli.ethernetDevices; + root.ethernetDeviceCount = Nmcli.ethernetDevices.length; + root.ethernetProcessRunning = false; + }); + } + + function connectEthernet(connectionName: string, interfaceName: string): void { + Nmcli.connectEthernet(connectionName, interfaceName, result => { + if (result.success) { + getEthernetDevices(); + // Refresh device details after connection + Qt.callLater(() => { + const activeDevice = root.ethernetDevices.find(function (d) { + return d.connected; + }); + if (activeDevice && activeDevice.interface) { + updateEthernetDeviceDetails(activeDevice.interface); + } + }, 1000); + } + }); + } + + function disconnectEthernet(connectionName: string): void { + Nmcli.disconnectEthernet(connectionName, result => { + if (result.success) { + getEthernetDevices(); + // Clear device details after disconnection + Qt.callLater(() => { + root.ethernetDeviceDetails = null; + }); + } + }); + } + + function updateEthernetDeviceDetails(interfaceName: string): void { + Nmcli.getEthernetDeviceDetails(interfaceName, details => { + root.ethernetDeviceDetails = details; + }); + } + + function updateWirelessDeviceDetails(): void { + // Find the wireless interface by looking for wifi devices + // Pass empty string to let Nmcli find the active interface automatically + Nmcli.getWirelessDeviceDetails("", details => { + root.wirelessDeviceDetails = details; + }); + } + + function cidrToSubnetMask(cidr: string): string { + // Convert CIDR notation (e.g., "24") to subnet mask (e.g., "255.255.255.0") + const cidrNum = parseInt(cidr); + if (isNaN(cidrNum) || cidrNum < 0 || cidrNum > 32) { + return ""; + } + + const mask = (0xffffffff << (32 - cidrNum)) >>> 0; + const octets = [(mask >>> 24) & 0xff, (mask >>> 16) & 0xff, (mask >>> 8) & 0xff, mask & 0xff]; + + return octets.join("."); + } + + Process { + running: true + command: ["nmcli", "m"] + stdout: SplitParser { + onRead: { + Nmcli.getNetworks(() => { + syncNetworksFromNmcli(); + }); + getEthernetDevices(); + } + } + } +} diff --git a/.config/quickshell/caelestia/services/NetworkUsage.qml b/.config/quickshell/caelestia/services/NetworkUsage.qml new file mode 100644 index 0000000..502ec3a --- /dev/null +++ b/.config/quickshell/caelestia/services/NetworkUsage.qml @@ -0,0 +1,233 @@ +pragma Singleton + +import qs.config + +import Quickshell +import Quickshell.Io + +import QtQuick + +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 of speeds for sparkline (most recent at end) + readonly property var downloadHistory: _downloadHistory + readonly property var uploadHistory: _uploadHistory + readonly property int historyLength: 30 + + // Private properties + property real _downloadSpeed: 0 + property real _uploadSpeed: 0 + property real _downloadTotal: 0 + property real _uploadTotal: 0 + property var _downloadHistory: [] + property var _uploadHistory: [] + + // 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 + }; + } + + FileView { + id: netDevFile + path: "/proc/net/dev" + } + + Timer { + interval: Config.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; + + const maxHistory = root.historyLength + 1; + + if (root._downloadSpeed >= 0 && isFinite(root._downloadSpeed)) { + let newDownHist = root._downloadHistory.slice(); + newDownHist.push(root._downloadSpeed); + if (newDownHist.length > maxHistory) { + newDownHist.shift(); + } + root._downloadHistory = newDownHist; + } + + if (root._uploadSpeed >= 0 && isFinite(root._uploadSpeed)) { + let newUpHist = root._uploadHistory.slice(); + newUpHist.push(root._uploadSpeed); + if (newUpHist.length > maxHistory) { + newUpHist.shift(); + } + root._uploadHistory = newUpHist; + } + } + + // 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/.config/quickshell/caelestia/services/Nmcli.qml b/.config/quickshell/caelestia/services/Nmcli.qml new file mode 100644 index 0000000..36bd3e6 --- /dev/null +++ b/.config/quickshell/caelestia/services/Nmcli.qml @@ -0,0 +1,1352 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + property var deviceStatus: null + property var wirelessInterfaces: [] + property var ethernetInterfaces: [] + property bool isConnected: false + property string activeInterface: "" + property string activeConnection: "" + property bool wifiEnabled: true + readonly property bool scanning: rescanProc.running + readonly property list networks: [] + readonly property AccessPoint active: networks.find(n => n.active) ?? null + property list savedConnections: [] + property list savedConnectionSsids: [] + + 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: [] + + // Constants + readonly property string deviceTypeWifi: "wifi" + readonly property string deviceTypeEthernet: "ethernet" + readonly property string connectionTypeWireless: "802-11-wireless" + readonly property string nmcliCommandDevice: "device" + readonly property string nmcliCommandConnection: "connection" + readonly property string nmcliCommandWifi: "wifi" + readonly property string nmcliCommandRadio: "radio" + readonly property string deviceStatusFields: "DEVICE,TYPE,STATE,CONNECTION" + readonly property string connectionListFields: "NAME,TYPE" + readonly property string wirelessSsidField: "802-11-wireless.ssid" + readonly property string networkListFields: "SSID,SIGNAL,SECURITY" + readonly property string networkDetailFields: "ACTIVE,SIGNAL,FREQ,SSID,BSSID,SECURITY" + readonly property string securityKeyMgmt: "802-11-wireless-security.key-mgmt" + readonly property string securityPsk: "802-11-wireless-security.psk" + readonly property string keyMgmtWpaPsk: "wpa-psk" + readonly property string connectionParamType: "type" + readonly property string connectionParamConName: "con-name" + readonly property string connectionParamIfname: "ifname" + readonly property string connectionParamSsid: "ssid" + readonly property string connectionParamPassword: "password" + readonly property string connectionParamBssid: "802-11-wireless.bssid" + + function detectPasswordRequired(error: string): bool { + if (!error || error.length === 0) { + return false; + } + + return (error.includes("Secrets were required") || error.includes("Secrets were required, but not provided") || error.includes("No secrets provided") || error.includes("802-11-wireless-security.psk") || error.includes("password for") || (error.includes("password") && !error.includes("Connection activated") && !error.includes("successfully")) || (error.includes("Secrets") && !error.includes("Connection activated") && !error.includes("successfully")) || (error.includes("802.11") && !error.includes("Connection activated") && !error.includes("successfully"))) && !error.includes("Connection activated") && !error.includes("successfully"); + } + + function parseNetworkOutput(output: string): list { + if (!output || output.length === 0) { + return []; + } + + const PLACEHOLDER = "STRINGWHICHHOPEFULLYWONTBEUSED"; + const rep = new RegExp("\\\\:", "g"); + const rep2 = new RegExp(PLACEHOLDER, "g"); + + const allNetworks = output.trim().split("\n").filter(line => line && line.length > 0).map(n => { + const net = n.replace(rep, PLACEHOLDER).split(":"); + return { + active: net[0] === "yes", + strength: parseInt(net[1] || "0", 10) || 0, + frequency: parseInt(net[2] || "0", 10) || 0, + ssid: (net[3]?.replace(rep2, ":") ?? "").trim(), + bssid: (net[4]?.replace(rep2, ":") ?? "").trim(), + security: (net[5] ?? "").trim() + }; + }).filter(n => n.ssid && n.ssid.length > 0); + + return allNetworks; + } + + function deduplicateNetworks(networks: list): list { + if (!networks || networks.length === 0) { + return []; + } + + const networkMap = new Map(); + for (const network of networks) { + const existing = networkMap.get(network.ssid); + if (!existing) { + networkMap.set(network.ssid, network); + } else { + if (network.active && !existing.active) { + networkMap.set(network.ssid, network); + } else if (!network.active && !existing.active) { + if (network.strength > existing.strength) { + networkMap.set(network.ssid, network); + } + } + } + } + + return Array.from(networkMap.values()); + } + + function isConnectionCommand(command: list): bool { + if (!command || command.length === 0) { + return false; + } + + return command.includes(root.nmcliCommandWifi) || command.includes(root.nmcliCommandConnection); + } + + function parseDeviceStatusOutput(output: string, filterType: string): list { + if (!output || output.length === 0) { + return []; + } + + const interfaces = []; + const lines = output.trim().split("\n"); + + for (const line of lines) { + const parts = line.split(":"); + if (parts.length >= 2) { + const deviceType = parts[1]; + let shouldInclude = false; + + if (filterType === root.deviceTypeWifi && deviceType === root.deviceTypeWifi) { + shouldInclude = true; + } else if (filterType === root.deviceTypeEthernet && deviceType === root.deviceTypeEthernet) { + shouldInclude = true; + } else if (filterType === "both" && (deviceType === root.deviceTypeWifi || deviceType === root.deviceTypeEthernet)) { + shouldInclude = true; + } + + if (shouldInclude) { + interfaces.push({ + device: parts[0] || "", + type: parts[1] || "", + state: parts[2] || "", + connection: parts[3] || "" + }); + } + } + } + + return interfaces; + } + + function isConnectedState(state: string): bool { + if (!state || state.length === 0) { + return false; + } + + return state === "100 (connected)" || state === "connected" || state.startsWith("connected"); + } + + function executeCommand(args: list, callback: var): void { + const proc = commandProc.createObject(root); + proc.command = ["nmcli", ...args]; + proc.callback = callback; + + activeProcesses.push(proc); + + proc.processFinished.connect(() => { + const index = activeProcesses.indexOf(proc); + if (index >= 0) { + activeProcesses.splice(index, 1); + } + }); + + Qt.callLater(() => { + proc.exec(proc.command); + }); + } + + function getDeviceStatus(callback: var): void { + executeCommand(["-t", "-f", root.deviceStatusFields, root.nmcliCommandDevice, "status"], result => { + if (callback) + callback(result.output); + }); + } + + function getWirelessInterfaces(callback: var): void { + executeCommand(["-t", "-f", root.deviceStatusFields, root.nmcliCommandDevice, "status"], result => { + const interfaces = parseDeviceStatusOutput(result.output, root.deviceTypeWifi); + root.wirelessInterfaces = interfaces; + if (callback) + callback(interfaces); + }); + } + + function getEthernetInterfaces(callback: var): void { + executeCommand(["-t", "-f", root.deviceStatusFields, root.nmcliCommandDevice, "status"], result => { + const interfaces = parseDeviceStatusOutput(result.output, root.deviceTypeEthernet); + const devices = []; + + for (const iface of interfaces) { + const connected = isConnectedState(iface.state); + + devices.push({ + interface: iface.device, + type: iface.type, + state: iface.state, + connection: iface.connection, + connected: connected, + ipAddress: "", + gateway: "", + dns: [], + subnet: "", + macAddress: "", + speed: "" + }); + } + + root.ethernetInterfaces = interfaces; + root.ethernetDevices = devices; + if (callback) + callback(interfaces); + }); + } + + function connectEthernet(connectionName: string, interfaceName: string, callback: var): void { + if (connectionName && connectionName.length > 0) { + executeCommand([root.nmcliCommandConnection, "up", connectionName], result => { + if (result.success) { + Qt.callLater(() => { + getEthernetInterfaces(() => {}); + if (interfaceName && interfaceName.length > 0) { + Qt.callLater(() => { + getEthernetDeviceDetails(interfaceName, () => {}); + }, 1000); + } + }, 500); + } + if (callback) + callback(result); + }); + } else if (interfaceName && interfaceName.length > 0) { + executeCommand([root.nmcliCommandDevice, "connect", interfaceName], result => { + if (result.success) { + Qt.callLater(() => { + getEthernetInterfaces(() => {}); + Qt.callLater(() => { + getEthernetDeviceDetails(interfaceName, () => {}); + }, 1000); + }, 500); + } + if (callback) + callback(result); + }); + } else { + if (callback) + callback({ + success: false, + output: "", + error: "No connection name or interface specified", + exitCode: -1 + }); + } + } + + function disconnectEthernet(connectionName: string, callback: var): void { + if (!connectionName || connectionName.length === 0) { + if (callback) + callback({ + success: false, + output: "", + error: "No connection name specified", + exitCode: -1 + }); + return; + } + + executeCommand([root.nmcliCommandConnection, "down", connectionName], result => { + if (result.success) { + root.ethernetDeviceDetails = null; + Qt.callLater(() => { + getEthernetInterfaces(() => {}); + }, 500); + } + if (callback) + callback(result); + }); + } + + function getAllInterfaces(callback: var): void { + executeCommand(["-t", "-f", root.deviceStatusFields, root.nmcliCommandDevice, "status"], result => { + const interfaces = parseDeviceStatusOutput(result.output, "both"); + if (callback) + callback(interfaces); + }); + } + + function isInterfaceConnected(interfaceName: string, callback: var): void { + executeCommand([root.nmcliCommandDevice, "status"], result => { + const lines = result.output.trim().split("\n"); + for (const line of lines) { + const parts = line.split(/\s+/); + if (parts.length >= 3 && parts[0] === interfaceName) { + const connected = isConnectedState(parts[2]); + if (callback) + callback(connected); + return; + } + } + if (callback) + callback(false); + }); + } + + function connectToNetworkWithPasswordCheck(ssid: string, isSecure: bool, callback: var, bssid: string): void { + if (isSecure) { + const hasBssid = bssid !== undefined && bssid !== null && bssid.length > 0; + connectWireless(ssid, "", bssid, result => { + if (result.success) { + if (callback) + callback({ + success: true, + usedSavedPassword: true, + output: result.output, + error: "", + exitCode: 0 + }); + } else if (result.needsPassword) { + if (callback) + callback({ + success: false, + needsPassword: true, + output: result.output, + error: result.error, + exitCode: result.exitCode + }); + } else { + if (callback) + callback(result); + } + }); + } else { + connectWireless(ssid, "", bssid, callback); + } + } + + function connectToNetwork(ssid: string, password: string, bssid: string, callback: var): void { + connectWireless(ssid, password, bssid, callback); + } + + function connectWireless(ssid: string, password: string, bssid: string, callback: var, retryCount: int): void { + const hasBssid = bssid !== undefined && bssid !== null && bssid.length > 0; + const retries = retryCount !== undefined ? retryCount : 0; + const maxRetries = 2; + + if (callback) { + root.pendingConnection = { + ssid: ssid, + bssid: hasBssid ? bssid : "", + callback: callback, + retryCount: retries + }; + connectionCheckTimer.start(); + immediateCheckTimer.checkCount = 0; + immediateCheckTimer.start(); + } + + if (password && password.length > 0 && hasBssid) { + const bssidUpper = bssid.toUpperCase(); + createConnectionWithPassword(ssid, bssidUpper, password, callback); + return; + } + + let cmd = [root.nmcliCommandDevice, root.nmcliCommandWifi, "connect", ssid]; + if (password && password.length > 0) { + cmd.push(root.connectionParamPassword, password); + } + executeCommand(cmd, result => { + if (result.needsPassword && callback) { + if (callback) + callback(result); + return; + } + + if (!result.success && root.pendingConnection && retries < maxRetries) { + console.warn("[NMCLI] Connection failed, retrying... (attempt " + (retries + 1) + "/" + maxRetries + ")"); + Qt.callLater(() => { + connectWireless(ssid, password, bssid, callback, retries + 1); + }, 1000); + } else if (!result.success && root.pendingConnection) {} else if (result.success && callback) {} else if (!result.success && !root.pendingConnection) { + if (callback) + callback(result); + } + }); + } + + function createConnectionWithPassword(ssid: string, bssidUpper: string, password: string, callback: var): void { + checkAndDeleteConnection(ssid, () => { + const cmd = [root.nmcliCommandConnection, "add", root.connectionParamType, root.deviceTypeWifi, root.connectionParamConName, ssid, root.connectionParamIfname, "*", root.connectionParamSsid, ssid, root.connectionParamBssid, bssidUpper, root.securityKeyMgmt, root.keyMgmtWpaPsk, root.securityPsk, password]; + + executeCommand(cmd, result => { + if (result.success) { + loadSavedConnections(() => {}); + activateConnection(ssid, callback); + } else { + const hasDuplicateWarning = result.error && (result.error.includes("another connection with the name") || result.error.includes("Reference the connection by its uuid")); + + if (hasDuplicateWarning || (result.exitCode > 0 && result.exitCode < 10)) { + loadSavedConnections(() => {}); + activateConnection(ssid, callback); + } else { + console.warn("[NMCLI] Connection profile creation failed, trying fallback..."); + let fallbackCmd = [root.nmcliCommandDevice, root.nmcliCommandWifi, "connect", ssid, root.connectionParamPassword, password]; + executeCommand(fallbackCmd, fallbackResult => { + if (callback) + callback(fallbackResult); + }); + } + } + }); + }); + } + + function checkAndDeleteConnection(ssid: string, callback: var): void { + executeCommand([root.nmcliCommandConnection, "show", ssid], result => { + if (result.success) { + executeCommand([root.nmcliCommandConnection, "delete", ssid], deleteResult => { + Qt.callLater(() => { + if (callback) + callback(); + }, 300); + }); + } else { + if (callback) + callback(); + } + }); + } + + function activateConnection(connectionName: string, callback: var): void { + executeCommand([root.nmcliCommandConnection, "up", connectionName], result => { + if (callback) + callback(result); + }); + } + + function loadSavedConnections(callback: var): void { + executeCommand(["-t", "-f", root.connectionListFields, root.nmcliCommandConnection, "show"], result => { + if (!result.success) { + root.savedConnections = []; + root.savedConnectionSsids = []; + if (callback) + callback([]); + return; + } + + parseConnectionList(result.output, callback); + }); + } + + function parseConnectionList(output: string, callback: var): void { + const lines = output.trim().split("\n").filter(line => line.length > 0); + const wifiConnections = []; + const connections = []; + + for (const line of lines) { + const parts = line.split(":"); + if (parts.length >= 2) { + const name = parts[0]; + const type = parts[1]; + connections.push(name); + + if (type === root.connectionTypeWireless) { + wifiConnections.push(name); + } + } + } + + root.savedConnections = connections; + + if (wifiConnections.length > 0) { + root.wifiConnectionQueue = wifiConnections; + root.currentSsidQueryIndex = 0; + root.savedConnectionSsids = []; + queryNextSsid(callback); + } else { + root.savedConnectionSsids = []; + root.wifiConnectionQueue = []; + if (callback) + callback(root.savedConnectionSsids); + } + } + + function queryNextSsid(callback: var): void { + if (root.currentSsidQueryIndex < root.wifiConnectionQueue.length) { + const connectionName = root.wifiConnectionQueue[root.currentSsidQueryIndex]; + root.currentSsidQueryIndex++; + + executeCommand(["-t", "-f", root.wirelessSsidField, root.nmcliCommandConnection, "show", connectionName], result => { + if (result.success) { + processSsidOutput(result.output); + } + queryNextSsid(callback); + }); + } else { + root.wifiConnectionQueue = []; + root.currentSsidQueryIndex = 0; + if (callback) + callback(root.savedConnectionSsids); + } + } + + function processSsidOutput(output: string): void { + const lines = output.trim().split("\n"); + for (const line of lines) { + if (line.startsWith("802-11-wireless.ssid:")) { + const ssid = line.substring("802-11-wireless.ssid:".length).trim(); + if (ssid && ssid.length > 0) { + const ssidLower = ssid.toLowerCase(); + const exists = root.savedConnectionSsids.some(s => s && s.toLowerCase() === ssidLower); + if (!exists) { + const newList = root.savedConnectionSsids.slice(); + newList.push(ssid); + root.savedConnectionSsids = newList; + } + } + } + } + } + + function hasSavedProfile(ssid: string): bool { + if (!ssid || ssid.length === 0) { + return false; + } + const ssidLower = ssid.toLowerCase().trim(); + + if (root.active && root.active.ssid) { + const activeSsidLower = root.active.ssid.toLowerCase().trim(); + if (activeSsidLower === ssidLower) { + return true; + } + } + + const hasSsid = root.savedConnectionSsids.some(savedSsid => savedSsid && savedSsid.toLowerCase().trim() === ssidLower); + + if (hasSsid) { + return true; + } + + const hasConnectionName = root.savedConnections.some(connName => connName && connName.toLowerCase().trim() === ssidLower); + + return hasConnectionName; + } + + function forgetNetwork(ssid: string, callback: var): void { + if (!ssid || ssid.length === 0) { + if (callback) + callback({ + success: false, + output: "", + error: "No SSID specified", + exitCode: -1 + }); + return; + } + + const connectionName = root.savedConnections.find(conn => conn && conn.toLowerCase().trim() === ssid.toLowerCase().trim()) || ssid; + + executeCommand([root.nmcliCommandConnection, "delete", connectionName], result => { + if (result.success) { + Qt.callLater(() => { + loadSavedConnections(() => {}); + }, 500); + } + if (callback) + callback(result); + }); + } + + function disconnect(interfaceName: string, callback: var): void { + if (interfaceName && interfaceName.length > 0) { + executeCommand([root.nmcliCommandDevice, "disconnect", interfaceName], result => { + if (callback) + callback(result.success ? result.output : ""); + }); + } else { + executeCommand([root.nmcliCommandDevice, "disconnect", root.deviceTypeWifi], result => { + if (callback) + callback(result.success ? result.output : ""); + }); + } + } + + function disconnectFromNetwork(): void { + if (active && active.ssid) { + executeCommand([root.nmcliCommandConnection, "down", active.ssid], result => { + if (result.success) { + getNetworks(() => {}); + } + }); + } else { + executeCommand([root.nmcliCommandDevice, "disconnect", root.deviceTypeWifi], result => { + if (result.success) { + getNetworks(() => {}); + } + }); + } + } + + function getDeviceDetails(interfaceName: string, callback: var): void { + executeCommand([root.nmcliCommandDevice, "show", interfaceName], result => { + if (callback) + callback(result.output); + }); + } + + function refreshStatus(callback: var): void { + getDeviceStatus(output => { + const lines = output.trim().split("\n"); + let connected = false; + let activeIf = ""; + let activeConn = ""; + + for (const line of lines) { + const parts = line.split(":"); + if (parts.length >= 4) { + const state = parts[2] || ""; + if (isConnectedState(state)) { + connected = true; + activeIf = parts[0] || ""; + activeConn = parts[3] || ""; + break; + } + } + } + + root.isConnected = connected; + root.activeInterface = activeIf; + root.activeConnection = activeConn; + + if (callback) + callback({ + connected, + interface: activeIf, + connection: activeConn + }); + }); + } + + function bringInterfaceUp(interfaceName: string, callback: var): void { + if (interfaceName && interfaceName.length > 0) { + executeCommand([root.nmcliCommandDevice, "connect", interfaceName], result => { + if (callback) { + callback(result); + } + }); + } else { + if (callback) + callback({ + success: false, + output: "", + error: "No interface specified", + exitCode: -1 + }); + } + } + + function bringInterfaceDown(interfaceName: string, callback: var): void { + if (interfaceName && interfaceName.length > 0) { + executeCommand([root.nmcliCommandDevice, "disconnect", interfaceName], result => { + if (callback) { + callback(result); + } + }); + } else { + if (callback) + callback({ + success: false, + output: "", + error: "No interface specified", + exitCode: -1 + }); + } + } + + function scanWirelessNetworks(interfaceName: string, callback: var): void { + let cmd = [root.nmcliCommandDevice, root.nmcliCommandWifi, "rescan"]; + if (interfaceName && interfaceName.length > 0) { + cmd.push(root.connectionParamIfname, interfaceName); + } + executeCommand(cmd, result => { + if (callback) { + callback(result); + } + }); + } + + function rescanWifi(): void { + rescanProc.running = true; + } + + function enableWifi(enabled: bool, callback: var): void { + const cmd = enabled ? "on" : "off"; + executeCommand([root.nmcliCommandRadio, root.nmcliCommandWifi, cmd], result => { + if (result.success) { + getWifiStatus(status => { + root.wifiEnabled = status; + if (callback) + callback(result); + }); + } else { + if (callback) + callback(result); + } + }); + } + + function toggleWifi(callback: var): void { + const newState = !root.wifiEnabled; + enableWifi(newState, callback); + } + + function getWifiStatus(callback: var): void { + executeCommand([root.nmcliCommandRadio, root.nmcliCommandWifi], result => { + if (result.success) { + const enabled = result.output.trim() === "enabled"; + root.wifiEnabled = enabled; + if (callback) + callback(enabled); + } else { + if (callback) + callback(root.wifiEnabled); + } + }); + } + + function getNetworks(callback: var): void { + executeCommand(["-g", root.networkDetailFields, "d", "w"], result => { + if (!result.success) { + if (callback) + callback([]); + return; + } + + const allNetworks = parseNetworkOutput(result.output); + 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(); + } + } + + for (const network of networks) { + const match = rNetworks.find(n => n.frequency === network.frequency && n.ssid === network.ssid && n.bssid === network.bssid); + if (match) { + match.lastIpcObject = network; + } else { + rNetworks.push(apComp.createObject(root, { + lastIpcObject: network + })); + } + } + + if (callback) + callback(root.networks); + checkPendingConnection(); + }); + } + + function getWirelessSSIDs(interfaceName: string, callback: var): void { + let cmd = ["-t", "-f", root.networkListFields, root.nmcliCommandDevice, root.nmcliCommandWifi, "list"]; + if (interfaceName && interfaceName.length > 0) { + cmd.push(root.connectionParamIfname, interfaceName); + } + executeCommand(cmd, result => { + if (!result.success) { + if (callback) + callback([]); + return; + } + + const ssids = []; + const lines = result.output.trim().split("\n"); + const seenSSIDs = new Set(); + + for (const line of lines) { + if (!line || line.length === 0) + continue; + + const parts = line.split(":"); + if (parts.length >= 1) { + const ssid = parts[0].trim(); + if (ssid && ssid.length > 0 && !seenSSIDs.has(ssid)) { + seenSSIDs.add(ssid); + const signalStr = parts.length >= 2 ? parts[1].trim() : ""; + const signal = signalStr ? parseInt(signalStr, 10) : 0; + const security = parts.length >= 3 ? parts[2].trim() : ""; + ssids.push({ + ssid: ssid, + signal: signalStr, + signalValue: isNaN(signal) ? 0 : signal, + security: security + }); + } + } + } + + ssids.sort((a, b) => { + return b.signalValue - a.signalValue; + }); + + if (callback) + callback(ssids); + }); + } + + function handlePasswordRequired(proc: var, error: string, output: string, exitCode: int): bool { + if (!proc || !error || error.length === 0) { + return false; + } + + if (!isConnectionCommand(proc.command) || !root.pendingConnection || !root.pendingConnection.callback) { + return false; + } + + const needsPassword = detectPasswordRequired(error); + + if (needsPassword && !proc.callbackCalled && root.pendingConnection) { + connectionCheckTimer.stop(); + immediateCheckTimer.stop(); + immediateCheckTimer.checkCount = 0; + const pending = root.pendingConnection; + root.pendingConnection = null; + proc.callbackCalled = true; + const result = { + success: false, + output: output || "", + error: error, + exitCode: exitCode, + needsPassword: true + }; + if (pending.callback) { + pending.callback(result); + } + if (proc.callback && proc.callback !== pending.callback) { + proc.callback(result); + } + return true; + } + + 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(() => { + 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 { + if (!immediateCheckTimer.running) { + immediateCheckTimer.start(); + } + } + }); + } + } + + function cidrToSubnetMask(cidr: string): string { + const cidrNum = parseInt(cidr, 10); + if (isNaN(cidrNum) || cidrNum < 0 || cidrNum > 32) { + return ""; + } + + const mask = (0xffffffff << (32 - cidrNum)) >>> 0; + const octet1 = (mask >>> 24) & 0xff; + const octet2 = (mask >>> 16) & 0xff; + const octet3 = (mask >>> 8) & 0xff; + const octet4 = mask & 0xff; + + return `${octet1}.${octet2}.${octet3}.${octet4}`; + } + + function getWirelessDeviceDetails(interfaceName: string, callback: var): void { + if (!interfaceName || interfaceName.length === 0) { + const activeInterface = root.wirelessInterfaces.find(iface => { + return isConnectedState(iface.state); + }); + if (activeInterface && activeInterface.device) { + interfaceName = activeInterface.device; + } else { + if (callback) + callback(null); + return; + } + } + + executeCommand(["device", "show", interfaceName], result => { + if (!result.success || !result.output) { + root.wirelessDeviceDetails = null; + if (callback) + callback(null); + return; + } + + const details = parseDeviceDetails(result.output, false); + root.wirelessDeviceDetails = details; + if (callback) + callback(details); + }); + } + + function getEthernetDeviceDetails(interfaceName: string, callback: var): void { + if (!interfaceName || interfaceName.length === 0) { + const activeInterface = root.ethernetInterfaces.find(iface => { + return isConnectedState(iface.state); + }); + if (activeInterface && activeInterface.device) { + interfaceName = activeInterface.device; + } else { + if (callback) + callback(null); + return; + } + } + + executeCommand(["device", "show", interfaceName], result => { + if (!result.success || !result.output) { + root.ethernetDeviceDetails = null; + if (callback) + callback(null); + return; + } + + const details = parseDeviceDetails(result.output, true); + root.ethernetDeviceDetails = details; + if (callback) + callback(details); + }); + } + + function parseDeviceDetails(output: string, isEthernet: bool): var { + const details = { + ipAddress: "", + gateway: "", + dns: [], + subnet: "", + macAddress: "", + speed: "" + }; + + if (!output || output.length === 0) { + return details; + } + + const lines = output.trim().split("\n"); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const parts = line.split(":"); + if (parts.length >= 2) { + const key = parts[0].trim(); + const value = parts.slice(1).join(":").trim(); + + if (key.startsWith("IP4.ADDRESS")) { + const ipParts = value.split("/"); + details.ipAddress = ipParts[0] || ""; + if (ipParts[1]) { + details.subnet = cidrToSubnetMask(ipParts[1]); + } else { + details.subnet = ""; + } + } else if (key === "IP4.GATEWAY") { + if (value !== "--") { + details.gateway = value; + } + } else if (key.startsWith("IP4.DNS")) { + if (value !== "--" && value.length > 0) { + details.dns.push(value); + } + } else if (isEthernet && key === "WIRED-PROPERTIES.MAC") { + details.macAddress = value; + } else if (isEthernet && key === "WIRED-PROPERTIES.SPEED") { + details.speed = value; + } else if (!isEthernet && key === "GENERAL.HWADDR") { + details.macAddress = value; + } + } + } + + 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; + + if (newActive && newActive.active) { + Qt.callLater(() => { + if (root.wirelessInterfaces.length > 0) { + const activeWireless = root.wirelessInterfaces.find(iface => { + return isConnectedState(iface.state); + }); + if (activeWireless && activeWireless.device) { + getWirelessDeviceDetails(activeWireless.device, () => {}); + } + } + + if (root.ethernetInterfaces.length > 0) { + const activeEthernet = root.ethernetInterfaces.find(iface => { + return isConnectedState(iface.state); + }); + if (activeEthernet && activeEthernet.device) { + getEthernetDeviceDetails(activeEthernet.device, () => {}); + } + } + }, 500); + } else { + root.wirelessDeviceDetails = null; + root.ethernetDeviceDetails = null; + } + + getWirelessInterfaces(() => {}); + getEthernetInterfaces(() => { + if (root.activeEthernet && root.activeEthernet.connected) { + Qt.callLater(() => { + getEthernetDeviceDetails(root.activeEthernet.interface, () => {}); + }, 500); + } + }); + }); + } + + Component.onCompleted: { + getWifiStatus(() => {}); + getNetworks(() => {}); + loadSavedConnections(() => {}); + getEthernetInterfaces(() => {}); + + Qt.callLater(() => { + if (root.wirelessInterfaces.length > 0) { + const activeWireless = root.wirelessInterfaces.find(iface => { + return isConnectedState(iface.state); + }); + if (activeWireless && activeWireless.device) { + getWirelessDeviceDetails(activeWireless.device, () => {}); + } + } + + if (root.ethernetInterfaces.length > 0) { + const activeEthernet = root.ethernetInterfaces.find(iface => { + return isConnectedState(iface.state); + }); + if (activeEthernet && activeEthernet.device) { + getEthernetDeviceDetails(activeEthernet.device, () => {}); + } + } + }, 2000); + } +} diff --git a/.config/quickshell/caelestia/services/Notifs.qml b/.config/quickshell/caelestia/services/Notifs.qml new file mode 100644 index 0000000..2ebc32d --- /dev/null +++ b/.config/quickshell/caelestia/services/Notifs.qml @@ -0,0 +1,338 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.components.misc +import qs.config +import qs.utils +import Caelestia +import Quickshell +import Quickshell.Io +import Quickshell.Services.Notifications +import QtQuick + +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 alias dnd: props.dnd + + property bool loaded + + onDndChanged: { + if (!Config.utilities.toasts.dndChanged) + return; + + if (dnd) + Toaster.toast(qsTr("Do not disturb enabled"), qsTr("Popup notifications are now disabled"), "do_not_disturb_on"); + else + Toaster.toast(qsTr("Do not disturb disabled"), qsTr("Popup notifications are now enabled"), "do_not_disturb_off"); + } + + onListChanged: { + if (loaded) + saveTimer.restart(); + } + + Timer { + id: saveTimer + + interval: 1000 + onTriggered: storage.setText(JSON.stringify(root.notClosed.map(n => ({ + time: n.time, + id: n.id, + summary: n.summary, + body: n.body, + appIcon: n.appIcon, + appName: n.appName, + image: n.image, + expireTimeout: n.expireTimeout, + urgency: n.urgency, + resident: n.resident, + hasActionIcons: n.hasActionIcons, + actions: n.actions + })))) + } + + PersistentProperties { + id: props + + property bool dnd + + reloadableId: "notifs" + } + + NotificationServer { + id: server + + keepOnReload: false + actionsSupported: true + bodyHyperlinksSupported: true + bodyImagesSupported: true + bodyMarkupSupported: true + imageSupported: true + persistenceSupported: true + + onNotification: notif => { + notif.tracked = true; + + const comp = notifComp.createObject(root, { + popup: !props.dnd && ![...Visibilities.screens.values()].some(v => v.sidebar), + notification: notif + }); + root.list = [comp, ...root.list]; + } + } + + FileView { + id: storage + + path: `${Paths.state}/notifs.json` + onLoaded: { + const data = JSON.parse(text()); + for (const notif of data) + root.list.push(notifComp.createObject(root, notif)); + root.list.sort((a, b) => b.time - a.time); + root.loaded = true; + } + onLoadFailed: err => { + if (err === FileViewError.FileNotFound) { + root.loaded = true; + setText("[]"); + } + } + } + + CustomShortcut { + name: "clearNotifs" + description: "Clear all notifications" + onPressed: { + for (const notif of root.list.slice()) + notif.close(); + } + } + + IpcHandler { + target: "notifs" + + function clear(): void { + for (const notif of root.list.slice()) + notif.close(); + } + + function isDndEnabled(): bool { + return props.dnd; + } + + function toggleDnd(): void { + props.dnd = !props.dnd; + } + + function enableDnd(): void { + props.dnd = true; + } + + 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() + })); + } + } + + Component { + id: notifComp + + Notif {} + } +} diff --git a/.config/quickshell/caelestia/services/Players.qml b/.config/quickshell/caelestia/services/Players.qml new file mode 100644 index 0000000..1191696 --- /dev/null +++ b/.config/quickshell/caelestia/services/Players.qml @@ -0,0 +1,126 @@ +pragma Singleton + +import qs.components.misc +import qs.config +import Quickshell +import Quickshell.Io +import Quickshell.Services.Mpris +import QtQml +import Caelestia + +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 + property alias manualActive: props.manualActive + + function getIdentity(player: MprisPlayer): string { + const alias = Config.services.playerAliases.find(a => a.from === player.identity); + return alias?.to ?? player.identity; + } + + Connections { + target: active + + function onPostTrackChanged() { + if (!Config.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"); + } + } + } + + PersistentProperties { + id: props + + property MprisPlayer manualActive + + reloadableId: "players" + } + + CustomShortcut { + name: "mediaToggle" + description: "Toggle media playback" + onPressed: { + const active = root.active; + if (active && active.canTogglePlaying) + active.togglePlaying(); + } + } + + CustomShortcut { + name: "mediaPrev" + description: "Previous track" + onPressed: { + const active = root.active; + if (active && active.canGoPrevious) + active.previous(); + } + } + + CustomShortcut { + name: "mediaNext" + description: "Next track" + onPressed: { + const active = root.active; + if (active && active.canGoNext) + active.next(); + } + } + + CustomShortcut { + name: "mediaStop" + description: "Stop media playback" + onPressed: root.active?.stop() + } + + IpcHandler { + target: "mpris" + + function getActive(prop: string): string { + const active = root.active; + return active ? active[prop] ?? "Invalid property" : "No active player"; + } + + function list(): string { + return root.list.map(p => root.getIdentity(p)).join("\n"); + } + + function play(): void { + const active = root.active; + if (active?.canPlay) + active.play(); + } + + function pause(): void { + const active = root.active; + if (active?.canPause) + active.pause(); + } + + function playPause(): void { + const active = root.active; + if (active?.canTogglePlaying) + active.togglePlaying(); + } + + function previous(): void { + const active = root.active; + if (active?.canGoPrevious) + active.previous(); + } + + function next(): void { + const active = root.active; + if (active?.canGoNext) + active.next(); + } + + function stop(): void { + root.active?.stop(); + } + } +} diff --git a/.config/quickshell/caelestia/services/Recorder.qml b/.config/quickshell/caelestia/services/Recorder.qml new file mode 100644 index 0000000..6eddce9 --- /dev/null +++ b/.config/quickshell/caelestia/services/Recorder.qml @@ -0,0 +1,82 @@ +pragma Singleton + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + readonly property alias running: props.running + 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; + } + + function stop(): void { + needsStop = true; + checkProc.running = true; + } + + function togglePause(): void { + needsPause = true; + checkProc.running = true; + } + + PersistentProperties { + id: props + + property bool running: false + property bool paused: false + property real elapsed: 0 // Might get too large for int + + reloadableId: "recorder" + } + + Process { + id: checkProc + + running: true + command: ["pidof", "gpu-screen-recorder"] + onExited: code => { + props.running = 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]); + props.running = true; + props.paused = false; + props.elapsed = 0; + } + + root.needsStart = false; + root.needsStop = false; + root.needsPause = false; + } + } + + Connections { + target: Time + // enabled: props.running && !props.paused + + function onSecondsChanged(): void { + props.elapsed++; + } + } +} diff --git a/.config/quickshell/caelestia/services/SystemUsage.qml b/.config/quickshell/caelestia/services/SystemUsage.qml new file mode 100644 index 0000000..ce62017 --- /dev/null +++ b/.config/quickshell/caelestia/services/SystemUsage.qml @@ -0,0 +1,342 @@ +pragma Singleton + +import qs.config +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + // CPU properties + property string cpuName: "" + property real cpuPerc + property real cpuTemp + + // GPU properties + readonly property string gpuType: Config.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 + + // 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\)/gi, "").replace(/\(TM\)/gi, "").replace(/CPU/gi, "").replace(/\d+th Gen /gi, "").replace(/\d+nd Gen /gi, "").replace(/\d+rd Gen /gi, "").replace(/\d+st Gen /gi, "").replace(/Core /gi, "").replace(/Processor/gi, "").replace(/\s+/g, " ").trim(); + } + + function cleanGpuName(name: string): string { + return name.replace(/\(R\)/gi, "").replace(/\(TM\)/gi, "").replace(/Graphics/gi, "").replace(/\s+/g, " ").trim(); + } + + function formatKib(kib: real): var { + const mib = 1024; + const gib = 1024 ** 2; + const tib = 1024 ** 3; + + if (kib >= tib) + return { + value: kib / tib, + unit: "TiB" + }; + if (kib >= gib) + return { + value: kib / gib, + unit: "GiB" + }; + if (kib >= mib) + return { + value: kib / mib, + unit: "MiB" + }; + return { + value: kib, + unit: "KiB" + }; + } + + Timer { + running: root.refCount > 0 + interval: Config.dashboard.resourceUpdateInterval + repeat: true + triggeredOnStart: true + onTriggered: { + stat.reload(); + meminfo.reload(); + storage.running = true; + gpuUsage.running = true; + sensors.running = true; + } + } + + // 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 + + path: "/proc/stat" + onLoaded: { + const data = text().match(/^cpu\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/); + if (data) { + const stats = data.slice(1).map(n => parseInt(n, 10)); + const total = stats.reduce((a, b) => a + b, 0); + const idle = stats[3] + (stats[4] ?? 0); + + const totalDiff = total - root.lastCpuTotal; + const idleDiff = idle - root.lastCpuIdle; + root.cpuPerc = totalDiff > 0 ? (1 - idleDiff / totalDiff) : 0; + + root.lastCpuTotal = total; + root.lastCpuIdle = idle; + } + } + } + + FileView { + id: meminfo + + path: "/proc/meminfo" + onLoaded: { + const data = text(); + root.memTotal = parseInt(data.match(/MemTotal: *(\d+)/)[1], 10) || 1; + root.memUsed = (root.memTotal - parseInt(data.match(/MemAvailable: *(\d+)/)[1], 10)) || 0; + } + } + + Process { + id: storage + + // Get physical disks with aggregated usage from their partitions + // lsblk outputs: NAME SIZE TYPE FSUSED FSSIZE in bytes + command: ["lsblk", "-b", "-o", "NAME,SIZE,TYPE,FSUSED,FSSIZE", "-P"] + stdout: StdioCollector { + onStreamFinished: { + const diskMap = {}; // Map disk name -> { name, totalSize, used, fsTotal } + const lines = text.trim().split("\n"); + + for (const line of lines) { + if (line.trim() === "") + continue; + + // Parse KEY="VALUE" format + const nameMatch = line.match(/NAME="([^"]+)"/); + const sizeMatch = line.match(/SIZE="([^"]+)"/); + const typeMatch = line.match(/TYPE="([^"]+)"/); + const fsusedMatch = line.match(/FSUSED="([^"]*)"/); + const fssizeMatch = line.match(/FSSIZE="([^"]*)"/); + + if (!nameMatch || !typeMatch) + continue; + + const name = nameMatch[1]; + const type = typeMatch[1]; + const size = parseInt(sizeMatch?.[1] || "0", 10); + const fsused = parseInt(fsusedMatch?.[1] || "0", 10); + const fssize = parseInt(fssizeMatch?.[1] || "0", 10); + + if (type === "disk") { + // Skip zram (swap) devices + if (name.startsWith("zram")) + continue; + + // Initialize disk entry + if (!diskMap[name]) { + diskMap[name] = { + name: name, + totalSize: size, + used: 0, + fsTotal: 0 + }; + } + } else if (type === "part") { + // Find parent disk (remove trailing numbers/p+numbers) + let parentDisk = name.replace(/p?\d+$/, ""); + // For nvme devices like nvme0n1p1, parent is nvme0n1 + if (name.match(/nvme\d+n\d+p\d+/)) + parentDisk = name.replace(/p\d+$/, ""); + + // Aggregate partition usage to parent disk + if (diskMap[parentDisk]) { + diskMap[parentDisk].used += fsused; + diskMap[parentDisk].fsTotal += fssize; + } + } + } + + // Convert map to sorted array + const diskList = []; + let totalUsed = 0; + let totalSize = 0; + + for (const diskName of Object.keys(diskMap).sort()) { + const disk = diskMap[diskName]; + // Use filesystem total if available, otherwise use disk size + const total = disk.fsTotal > 0 ? disk.fsTotal : disk.totalSize; + const used = disk.used; + const perc = total > 0 ? used / total : 0; + + // Convert bytes to KiB for consistency with formatKib + diskList.push({ + mount: disk.name // Using 'mount' property for compatibility + , + used: used / 1024, + total: total / 1024, + free: (total - used) / 1024, + perc: perc + }); + + totalUsed += used; + totalSize += total; + } + + root.disks = diskList; + } + } + } + + // GPU name detection (one-time) + Process { + id: gpuNameDetect + + 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; + + // 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]); + } + } + } + } + } + + Process { + id: gpuTypeCheck + + running: !Config.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() + } + } + + Process { + id: gpuUsage + + command: root.gpuType === "GENERIC" ? ["sh", "-c", "cat /sys/class/drm/card*/device/gpu_busy_percent"] : root.gpuType === "NVIDIA" ? ["nvidia-smi", "--query-gpu=utilization.gpu,temperature.gpu", "--format=csv,noheader,nounits"] : ["echo"] + stdout: StdioCollector { + onStreamFinished: { + if (root.gpuType === "GENERIC") { + const percs = text.trim().split("\n"); + const sum = percs.reduce((acc, d) => acc + parseInt(d, 10), 0); + root.gpuPerc = sum / percs.length / 100; + } else if (root.gpuType === "NVIDIA") { + const [usage, temp] = text.trim().split(","); + root.gpuPerc = parseInt(usage, 10) / 100; + root.gpuTemp = parseInt(temp, 10); + } else { + root.gpuPerc = 0; + root.gpuTemp = 0; + } + } + } + } + + Process { + id: sensors + + command: ["sensors"] + environment: ({ + LANG: "C.UTF-8", + LC_ALL: "C.UTF-8" + }) + stdout: StdioCollector { + onStreamFinished: { + let cpuTemp = text.match(/(?:Package id [0-9]+|Tdie):\s+((\+|-)[0-9.]+)(°| )C/); + if (!cpuTemp) + // If AMD Tdie pattern failed, try fallback on Tctl + cpuTemp = text.match(/Tctl:\s+((\+|-)[0-9.]+)(°| )C/); + + if (cpuTemp) + root.cpuTemp = parseFloat(cpuTemp[1]); + + if (root.gpuType !== "GENERIC") + return; + + let eligible = false; + let sum = 0; + let count = 0; + + for (const line of text.trim().split("\n")) { + if (line === "Adapter: PCI adapter") + eligible = true; + else if (line === "") + eligible = false; + else if (eligible) { + let match = line.match(/^(temp[0-9]+|GPU core|edge)+:\s+\+([0-9]+\.[0-9]+)(°| )C/); + if (!match) + // Fall back to junction/mem if GPU doesn't have edge temp (for AMD GPUs) + match = line.match(/^(junction|mem)+:\s+\+([0-9]+\.[0-9]+)(°| )C/); + + if (match) { + sum += parseFloat(match[2]); + count++; + } + } + } + + root.gpuTemp = count > 0 ? sum / count : 0; + } + } + } +} diff --git a/.config/quickshell/caelestia/services/Time.qml b/.config/quickshell/caelestia/services/Time.qml new file mode 100644 index 0000000..a07d9ef --- /dev/null +++ b/.config/quickshell/caelestia/services/Time.qml @@ -0,0 +1,28 @@ +pragma Singleton + +import qs.config +import Quickshell +import QtQuick + +Singleton { + property alias enabled: clock.enabled + readonly property date date: clock.date + readonly property int hours: clock.hours + readonly property int minutes: clock.minutes + readonly property int seconds: clock.seconds + + readonly property string timeStr: format(Config.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] ?? "" + readonly property string amPmStr: timeComponents[2] ?? "" + + function format(fmt: string): string { + return Qt.formatDateTime(clock.date, fmt); + } + + SystemClock { + id: clock + precision: SystemClock.Seconds + } +} diff --git a/.config/quickshell/caelestia/services/VPN.qml b/.config/quickshell/caelestia/services/VPN.qml new file mode 100644 index 0000000..2d08631 --- /dev/null +++ b/.config/quickshell/caelestia/services/VPN.qml @@ -0,0 +1,179 @@ +pragma Singleton + +import Quickshell +import Quickshell.Io +import QtQuick +import qs.config +import Caelestia + +Singleton { + id: root + + property bool connected: false + + 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 var providerInput: { + const enabledProvider = Config.utilities.vpn.provider.find(p => typeof p === "object" ? (p.enabled === true) : false); + return enabledProvider || "wireguard"; + } + readonly property bool isCustomProvider: typeof providerInput === "object" + readonly property string providerName: isCustomProvider ? (providerInput.name || "custom") : String(providerInput) + readonly property string interfaceName: isCustomProvider ? (providerInput.interface || "") : "" + readonly property var currentConfig: { + const name = providerName; + const iface = interfaceName; + const defaults = getBuiltinDefaults(name, iface); + + if (isCustomProvider) { + const custom = providerInput; + return { + connectCmd: custom.connectCmd || defaults.connectCmd, + disconnectCmd: custom.disconnectCmd || defaults.disconnectCmd, + interface: custom.interface || defaults.interface, + displayName: custom.displayName || defaults.displayName + }; + } + + return defaults; + } + + function getBuiltinDefaults(name, iface) { + const builtins = { + "wireguard": { + connectCmd: ["pkexec", "wg-quick", "up", iface], + disconnectCmd: ["pkexec", "wg-quick", "down", iface], + interface: iface, + displayName: iface + }, + "warp": { + connectCmd: ["warp-cli", "connect"], + disconnectCmd: ["warp-cli", "disconnect"], + interface: "CloudflareWARP", + displayName: "Warp" + }, + "netbird": { + connectCmd: ["netbird", "up"], + disconnectCmd: ["netbird", "down"], + interface: "wt0", + displayName: "NetBird" + }, + "tailscale": { + connectCmd: ["tailscale", "up"], + disconnectCmd: ["tailscale", "down"], + interface: "tailscale0", + displayName: "Tailscale" + } + }; + + return builtins[name] || { + connectCmd: [name, "up"], + disconnectCmd: [name, "down"], + interface: iface || name, + displayName: name + }; + } + + function connect(): void { + if (!connected && !connecting && root.currentConfig && root.currentConfig.connectCmd) { + connectProc.exec(root.currentConfig.connectCmd); + } + } + + function disconnect(): void { + if (connected && !connecting && root.currentConfig && root.currentConfig.disconnectCmd) { + disconnectProc.exec(root.currentConfig.disconnectCmd); + } + } + + function toggle(): void { + if (connected) { + disconnect(); + } else { + connect(); + } + } + + function checkStatus(): void { + if (root.enabled) { + statusProc.running = true; + } + } + + onConnectedChanged: { + if (!Config.utilities.toasts.vpnChanged) + return; + + const displayName = root.currentConfig ? (root.currentConfig.displayName || "VPN") : "VPN"; + if (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"); + } + } + + Component.onCompleted: root.enabled && statusCheckTimer.start() + + Process { + id: nmMonitor + + running: root.enabled + command: ["nmcli", "monitor"] + stdout: SplitParser { + onRead: statusCheckTimer.restart() + } + } + + Process { + id: statusProc + + command: ["ip", "link", "show"] + environment: ({ + 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 + ":"); + } + } + } + + Process { + id: connectProc + + onExited: statusCheckTimer.start() + stderr: StdioCollector { + onStreamFinished: { + const error = text.trim(); + if (error && !error.includes("[#]") && !error.includes("already exists")) { + console.warn("VPN connection error:", error); + } else if (error.includes("already exists")) { + root.connected = true; + } + } + } + } + + Process { + id: disconnectProc + + onExited: statusCheckTimer.start() + stderr: StdioCollector { + onStreamFinished: { + const error = text.trim(); + if (error && !error.includes("[#]")) { + console.warn("VPN disconnection error:", error); + } + } + } + } + + Timer { + id: statusCheckTimer + + interval: 500 + onTriggered: root.checkStatus() + } +} diff --git a/.config/quickshell/caelestia/services/Visibilities.qml b/.config/quickshell/caelestia/services/Visibilities.qml new file mode 100644 index 0000000..5ddde0c --- /dev/null +++ b/.config/quickshell/caelestia/services/Visibilities.qml @@ -0,0 +1,16 @@ +pragma Singleton + +import Quickshell + +Singleton { + property var screens: new Map() + property var bars: new Map() + + function load(screen: ShellScreen, visibilities: var): void { + screens.set(Hypr.monitorFor(screen), visibilities); + } + + function getForActive(): PersistentProperties { + return screens.get(Hypr.focusedMonitor); + } +} diff --git a/.config/quickshell/caelestia/services/Wallpapers.qml b/.config/quickshell/caelestia/services/Wallpapers.qml new file mode 100644 index 0000000..cb96bc5 --- /dev/null +++ b/.config/quickshell/caelestia/services/Wallpapers.qml @@ -0,0 +1,93 @@ +pragma Singleton + +import qs.config +import qs.utils +import Caelestia.Models +import Quickshell +import Quickshell.Io +import QtQuick + +Searcher { + id: root + + readonly property string currentNamePath: `${Paths.state}/wallpaper/path.txt` + readonly property list smartArg: Config.services.smartScheme ? [] : ["--no-smart"] + + property bool showPreview: false + readonly property string current: showPreview ? previewPath : actualCurrent + property string previewPath + property string actualCurrent + property bool previewColourLock + + function setWallpaper(path: string): void { + actualCurrent = path; + Quickshell.execDetached(["caelestia", "wallpaper", "-f", path, ...smartArg]); + } + + function preview(path: string): void { + previewPath = path; + showPreview = true; + + if (Colours.scheme === "dynamic") + getPreviewColoursProc.running = true; + } + + function stopPreview(): void { + showPreview = false; + if (!previewColourLock) + Colours.showPreview = false; + } + + list: wallpapers.entries + key: "relativePath" + useFuzzy: Config.launcher.useFuzzy.wallpapers + extraOpts: useFuzzy ? ({}) : ({ + forward: false + }) + + IpcHandler { + target: "wallpaper" + + function get(): string { + return root.actualCurrent; + } + + function set(path: string): void { + root.setWallpaper(path); + } + + function list(): string { + return root.list.map(w => w.path).join("\n"); + } + } + + FileView { + path: root.currentNamePath + watchChanges: true + onFileChanged: reload() + onLoaded: { + root.actualCurrent = text().trim(); + root.previewColourLock = false; + } + } + + FileSystemModel { + id: wallpapers + + recursive: true + path: Paths.wallsdir + filter: FileSystemModel.Images + } + + Process { + id: getPreviewColoursProc + + command: ["caelestia", "wallpaper", "-p", root.previewPath, ...root.smartArg] + stdout: StdioCollector { + onStreamFinished: { + Colours.load(text, true); + Colours.showPreview = true; + } + } + } +} diff --git a/.config/quickshell/caelestia/services/Weather.qml b/.config/quickshell/caelestia/services/Weather.qml new file mode 100644 index 0000000..a309542 --- /dev/null +++ b/.config/quickshell/caelestia/services/Weather.qml @@ -0,0 +1,206 @@ +pragma Singleton + +import qs.config +import qs.utils +import Caelestia +import Quickshell +import QtQuick + +Singleton { + id: root + + property string city + property string loc + property var cc + property list forecast + property list hourlyForecast + + 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 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 var cachedCities: new Map() + + function reload(): void { + const configLocation = Config.services.weatherLocation; + + if (configLocation) { + if (configLocation.indexOf(",") !== -1 && !isNaN(parseFloat(configLocation.split(",")[0]))) { + loc = configLocation; + fetchCityFromCoords(configLocation); + } else { + fetchCoordsFromCity(configLocation); + } + } else if (!loc || timer.elapsed() > 900) { + Requests.get("https://ipinfo.io/json", text => { + const response = JSON.parse(text); + if (response.loc) { + loc = response.loc; + city = response.city ?? ""; + timer.restart(); + } + }); + } + } + + function fetchCityFromCoords(coords: string): void { + if (cachedCities.has(coords)) { + city = cachedCities.get(coords); + return; + } + + const [lat, lon] = coords.split(","); + const url = `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=geocodejson`; + Requests.get(url, 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"; + } + }); + } + + function fetchCoordsFromCity(cityName: string): void { + const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(cityName)}&count=1&language=en&format=json`; + + Requests.get(url, text => { + const json = JSON.parse(text); + if (json.results && json.results.length > 0) { + const result = json.results[0]; + loc = result.latitude + "," + result.longitude; + city = result.name; + } else { + loc = ""; + reload(); + } + }); + } + + function fetchWeatherData(): void { + const url = getWeatherUrl(); + if (url === "") + return; + + Requests.get(url, text => { + const json = JSON.parse(text); + if (!json.current || !json.daily) + return; + + cc = { + weatherCode: json.current.weather_code, + weatherDesc: getWeatherCondition(json.current.weather_code), + tempC: Math.round(json.current.temperature_2m), + tempF: Math.round(toFahrenheit(json.current.temperature_2m)), + feelsLikeC: Math.round(json.current.apparent_temperature), + feelsLikeF: Math.round(toFahrenheit(json.current.apparent_temperature)), + 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] + }; + + const forecastList = []; + for (let i = 0; i < json.daily.time.length; i++) + forecastList.push({ + date: json.daily.time[i], + 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]), + minTempF: Math.round(toFahrenheit(json.daily.temperature_2m_min[i])), + weatherCode: json.daily.weather_code[i], + icon: Icons.getWeatherIcon(json.daily.weather_code[i]) + }); + forecast = forecastList; + + const hourlyList = []; + const now = new Date(); + for (let i = 0; i < json.hourly.time.length; i++) { + const time = new Date(json.hourly.time[i]); + if (time < now) + continue; + + hourlyList.push({ + timestamp: json.hourly.time[i], + hour: time.getHours(), + tempC: Math.round(json.hourly.temperature_2m[i]), + tempF: Math.round(toFahrenheit(json.hourly.temperature_2m[i])), + weatherCode: json.hourly.weather_code[i], + icon: Icons.getWeatherIcon(json.hourly.weather_code[i]) + }); + } + hourlyForecast = hourlyList; + }); + } + + function toFahrenheit(celcius: real): real { + return celcius * 9 / 5 + 32; + } + + function getWeatherUrl(): string { + if (!loc || loc.indexOf(",") === -1) + return ""; + + const [lat, lon] = loc.split(","); + 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"]; + + return baseUrl + "?" + params.join("&"); + } + + function getWeatherCondition(code: string): string { + const conditions = { + "0": "Clear", + "1": "Clear", + "2": "Partly cloudy", + "3": "Overcast", + "45": "Fog", + "48": "Fog", + "51": "Drizzle", + "53": "Drizzle", + "55": "Drizzle", + "56": "Freezing drizzle", + "57": "Freezing drizzle", + "61": "Light rain", + "63": "Rain", + "65": "Heavy rain", + "66": "Light rain", + "67": "Heavy rain", + "71": "Light snow", + "73": "Snow", + "75": "Heavy snow", + "77": "Snow", + "80": "Light rain", + "81": "Rain", + "82": "Heavy rain", + "85": "Light snow showers", + "86": "Heavy snow showers", + "95": "Thunderstorm", + "96": "Thunderstorm with hail", + "99": "Thunderstorm with hail" + }; + return conditions[code] || "Unknown"; + } + + onLocChanged: fetchWeatherData() + + // Refresh current location hourly + Timer { + interval: 3600000 // 1 hour + running: true + repeat: true + onTriggered: fetchWeatherData() + } + + ElapsedTimer { + id: timer + } +} diff --git a/.config/quickshell/caelestia/shell.qml b/.config/quickshell/caelestia/shell.qml new file mode 100644 index 0000000..3ce7776 --- /dev/null +++ b/.config/quickshell/caelestia/shell.qml @@ -0,0 +1,25 @@ +//@ pragma Env QS_NO_RELOAD_POPUP=1 +//@ pragma Env QSG_RENDER_LOOP=threaded +//@ pragma Env QT_QUICK_FLICKABLE_WHEEL_DECELERATION=10000 + +import "modules" +import "modules/drawers" +import "modules/background" +import "modules/areapicker" +import "modules/lock" +import Quickshell + +ShellRoot { + Background {} + Drawers {} + AreaPicker {} + Lock { + id: lock + } + + Shortcuts {} + BatteryMonitor {} + IdleMonitors { + lock: lock + } +} diff --git a/.config/quickshell/caelestia/utils/Icons.qml b/.config/quickshell/caelestia/utils/Icons.qml new file mode 100644 index 0000000..c06cbf8 --- /dev/null +++ b/.config/quickshell/caelestia/utils/Icons.qml @@ -0,0 +1,221 @@ +pragma Singleton + +import qs.config +import Quickshell +import Quickshell.Services.Notifications +import QtQuick + +Singleton { + id: root + + readonly property var weatherIcons: ({ + "0": "clear_day", + "1": "clear_day", + "2": "partly_cloudy_day", + "3": "cloud", + "45": "foggy", + "48": "foggy", + "51": "rainy", + "53": "rainy", + "55": "rainy", + "56": "rainy", + "57": "rainy", + "61": "rainy", + "63": "rainy", + "65": "rainy", + "66": "rainy", + "67": "rainy", + "71": "cloudy_snowing", + "73": "cloudy_snowing", + "75": "snowing_heavy", + "77": "cloudy_snowing", + "80": "rainy", + "81": "rainy", + "82": "rainy", + "85": "cloudy_snowing", + "86": "snowing_heavy", + "95": "thunderstorm", + "96": "thunderstorm", + "99": "thunderstorm" + }) + + readonly property var categoryIcons: ({ + WebBrowser: "web", + Printing: "print", + Security: "security", + Network: "chat", + Archiving: "archive", + Compression: "archive", + Development: "code", + IDE: "code", + TextEditor: "edit_note", + Audio: "music_note", + Music: "music_note", + Player: "music_note", + Recorder: "mic", + Game: "sports_esports", + FileTools: "files", + FileManager: "files", + Filesystem: "files", + FileTransfer: "files", + Settings: "settings", + DesktopSettings: "settings", + HardwareSettings: "settings", + TerminalEmulator: "terminal", + ConsoleOnly: "terminal", + Utility: "build", + Monitor: "monitor_heart", + Midi: "graphic_eq", + Mixer: "graphic_eq", + AudioVideoEditing: "video_settings", + AudioVideo: "music_video", + Video: "videocam", + Building: "construction", + Graphics: "photo_library", + "2DGraphics": "photo_library", + RasterGraphics: "photo_library", + TV: "tv", + System: "host", + Office: "content_paste" + }) + + function getAppIcon(name: string, fallback: string): string { + const icon = DesktopEntries.heuristicLookup(name)?.icon; + if (fallback !== "undefined") + return Quickshell.iconPath(icon, fallback); + return Quickshell.iconPath(icon); + } + + function getAppCategoryIcon(name: string, fallback: string): string { + const categories = DesktopEntries.heuristicLookup(name)?.categories; + + if (categories) + for (const [key, value] of Object.entries(categoryIcons)) + if (categories.includes(key)) + return value; + return fallback; + } + + function getNetworkIcon(strength: int, isSecure = false): string { + if (isSecure) { + if (strength >= 80) + return "network_wifi_locked"; + if (strength >= 60) + return "network_wifi_3_bar_locked"; + if (strength >= 40) + return "network_wifi_2_bar_locked"; + if (strength >= 20) + return "network_wifi_1_bar_locked"; + return "signal_wifi_0_bar"; + } else { + if (strength >= 80) + return "network_wifi"; + if (strength >= 60) + return "network_wifi_3_bar"; + if (strength >= 40) + return "network_wifi_2_bar"; + if (strength >= 20) + return "network_wifi_1_bar"; + return "signal_wifi_0_bar"; + } + } + + function getBluetoothIcon(icon: string): string { + if (icon.includes("headset") || icon.includes("headphones")) + return "headphones"; + if (icon.includes("audio")) + return "speaker"; + if (icon.includes("phone")) + return "smartphone"; + if (icon.includes("mouse")) + return "mouse"; + if (icon.includes("keyboard")) + return "keyboard"; + return "bluetooth"; + } + + function getWeatherIcon(code: string): string { + if (weatherIcons.hasOwnProperty(code)) + return weatherIcons[code]; + return "air"; + } + + function getNotifIcon(summary: string, urgency: int): string { + summary = summary.toLowerCase(); + if (summary.includes("reboot")) + return "restart_alt"; + if (summary.includes("recording")) + return "screen_record"; + if (summary.includes("battery")) + return "power"; + if (summary.includes("screenshot")) + return "screenshot_monitor"; + if (summary.includes("welcome")) + return "waving_hand"; + if (summary.includes("time") || summary.includes("a break")) + return "schedule"; + if (summary.includes("installed")) + return "download"; + if (summary.includes("update")) + return "update"; + if (summary.includes("unable to")) + return "deployed_code_alert"; + if (summary.includes("profile")) + return "person"; + if (summary.includes("file")) + return "folder_copy"; + if (urgency === NotificationUrgency.Critical) + return "release_alert"; + return "chat"; + } + + function getVolumeIcon(volume: real, isMuted: bool): string { + if (isMuted) + return "no_sound"; + if (volume >= 0.5) + return "volume_up"; + if (volume > 0) + return "volume_down"; + return "volume_mute"; + } + + function getMicVolumeIcon(volume: real, isMuted: bool): string { + if (!isMuted && volume > 0) + return "mic"; + return "mic_off"; + } + + function getSpecialWsIcon(name: string): string { + name = name.toLowerCase().slice("special:".length); + + for (const iconConfig of Config.bar.workspaces.specialWorkspaceIcons) { + if (iconConfig.name === name) { + return iconConfig.icon; + } + } + + if (name === "special") + return "star"; + if (name === "communication") + return "forum"; + if (name === "music") + return "music_cast"; + if (name === "todo") + return "checklist"; + if (name === "sysmon") + return "monitor_heart"; + return name[0].toUpperCase(); + } + + function getTrayIcon(id: string, icon: string): string { + for (const sub of Config.bar.tray.iconSubs) + if (sub.id === id) + return sub.image ? Qt.resolvedUrl(sub.image) : Quickshell.iconPath(sub.icon); + + if (icon.includes("?path=")) { + const [name, path] = icon.split("?path="); + icon = Qt.resolvedUrl(`${path}/${name.slice(name.lastIndexOf("/") + 1)}`); + } + return icon; + } +} diff --git a/.config/quickshell/caelestia/utils/Images.qml b/.config/quickshell/caelestia/utils/Images.qml new file mode 100644 index 0000000..ac76f51 --- /dev/null +++ b/.config/quickshell/caelestia/utils/Images.qml @@ -0,0 +1,12 @@ +pragma Singleton + +import Quickshell + +Singleton { + readonly property list validImageTypes: ["jpeg", "png", "webp", "tiff", "svg"] + readonly property list validImageExtensions: ["jpg", "jpeg", "png", "webp", "tif", "tiff", "svg"] + + function isValidImageByName(name: string): bool { + return validImageExtensions.some(t => name.endsWith(`.${t}`)); + } +} diff --git a/.config/quickshell/caelestia/utils/NetworkConnection.qml b/.config/quickshell/caelestia/utils/NetworkConnection.qml new file mode 100644 index 0000000..e55b87b --- /dev/null +++ b/.config/quickshell/caelestia/utils/NetworkConnection.qml @@ -0,0 +1,116 @@ +pragma Singleton + +import qs.services +import QtQuick + +/** + * NetworkConnection + * + * Centralized utility for network connection logic. Provides a single source of truth + * for connecting to wireless networks, eliminating code duplication across + * controlcenter components and bar popouts. + * + * Usage: + * ```qml + * import qs.utils + * + * // With Session object (controlcenter) + * NetworkConnection.handleConnect(network, session); + * + * // Without Session object (bar popouts) - provide password dialog callback + * NetworkConnection.handleConnect(network, null, (network) => { + * // Show password dialog + * root.passwordNetwork = network; + * root.showPasswordDialog = true; + * }); + * ``` + */ +QtObject { + id: root + + /** + * Handle network connection with automatic disconnection if needed. + * If there's an active network different from the target, disconnects first, + * then connects to the target network. + * + * @param network The network object to connect to (must have ssid property) + * @param session Optional Session object (for controlcenter - must have network property with showPasswordDialog and pendingNetwork) + * @param onPasswordNeeded Optional callback function(network) called when password is needed (for bar popouts) + */ + function handleConnect(network, session, onPasswordNeeded): void { + if (!network) { + return; + } + + if (Nmcli.active && Nmcli.active.ssid !== network.ssid) { + Nmcli.disconnectFromNetwork(); + Qt.callLater(() => { + root.connectToNetwork(network, session, onPasswordNeeded); + }); + } else { + root.connectToNetwork(network, session, onPasswordNeeded); + } + } + + /** + * Connect to a wireless network. + * Handles both secured and open networks, checks for saved profiles, + * and shows password dialog if needed. + * + * @param network The network object to connect to (must have ssid, isSecure, bssid properties) + * @param session Optional Session object (for controlcenter - must have network property with showPasswordDialog and pendingNetwork) + * @param onPasswordNeeded Optional callback function(network) called when password is needed (for bar popouts) + */ + function connectToNetwork(network, session, onPasswordNeeded): void { + if (!network) { + return; + } + + if (network.isSecure) { + const hasSavedProfile = Nmcli.hasSavedProfile(network.ssid); + + if (hasSavedProfile) { + Nmcli.connectToNetwork(network.ssid, "", network.bssid, null); + } else { + // Use password check with callback + Nmcli.connectToNetworkWithPasswordCheck(network.ssid, network.isSecure, result => { + if (result.needsPassword) { + // Clear pending connection if exists + if (Nmcli.pendingConnection) { + Nmcli.connectionCheckTimer.stop(); + Nmcli.immediateCheckTimer.stop(); + Nmcli.immediateCheckTimer.checkCount = 0; + Nmcli.pendingConnection = null; + } + + // Handle password dialog - use session if available, otherwise use callback + if (session && session.network) { + session.network.showPasswordDialog = true; + session.network.pendingNetwork = network; + } else if (onPasswordNeeded) { + onPasswordNeeded(network); + } + } + }, network.bssid); + } + } else { + Nmcli.connectToNetwork(network.ssid, "", network.bssid, null); + } + } + + /** + * Connect to a wireless network with a provided password. + * Used by password dialogs when the user has already entered a password. + * + * @param network The network object to connect to (must have ssid, bssid properties) + * @param password The password to use for connection + * @param onResult Optional callback function(result) called with connection result + */ + function connectWithPassword(network, password, onResult): void { + if (!network) { + return; + } + + Nmcli.connectToNetwork(network.ssid, password || "", network.bssid || "", onResult || null); + } +} diff --git a/.config/quickshell/caelestia/utils/Paths.qml b/.config/quickshell/caelestia/utils/Paths.qml new file mode 100644 index 0000000..bc89770 --- /dev/null +++ b/.config/quickshell/caelestia/utils/Paths.qml @@ -0,0 +1,37 @@ +pragma Singleton + +import qs.config +import Caelestia +import Quickshell + +Singleton { + id: root + + readonly property string home: Quickshell.env("HOME") + readonly property string pictures: Quickshell.env("XDG_PICTURES_DIR") || `${home}/Pictures` + readonly property string videos: Quickshell.env("XDG_VIDEOS_DIR") || `${home}/Videos` + + readonly property string data: `${Quickshell.env("XDG_DATA_HOME") || `${home}/.local/share`}/caelestia` + readonly property string state: `${Quickshell.env("XDG_STATE_HOME") || `${home}/.local/state`}/caelestia` + readonly property string cache: `${Quickshell.env("XDG_CACHE_HOME") || `${home}/.cache`}/caelestia` + readonly property string config: `${Quickshell.env("XDG_CONFIG_HOME") || `${home}/.config`}/caelestia` + + 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 recsdir: Quickshell.env("CAELESTIA_RECORDINGS_DIR") || `${videos}/Recordings` + readonly property string libdir: Quickshell.env("CAELESTIA_LIB_DIR") || "/usr/lib/caelestia" + + function toLocalFile(path: url): string { + path = Qt.resolvedUrl(path); + return path.toString() ? CUtils.toLocalFile(path) : ""; + } + + function absolutePath(path: string): string { + return toLocalFile(path.replace(/~|(\$({?)HOME(}?))+/, home)); + } + + function shortenHome(path: string): string { + return path.replace(home, "~"); + } +} diff --git a/.config/quickshell/caelestia/utils/Searcher.qml b/.config/quickshell/caelestia/utils/Searcher.qml new file mode 100644 index 0000000..053b73b --- /dev/null +++ b/.config/quickshell/caelestia/utils/Searcher.qml @@ -0,0 +1,56 @@ +import Quickshell + +import "scripts/fzf.js" as Fzf +import "scripts/fuzzysort.js" as Fuzzy +import QtQuick + +Singleton { + required property list list + property string key: "name" + property bool useFuzzy: false + property var extraOpts: ({}) + + // Extra stuff for fuzzy + property list keys: [key] + property list weights: [1] + + readonly property var fzf: useFuzzy ? [] : new Fzf.Finder(list, Object.assign({ + selector + }, extraOpts)) + readonly property list fuzzyPrepped: useFuzzy ? list.map(e => { + const obj = { + _item: e + }; + for (const k of keys) + obj[k] = Fuzzy.prepare(e[k]); + return obj; + }) : [] + + function transformSearch(search: string): string { + return search; + } + + function selector(item: var): string { + // Only for fzf + return item[key]; + } + + function query(search: string): list { + search = transformSearch(search); + if (!search) + return [...list]; + + if (useFuzzy) + return Fuzzy.go(search, fuzzyPrepped, Object.assign({ + all: true, + keys, + scoreFn: r => weights.reduce((a, w, i) => a + r[i].score * w, 0) + }, extraOpts)).map(r => r.obj._item); + + return fzf.find(search).sort((a, b) => { + if (a.score === b.score) + return selector(a.item).trim().length - selector(b.item).trim().length; + return b.score - a.score; + }).map(r => r.item); + } +} diff --git a/.config/quickshell/caelestia/utils/Strings.qml b/.config/quickshell/caelestia/utils/Strings.qml new file mode 100644 index 0000000..1d0cc76 --- /dev/null +++ b/.config/quickshell/caelestia/utils/Strings.qml @@ -0,0 +1,20 @@ +pragma Singleton + +import Quickshell + +Singleton { + function testRegexList(filterList: list, target: string): bool { + const regexChecker = /^\^.*\$$/; + for (const filter of filterList) { + // If filter is a regex + if (regexChecker.test(filter)) { + if ((new RegExp(filter)).test(target)) + return true; + } else { + if (filter === target) + return true; + } + } + return false; + } +} diff --git a/.config/quickshell/caelestia/utils/SysInfo.qml b/.config/quickshell/caelestia/utils/SysInfo.qml new file mode 100644 index 0000000..19aa4a7 --- /dev/null +++ b/.config/quickshell/caelestia/utils/SysInfo.qml @@ -0,0 +1,88 @@ +pragma Singleton + +import qs.config +import qs.utils +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + property string osName + property string osPrettyName + property string osId + property list osIdLike + property string osLogo: Qt.resolvedUrl(`${Quickshell.shellDir}/assets/logo.svg`) + property bool isDefaultLogo: true + + property string uptime + readonly property string user: Quickshell.env("USER") + readonly property string wm: Quickshell.env("XDG_CURRENT_DESKTOP") || Quickshell.env("XDG_SESSION_DESKTOP") + readonly property string shell: Quickshell.env("SHELL").split("/").pop() + + FileView { + id: osRelease + + path: "/etc/os-release" + onLoaded: { + const lines = text().split("\n"); + + const fd = key => lines.find(l => l.startsWith(`${key}=`))?.split("=")[1].replace(/"/g, "") ?? ""; + + root.osName = fd("NAME"); + root.osPrettyName = fd("PRETTY_NAME"); + root.osId = fd("ID"); + root.osIdLike = fd("ID_LIKE").split(" "); + + const logo = Quickshell.iconPath(fd("LOGO"), true); + if (Config.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); + root.isDefaultLogo = false; + } else if (logo) { + root.osLogo = logo; + root.isDefaultLogo = false; + } + } + } + + Connections { + target: Config.general + + function onLogoChanged(): void { + osRelease.reload(); + } + } + + Timer { + running: true + repeat: true + interval: 15000 + onTriggered: fileUptime.reload() + } + + FileView { + id: fileUptime + + path: "/proc/uptime" + onLoaded: { + const up = parseInt(text().split(" ")[0] ?? 0); + + const days = Math.floor(up / 86400); + const hours = Math.floor((up % 86400) / 3600); + const minutes = Math.floor((up % 3600) / 60); + + let str = ""; + if (days > 0) + str += `${days} day${days === 1 ? "" : "s"}`; + if (hours > 0) + str += `${str ? ", " : ""}${hours} hour${hours === 1 ? "" : "s"}`; + if (minutes > 0 || !str) + str += `${str ? ", " : ""}${minutes} minute${minutes === 1 ? "" : "s"}`; + root.uptime = str; + } + } +} diff --git a/.config/quickshell/caelestia/utils/scripts/fuzzysort.js b/.config/quickshell/caelestia/utils/scripts/fuzzysort.js new file mode 100644 index 0000000..94308ff --- /dev/null +++ b/.config/quickshell/caelestia/utils/scripts/fuzzysort.js @@ -0,0 +1,705 @@ +.pragma library + +/* +https://github.com/farzher/fuzzysort + +MIT License + +Copyright (c) 2018 Stephen Kamenar + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +var single = (search, target) => { + if(!search || !target) return NULL + + var preparedSearch = getPreparedSearch(search) + if(!isPrepared(target)) target = getPrepared(target) + + var searchBitflags = preparedSearch.bitflags + if((searchBitflags & target._bitflags) !== searchBitflags) return NULL + + return algorithm(preparedSearch, target) +} + +var go = (search, targets, options) => { + if(!search) return options?.all ? all(targets, options) : noResults + + var preparedSearch = getPreparedSearch(search) + var searchBitflags = preparedSearch.bitflags + var containsSpace = preparedSearch.containsSpace + + var threshold = denormalizeScore( options?.threshold || 0 ) + var limit = options?.limit || INFINITY + + var resultsLen = 0; var limitedCount = 0 + var targetsLen = targets.length + + function push_result(result) { + if(resultsLen < limit) { q.add(result); ++resultsLen } + else { + ++limitedCount + if(result._score > q.peek()._score) q.replaceTop(result) + } + } + + // This code is copy/pasted 3 times for performance reasons [options.key, options.keys, no keys] + + // options.key + if(options?.key) { + var key = options.key + for(var i = 0; i < targetsLen; ++i) { var obj = targets[i] + var target = getValue(obj, key) + if(!target) continue + if(!isPrepared(target)) target = getPrepared(target) + + if((searchBitflags & target._bitflags) !== searchBitflags) continue + var result = algorithm(preparedSearch, target) + if(result === NULL) continue + if(result._score < threshold) continue + + result.obj = obj + push_result(result) + } + + // options.keys + } else if(options?.keys) { + var keys = options.keys + var keysLen = keys.length + + outer: for(var i = 0; i < targetsLen; ++i) { var obj = targets[i] + + { // early out based on bitflags + var keysBitflags = 0 + for (var keyI = 0; keyI < keysLen; ++keyI) { + var key = keys[keyI] + var target = getValue(obj, key) + if(!target) { tmpTargets[keyI] = noTarget; continue } + if(!isPrepared(target)) target = getPrepared(target) + tmpTargets[keyI] = target + + keysBitflags |= target._bitflags + } + + if((searchBitflags & keysBitflags) !== searchBitflags) continue + } + + if(containsSpace) for(let i=0; i -1000) { + if(keysSpacesBestScores[i] > NEGATIVE_INFINITY) { + var tmp = (keysSpacesBestScores[i] + allowPartialMatchScores[i]) / 4/*bonus score for having multiple matches*/ + if(tmp > keysSpacesBestScores[i]) keysSpacesBestScores[i] = tmp + } + } + if(allowPartialMatchScores[i] > keysSpacesBestScores[i]) keysSpacesBestScores[i] = allowPartialMatchScores[i] + } + } + + if(containsSpace) { + for(let i=0; i -1000) { + if(score > NEGATIVE_INFINITY) { + var tmp = (score + result._score) / 4/*bonus score for having multiple matches*/ + if(tmp > score) score = tmp + } + } + if(result._score > score) score = result._score + } + } + + objResults.obj = obj + objResults._score = score + if(options?.scoreFn) { + score = options.scoreFn(objResults) + if(!score) continue + score = denormalizeScore(score) + objResults._score = score + } + + if(score < threshold) continue + push_result(objResults) + } + + // no keys + } else { + for(var i = 0; i < targetsLen; ++i) { var target = targets[i] + if(!target) continue + if(!isPrepared(target)) target = getPrepared(target) + + if((searchBitflags & target._bitflags) !== searchBitflags) continue + var result = algorithm(preparedSearch, target) + if(result === NULL) continue + if(result._score < threshold) continue + + push_result(result) + } + } + + if(resultsLen === 0) return noResults + var results = new Array(resultsLen) + for(var i = resultsLen - 1; i >= 0; --i) results[i] = q.poll() + results.total = resultsLen + limitedCount + return results +} + + +// this is written as 1 function instead of 2 for minification. perf seems fine ... +// except when minified. the perf is very slow +var highlight = (result, open='', close='') => { + var callback = typeof open === 'function' ? open : undefined + + var target = result.target + var targetLen = target.length + var indexes = result.indexes + var highlighted = '' + var matchI = 0 + var indexesI = 0 + var opened = false + var parts = [] + + for(var i = 0; i < targetLen; ++i) { var char = target[i] + if(indexes[indexesI] === i) { + ++indexesI + if(!opened) { opened = true + if(callback) { + parts.push(highlighted); highlighted = '' + } else { + highlighted += open + } + } + + if(indexesI === indexes.length) { + if(callback) { + highlighted += char + parts.push(callback(highlighted, matchI++)); highlighted = '' + parts.push(target.substr(i+1)) + } else { + highlighted += char + close + target.substr(i+1) + } + break + } + } else { + if(opened) { opened = false + if(callback) { + parts.push(callback(highlighted, matchI++)); highlighted = '' + } else { + highlighted += close + } + } + } + highlighted += char + } + + return callback ? parts : highlighted +} + + +var prepare = (target) => { + if(typeof target === 'number') target = ''+target + else if(typeof target !== 'string') target = '' + var info = prepareLowerInfo(target) + return new_result(target, {_targetLower:info._lower, _targetLowerCodes:info.lowerCodes, _bitflags:info.bitflags}) +} + +var cleanup = () => { preparedCache.clear(); preparedSearchCache.clear() } + + +// Below this point is only internal code +// Below this point is only internal code +// Below this point is only internal code +// Below this point is only internal code + + +class Result { + get ['indexes']() { return this._indexes.slice(0, this._indexes.len).sort((a,b)=>a-b) } + set ['indexes'](indexes) { return this._indexes = indexes } + ['highlight'](open, close) { return highlight(this, open, close) } + get ['score']() { return normalizeScore(this._score) } + set ['score'](score) { this._score = denormalizeScore(score) } +} + +class KeysResult extends Array { + get ['score']() { return normalizeScore(this._score) } + set ['score'](score) { this._score = denormalizeScore(score) } +} + +var new_result = (target, options) => { + const result = new Result() + result['target'] = target + result['obj'] = options.obj ?? NULL + result._score = options._score ?? NEGATIVE_INFINITY + result._indexes = options._indexes ?? [] + result._targetLower = options._targetLower ?? '' + result._targetLowerCodes = options._targetLowerCodes ?? NULL + result._nextBeginningIndexes = options._nextBeginningIndexes ?? NULL + result._bitflags = options._bitflags ?? 0 + return result +} + + +var normalizeScore = score => { + if(score === NEGATIVE_INFINITY) return 0 + if(score > 1) return score + return Math.E ** ( ((-score + 1)**.04307 - 1) * -2) +} +var denormalizeScore = normalizedScore => { + if(normalizedScore === 0) return NEGATIVE_INFINITY + if(normalizedScore > 1) return normalizedScore + return 1 - Math.pow((Math.log(normalizedScore) / -2 + 1), 1 / 0.04307) +} + + +var prepareSearch = (search) => { + if(typeof search === 'number') search = ''+search + else if(typeof search !== 'string') search = '' + search = search.trim() + var info = prepareLowerInfo(search) + + var spaceSearches = [] + if(info.containsSpace) { + var searches = search.split(/\s+/) + searches = [...new Set(searches)] // distinct + for(var i=0; i { + if(target.length > 999) return prepare(target) // don't cache huge targets + var targetPrepared = preparedCache.get(target) + if(targetPrepared !== undefined) return targetPrepared + targetPrepared = prepare(target) + preparedCache.set(target, targetPrepared) + return targetPrepared +} +var getPreparedSearch = (search) => { + if(search.length > 999) return prepareSearch(search) // don't cache huge searches + var searchPrepared = preparedSearchCache.get(search) + if(searchPrepared !== undefined) return searchPrepared + searchPrepared = prepareSearch(search) + preparedSearchCache.set(search, searchPrepared) + return searchPrepared +} + + +var all = (targets, options) => { + var results = []; results.total = targets.length // this total can be wrong if some targets are skipped + + var limit = options?.limit || INFINITY + + if(options?.key) { + for(var i=0;i= limit) return results + } + } else if(options?.keys) { + for(var i=0;i= 0; --keyI) { + var target = getValue(obj, options.keys[keyI]) + if(!target) { objResults[keyI] = noTarget; continue } + if(!isPrepared(target)) target = getPrepared(target) + target._score = NEGATIVE_INFINITY + target._indexes.len = 0 + objResults[keyI] = target + } + objResults.obj = obj + objResults._score = NEGATIVE_INFINITY + results.push(objResults); if(results.length >= limit) return results + } + } else { + for(var i=0;i= limit) return results + } + } + + return results +} + + +var algorithm = (preparedSearch, prepared, allowSpaces=false, allowPartialMatch=false) => { + if(allowSpaces===false && preparedSearch.containsSpace) return algorithmSpaces(preparedSearch, prepared, allowPartialMatch) + + var searchLower = preparedSearch._lower + var searchLowerCodes = preparedSearch.lowerCodes + var searchLowerCode = searchLowerCodes[0] + var targetLowerCodes = prepared._targetLowerCodes + var searchLen = searchLowerCodes.length + var targetLen = targetLowerCodes.length + var searchI = 0 // where we at + var targetI = 0 // where you at + var matchesSimpleLen = 0 + + // very basic fuzzy match; to remove non-matching targets ASAP! + // walk through target. find sequential matches. + // if all chars aren't found then exit + for(;;) { + var isMatch = searchLowerCode === targetLowerCodes[targetI] + if(isMatch) { + matchesSimple[matchesSimpleLen++] = targetI + ++searchI; if(searchI === searchLen) break + searchLowerCode = searchLowerCodes[searchI] + } + ++targetI; if(targetI >= targetLen) return NULL // Failed to find searchI + } + + var searchI = 0 + var successStrict = false + var matchesStrictLen = 0 + + var nextBeginningIndexes = prepared._nextBeginningIndexes + if(nextBeginningIndexes === NULL) nextBeginningIndexes = prepared._nextBeginningIndexes = prepareNextBeginningIndexes(prepared.target) + targetI = matchesSimple[0]===0 ? 0 : nextBeginningIndexes[matchesSimple[0]-1] + + // Our target string successfully matched all characters in sequence! + // Let's try a more advanced and strict test to improve the score + // only count it as a match if it's consecutive or a beginning character! + var backtrackCount = 0 + if(targetI !== targetLen) for(;;) { + if(targetI >= targetLen) { + // We failed to find a good spot for this search char, go back to the previous search char and force it forward + if(searchI <= 0) break // We failed to push chars forward for a better match + + ++backtrackCount; if(backtrackCount > 200) break // exponential backtracking is taking too long, just give up and return a bad match + + --searchI + var lastMatch = matchesStrict[--matchesStrictLen] + targetI = nextBeginningIndexes[lastMatch] + + } else { + var isMatch = searchLowerCodes[searchI] === targetLowerCodes[targetI] + if(isMatch) { + matchesStrict[matchesStrictLen++] = targetI + ++searchI; if(searchI === searchLen) { successStrict = true; break } + ++targetI + } else { + targetI = nextBeginningIndexes[targetI] + } + } + } + + // check if it's a substring match + var substringIndex = searchLen <= 1 ? -1 : prepared._targetLower.indexOf(searchLower, matchesSimple[0]) // perf: this is slow + var isSubstring = !!~substringIndex + var isSubstringBeginning = !isSubstring ? false : substringIndex===0 || prepared._nextBeginningIndexes[substringIndex-1] === substringIndex + + // if it's a substring match but not at a beginning index, let's try to find a substring starting at a beginning index for a better score + if(isSubstring && !isSubstringBeginning) { + for(var i=0; i { + var score = 0 + + var extraMatchGroupCount = 0 + for(var i = 1; i < searchLen; ++i) { + if(matches[i] - matches[i-1] !== 1) {score -= matches[i]; ++extraMatchGroupCount} + } + var unmatchedDistance = matches[searchLen-1] - matches[0] - (searchLen-1) + + score -= (12+unmatchedDistance) * extraMatchGroupCount // penality for more groups + + if(matches[0] !== 0) score -= matches[0]*matches[0]*.2 // penality for not starting near the beginning + + if(!successStrict) { + score *= 1000 + } else { + // successStrict on a target with too many beginning indexes loses points for being a bad target + var uniqueBeginningIndexes = 1 + for(var i = nextBeginningIndexes[0]; i < targetLen; i=nextBeginningIndexes[i]) ++uniqueBeginningIndexes + + if(uniqueBeginningIndexes > 24) score *= (uniqueBeginningIndexes-24)*10 // quite arbitrary numbers here ... + } + + score -= (targetLen - searchLen)/2 // penality for longer targets + + if(isSubstring) score /= 1+searchLen*searchLen*1 // bonus for being a full substring + if(isSubstringBeginning) score /= 1+searchLen*searchLen*1 // bonus for substring starting on a beginningIndex + + score -= (targetLen - searchLen)/2 // penality for longer targets + + return score + } + + if(!successStrict) { + if(isSubstring) for(var i=0; i { + var seen_indexes = new Set() + var score = 0 + var result = NULL + + var first_seen_index_last_search = 0 + var searches = preparedSearch.spaceSearches + var searchesLen = searches.length + var changeslen = 0 + + // Return _nextBeginningIndexes back to its normal state + var resetNextBeginningIndexes = () => { + for(let i=changeslen-1; i>=0; i--) target._nextBeginningIndexes[nextBeginningIndexesChanges[i*2 + 0]] = nextBeginningIndexesChanges[i*2 + 1] + } + + var hasAtLeast1Match = false + for(var i=0; i=0; i--) { + if(toReplace !== target._nextBeginningIndexes[i]) break + target._nextBeginningIndexes[i] = newBeginningIndex + nextBeginningIndexesChanges[changeslen*2 + 0] = i + nextBeginningIndexesChanges[changeslen*2 + 1] = toReplace + changeslen++ + } + } + } + + score += result._score / searchesLen + allowPartialMatchScores[i] = result._score / searchesLen + + // dock points based on order otherwise "c man" returns Manifest.cpp instead of CheatManager.h + if(result._indexes[0] < first_seen_index_last_search) { + score -= (first_seen_index_last_search - result._indexes[0]) * 2 + } + first_seen_index_last_search = result._indexes[0] + + for(var j=0; j score) { + if(allowPartialMatch) { + for(var i=0; i str.replace(/\p{Script=Latin}+/gu, match => match.normalize('NFD')).replace(/[\u0300-\u036f]/g, '') + +var prepareLowerInfo = (str) => { + str = remove_accents(str) + var strLen = str.length + var lower = str.toLowerCase() + var lowerCodes = [] // new Array(strLen) sparse array is too slow + var bitflags = 0 + var containsSpace = false // space isn't stored in bitflags because of how searching with a space works + + for(var i = 0; i < strLen; ++i) { + var lowerCode = lowerCodes[i] = lower.charCodeAt(i) + + if(lowerCode === 32) { + containsSpace = true + continue // it's important that we don't set any bitflags for space + } + + var bit = lowerCode>=97&&lowerCode<=122 ? lowerCode-97 // alphabet + : lowerCode>=48&&lowerCode<=57 ? 26 // numbers + // 3 bits available + : lowerCode<=127 ? 30 // other ascii + : 31 // other utf8 + bitflags |= 1< { + var targetLen = target.length + var beginningIndexes = []; var beginningIndexesLen = 0 + var wasUpper = false + var wasAlphanum = false + for(var i = 0; i < targetLen; ++i) { + var targetCode = target.charCodeAt(i) + var isUpper = targetCode>=65&&targetCode<=90 + var isAlphanum = isUpper || targetCode>=97&&targetCode<=122 || targetCode>=48&&targetCode<=57 + var isBeginning = isUpper && !wasUpper || !wasAlphanum || !isAlphanum + wasUpper = isUpper + wasAlphanum = isAlphanum + if(isBeginning) beginningIndexes[beginningIndexesLen++] = i + } + return beginningIndexes +} +var prepareNextBeginningIndexes = (target) => { + target = remove_accents(target) + var targetLen = target.length + var beginningIndexes = prepareBeginningIndexes(target) + var nextBeginningIndexes = [] // new Array(targetLen) sparse array is too slow + var lastIsBeginning = beginningIndexes[0] + var lastIsBeginningI = 0 + for(var i = 0; i < targetLen; ++i) { + if(lastIsBeginning > i) { + nextBeginningIndexes[i] = lastIsBeginning + } else { + lastIsBeginning = beginningIndexes[++lastIsBeginningI] + nextBeginningIndexes[i] = lastIsBeginning===undefined ? targetLen : lastIsBeginning + } + } + return nextBeginningIndexes +} + +var preparedCache = new Map() +var preparedSearchCache = new Map() + +// the theory behind these being globals is to reduce garbage collection by not making new arrays +var matchesSimple = []; var matchesStrict = [] +var nextBeginningIndexesChanges = [] // allows straw berry to match strawberry well, by modifying the end of a substring to be considered a beginning index for the rest of the search +var keysSpacesBestScores = []; var allowPartialMatchScores = [] +var tmpTargets = []; var tmpResults = [] + +// prop = 'key' 2.5ms optimized for this case, seems to be about as fast as direct obj[prop] +// prop = 'key1.key2' 10ms +// prop = ['key1', 'key2'] 27ms +// prop = obj => obj.tags.join() ??ms +var getValue = (obj, prop) => { + var tmp = obj[prop]; if(tmp !== undefined) return tmp + if(typeof prop === 'function') return prop(obj) // this should run first. but that makes string props slower + var segs = prop + if(!Array.isArray(prop)) segs = prop.split('.') + var len = segs.length + var i = -1 + while (obj && (++i < len)) obj = obj[segs[i]] + return obj +} + +var isPrepared = (x) => { return typeof x === 'object' && typeof x._bitflags === 'number' } +var INFINITY = Infinity; var NEGATIVE_INFINITY = -INFINITY +var noResults = []; noResults.total = 0 +var NULL = null + +var noTarget = prepare('') + +// Hacked version of https://github.com/lemire/FastPriorityQueue.js +var fastpriorityqueue=r=>{var e=[],o=0,a={},v=r=>{for(var a=0,v=e[a],c=1;c>1]=e[a],c=1+(a<<1)}for(var f=a-1>>1;a>0&&v._score>1)e[a]=e[f];e[a]=v};return a.add=(r=>{var a=o;e[o++]=r;for(var v=a-1>>1;a>0&&r._score>1)e[a]=e[v];e[a]=r}),a.poll=(r=>{if(0!==o){var a=e[0];return e[0]=e[--o],v(),a}}),a.peek=(r=>{if(0!==o)return e[0]}),a.replaceTop=(r=>{e[0]=r,v()}),a} +var q = fastpriorityqueue() // reuse this + diff --git a/.config/quickshell/caelestia/utils/scripts/fzf.js b/.config/quickshell/caelestia/utils/scripts/fzf.js new file mode 100644 index 0000000..995a093 --- /dev/null +++ b/.config/quickshell/caelestia/utils/scripts/fzf.js @@ -0,0 +1,1307 @@ +.pragma library + +/* +https://github.com/ajitid/fzf-for-js + +BSD 3-Clause License + +Copyright (c) 2021, Ajit +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ + +const normalized = { + 216: "O", + 223: "s", + 248: "o", + 273: "d", + 295: "h", + 305: "i", + 320: "l", + 322: "l", + 359: "t", + 383: "s", + 384: "b", + 385: "B", + 387: "b", + 390: "O", + 392: "c", + 393: "D", + 394: "D", + 396: "d", + 398: "E", + 400: "E", + 402: "f", + 403: "G", + 407: "I", + 409: "k", + 410: "l", + 412: "M", + 413: "N", + 414: "n", + 415: "O", + 421: "p", + 427: "t", + 429: "t", + 430: "T", + 434: "V", + 436: "y", + 438: "z", + 477: "e", + 485: "g", + 544: "N", + 545: "d", + 549: "z", + 564: "l", + 565: "n", + 566: "t", + 567: "j", + 570: "A", + 571: "C", + 572: "c", + 573: "L", + 574: "T", + 575: "s", + 576: "z", + 579: "B", + 580: "U", + 581: "V", + 582: "E", + 583: "e", + 584: "J", + 585: "j", + 586: "Q", + 587: "q", + 588: "R", + 589: "r", + 590: "Y", + 591: "y", + 592: "a", + 593: "a", + 595: "b", + 596: "o", + 597: "c", + 598: "d", + 599: "d", + 600: "e", + 603: "e", + 604: "e", + 605: "e", + 606: "e", + 607: "j", + 608: "g", + 609: "g", + 610: "G", + 613: "h", + 614: "h", + 616: "i", + 618: "I", + 619: "l", + 620: "l", + 621: "l", + 623: "m", + 624: "m", + 625: "m", + 626: "n", + 627: "n", + 628: "N", + 629: "o", + 633: "r", + 634: "r", + 635: "r", + 636: "r", + 637: "r", + 638: "r", + 639: "r", + 640: "R", + 641: "R", + 642: "s", + 647: "t", + 648: "t", + 649: "u", + 651: "v", + 652: "v", + 653: "w", + 654: "y", + 655: "Y", + 656: "z", + 657: "z", + 663: "c", + 665: "B", + 666: "e", + 667: "G", + 668: "H", + 669: "j", + 670: "k", + 671: "L", + 672: "q", + 686: "h", + 867: "a", + 868: "e", + 869: "i", + 870: "o", + 871: "u", + 872: "c", + 873: "d", + 874: "h", + 875: "m", + 876: "r", + 877: "t", + 878: "v", + 879: "x", + 7424: "A", + 7427: "B", + 7428: "C", + 7429: "D", + 7431: "E", + 7432: "e", + 7433: "i", + 7434: "J", + 7435: "K", + 7436: "L", + 7437: "M", + 7438: "N", + 7439: "O", + 7440: "O", + 7441: "o", + 7442: "o", + 7443: "o", + 7446: "o", + 7447: "o", + 7448: "P", + 7449: "R", + 7450: "R", + 7451: "T", + 7452: "U", + 7453: "u", + 7454: "u", + 7455: "m", + 7456: "V", + 7457: "W", + 7458: "Z", + 7522: "i", + 7523: "r", + 7524: "u", + 7525: "v", + 7834: "a", + 7835: "s", + 8305: "i", + 8341: "h", + 8342: "k", + 8343: "l", + 8344: "m", + 8345: "n", + 8346: "p", + 8347: "s", + 8348: "t", + 8580: "c" +}; +for (let i = "\u0300".codePointAt(0); i <= "\u036F".codePointAt(0); ++i) { + const diacritic = String.fromCodePoint(i); + for (const asciiChar of "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz") { + const withDiacritic = (asciiChar + diacritic).normalize(); + const withDiacriticCodePoint = withDiacritic.codePointAt(0); + if (withDiacriticCodePoint > 126) { + normalized[withDiacriticCodePoint] = asciiChar; + } + } +} +const ranges = { + a: [7844, 7863], + e: [7870, 7879], + o: [7888, 7907], + u: [7912, 7921] +}; +for (const lowerChar of Object.keys(ranges)) { + const upperChar = lowerChar.toUpperCase(); + for (let i = ranges[lowerChar][0]; i <= ranges[lowerChar][1]; ++i) { + normalized[i] = i % 2 === 0 ? upperChar : lowerChar; + } +} +function normalizeRune(rune) { + if (rune < 192 || rune > 8580) { + return rune; + } + const normalizedChar = normalized[rune]; + if (normalizedChar !== void 0) + return normalizedChar.codePointAt(0); + return rune; +} +function toShort(number) { + return number; +} +function toInt(number) { + return number; +} +function maxInt16(num1, num2) { + return num1 > num2 ? num1 : num2; +} +const strToRunes = (str) => str.split("").map((s) => s.codePointAt(0)); +const runesToStr = (runes) => runes.map((r) => String.fromCodePoint(r)).join(""); +const whitespaceRunes = new Set( + " \f\n\r \v\xA0\u1680\u2028\u2029\u202F\u205F\u3000\uFEFF".split("").map((v) => v.codePointAt(0)) +); +for (let codePoint = "\u2000".codePointAt(0); codePoint <= "\u200A".codePointAt(0); codePoint++) { + whitespaceRunes.add(codePoint); +} +const isWhitespace = (rune) => whitespaceRunes.has(rune); +const whitespacesAtStart = (runes) => { + let whitespaces = 0; + for (const rune of runes) { + if (isWhitespace(rune)) + whitespaces++; + else + break; + } + return whitespaces; +}; +const whitespacesAtEnd = (runes) => { + let whitespaces = 0; + for (let i = runes.length - 1; i >= 0; i--) { + if (isWhitespace(runes[i])) + whitespaces++; + else + break; + } + return whitespaces; +}; +const MAX_ASCII = "\x7F".codePointAt(0); +const CAPITAL_A_RUNE = "A".codePointAt(0); +const CAPITAL_Z_RUNE = "Z".codePointAt(0); +const SMALL_A_RUNE = "a".codePointAt(0); +const SMALL_Z_RUNE = "z".codePointAt(0); +const NUMERAL_ZERO_RUNE = "0".codePointAt(0); +const NUMERAL_NINE_RUNE = "9".codePointAt(0); +function indexAt(index, max, forward) { + if (forward) { + return index; + } + return max - index - 1; +} +const SCORE_MATCH = 16, SCORE_GAP_START = -3, SCORE_GAP_EXTENTION = -1, BONUS_BOUNDARY = SCORE_MATCH / 2, BONUS_NON_WORD = SCORE_MATCH / 2, BONUS_CAMEL_123 = BONUS_BOUNDARY + SCORE_GAP_EXTENTION, BONUS_CONSECUTIVE = -(SCORE_GAP_START + SCORE_GAP_EXTENTION), BONUS_FIRST_CHAR_MULTIPLIER = 2; +function createPosSet(withPos) { + if (withPos) { + return /* @__PURE__ */ new Set(); + } + return null; +} +function alloc16(offset, slab2, size) { + if (slab2 !== null && slab2.i16.length > offset + size) { + const subarray = slab2.i16.subarray(offset, offset + size); + return [offset + size, subarray]; + } + return [offset, new Int16Array(size)]; +} +function alloc32(offset, slab2, size) { + if (slab2 !== null && slab2.i32.length > offset + size) { + const subarray = slab2.i32.subarray(offset, offset + size); + return [offset + size, subarray]; + } + return [offset, new Int32Array(size)]; +} +function charClassOfAscii(rune) { + if (rune >= SMALL_A_RUNE && rune <= SMALL_Z_RUNE) { + return 1; + } else if (rune >= CAPITAL_A_RUNE && rune <= CAPITAL_Z_RUNE) { + return 2; + } else if (rune >= NUMERAL_ZERO_RUNE && rune <= NUMERAL_NINE_RUNE) { + return 4; + } else { + return 0; + } +} +function charClassOfNonAscii(rune) { + const char = String.fromCodePoint(rune); + if (char !== char.toUpperCase()) { + return 1; + } else if (char !== char.toLowerCase()) { + return 2; + } else if (char.match(/\p{Number}/gu) !== null) { + return 4; + } else if (char.match(/\p{Letter}/gu) !== null) { + return 3; + } + return 0; +} +function charClassOf(rune) { + if (rune <= MAX_ASCII) { + return charClassOfAscii(rune); + } + return charClassOfNonAscii(rune); +} +function bonusFor(prevClass, currClass) { + if (prevClass === 0 && currClass !== 0) { + return BONUS_BOUNDARY; + } else if (prevClass === 1 && currClass === 2 || prevClass !== 4 && currClass === 4) { + return BONUS_CAMEL_123; + } else if (currClass === 0) { + return BONUS_NON_WORD; + } + return 0; +} +function bonusAt(input, idx) { + if (idx === 0) { + return BONUS_BOUNDARY; + } + return bonusFor(charClassOf(input[idx - 1]), charClassOf(input[idx])); +} +function trySkip(input, caseSensitive, char, from) { + let rest = input.slice(from); + let idx = rest.indexOf(char); + if (idx === 0) { + return from; + } + if (!caseSensitive && char >= SMALL_A_RUNE && char <= SMALL_Z_RUNE) { + if (idx > 0) { + rest = rest.slice(0, idx); + } + const uidx = rest.indexOf(char - 32); + if (uidx >= 0) { + idx = uidx; + } + } + if (idx < 0) { + return -1; + } + return from + idx; +} +function isAscii(runes) { + for (const rune of runes) { + if (rune >= 128) { + return false; + } + } + return true; +} +function asciiFuzzyIndex(input, pattern, caseSensitive) { + if (!isAscii(input)) { + return 0; + } + if (!isAscii(pattern)) { + return -1; + } + let firstIdx = 0, idx = 0; + for (let pidx = 0; pidx < pattern.length; pidx++) { + idx = trySkip(input, caseSensitive, pattern[pidx], idx); + if (idx < 0) { + return -1; + } + if (pidx === 0 && idx > 0) { + firstIdx = idx - 1; + } + idx++; + } + return firstIdx; +} +const fuzzyMatchV2 = (caseSensitive, normalize, forward, input, pattern, withPos, slab2) => { + const M = pattern.length; + if (M === 0) { + return [{ start: 0, end: 0, score: 0 }, createPosSet(withPos)]; + } + const N = input.length; + if (slab2 !== null && N * M > slab2.i16.length) { + return fuzzyMatchV1(caseSensitive, normalize, forward, input, pattern, withPos); + } + const idx = asciiFuzzyIndex(input, pattern, caseSensitive); + if (idx < 0) { + return [{ start: -1, end: -1, score: 0 }, null]; + } + let offset16 = 0, offset32 = 0, H0 = null, C0 = null, B = null, F = null; + [offset16, H0] = alloc16(offset16, slab2, N); + [offset16, C0] = alloc16(offset16, slab2, N); + [offset16, B] = alloc16(offset16, slab2, N); + [offset32, F] = alloc32(offset32, slab2, M); + const [, T] = alloc32(offset32, slab2, N); + for (let i = 0; i < T.length; i++) { + T[i] = input[i]; + } + let maxScore = toShort(0), maxScorePos = 0; + let pidx = 0, lastIdx = 0; + const pchar0 = pattern[0]; + let pchar = pattern[0], prevH0 = toShort(0), prevCharClass = 0, inGap = false; + let Tsub = T.subarray(idx); + let H0sub = H0.subarray(idx).subarray(0, Tsub.length), C0sub = C0.subarray(idx).subarray(0, Tsub.length), Bsub = B.subarray(idx).subarray(0, Tsub.length); + for (let [off, char] of Tsub.entries()) { + let charClass = null; + if (char <= MAX_ASCII) { + charClass = charClassOfAscii(char); + if (!caseSensitive && charClass === 2) { + char += 32; + } + } else { + charClass = charClassOfNonAscii(char); + if (!caseSensitive && charClass === 2) { + char = String.fromCodePoint(char).toLowerCase().codePointAt(0); + } + if (normalize) { + char = normalizeRune(char); + } + } + Tsub[off] = char; + const bonus = bonusFor(prevCharClass, charClass); + Bsub[off] = bonus; + prevCharClass = charClass; + if (char === pchar) { + if (pidx < M) { + F[pidx] = toInt(idx + off); + pidx++; + pchar = pattern[Math.min(pidx, M - 1)]; + } + lastIdx = idx + off; + } + if (char === pchar0) { + const score = SCORE_MATCH + bonus * BONUS_FIRST_CHAR_MULTIPLIER; + H0sub[off] = score; + C0sub[off] = 1; + if (M === 1 && (forward && score > maxScore || !forward && score >= maxScore)) { + maxScore = score; + maxScorePos = idx + off; + if (forward && bonus === BONUS_BOUNDARY) { + break; + } + } + inGap = false; + } else { + if (inGap) { + H0sub[off] = maxInt16(prevH0 + SCORE_GAP_EXTENTION, 0); + } else { + H0sub[off] = maxInt16(prevH0 + SCORE_GAP_START, 0); + } + C0sub[off] = 0; + inGap = true; + } + prevH0 = H0sub[off]; + } + if (pidx !== M) { + return [{ start: -1, end: -1, score: 0 }, null]; + } + if (M === 1) { + const result = { + start: maxScorePos, + end: maxScorePos + 1, + score: maxScore + }; + if (!withPos) { + return [result, null]; + } + const pos2 = /* @__PURE__ */ new Set(); + pos2.add(maxScorePos); + return [result, pos2]; + } + const f0 = F[0]; + const width = lastIdx - f0 + 1; + let H = null; + [offset16, H] = alloc16(offset16, slab2, width * M); + { + const toCopy = H0.subarray(f0, lastIdx + 1); + for (const [i, v] of toCopy.entries()) { + H[i] = v; + } + } + let [, C] = alloc16(offset16, slab2, width * M); + { + const toCopy = C0.subarray(f0, lastIdx + 1); + for (const [i, v] of toCopy.entries()) { + C[i] = v; + } + } + const Fsub = F.subarray(1); + const Psub = pattern.slice(1).slice(0, Fsub.length); + for (const [off, f] of Fsub.entries()) { + let inGap2 = false; + const pchar2 = Psub[off], pidx2 = off + 1, row = pidx2 * width, Tsub2 = T.subarray(f, lastIdx + 1), Bsub2 = B.subarray(f).subarray(0, Tsub2.length), Csub = C.subarray(row + f - f0).subarray(0, Tsub2.length), Cdiag = C.subarray(row + f - f0 - 1 - width).subarray(0, Tsub2.length), Hsub = H.subarray(row + f - f0).subarray(0, Tsub2.length), Hdiag = H.subarray(row + f - f0 - 1 - width).subarray(0, Tsub2.length), Hleft = H.subarray(row + f - f0 - 1).subarray(0, Tsub2.length); + Hleft[0] = 0; + for (const [off2, char] of Tsub2.entries()) { + const col = off2 + f; + let s1 = 0, s2 = 0, consecutive = 0; + if (inGap2) { + s2 = Hleft[off2] + SCORE_GAP_EXTENTION; + } else { + s2 = Hleft[off2] + SCORE_GAP_START; + } + if (pchar2 === char) { + s1 = Hdiag[off2] + SCORE_MATCH; + let b = Bsub2[off2]; + consecutive = Cdiag[off2] + 1; + if (b === BONUS_BOUNDARY) { + consecutive = 1; + } else if (consecutive > 1) { + b = maxInt16(b, maxInt16(BONUS_CONSECUTIVE, B[col - consecutive + 1])); + } + if (s1 + b < s2) { + s1 += Bsub2[off2]; + consecutive = 0; + } else { + s1 += b; + } + } + Csub[off2] = consecutive; + inGap2 = s1 < s2; + const score = maxInt16(maxInt16(s1, s2), 0); + if (pidx2 === M - 1 && (forward && score > maxScore || !forward && score >= maxScore)) { + maxScore = score; + maxScorePos = col; + } + Hsub[off2] = score; + } + } + const pos = createPosSet(withPos); + let j = f0; + if (withPos && pos !== null) { + let i = M - 1; + j = maxScorePos; + let preferMatch = true; + while (true) { + const I = i * width, j0 = j - f0, s = H[I + j0]; + let s1 = 0, s2 = 0; + if (i > 0 && j >= F[i]) { + s1 = H[I - width + j0 - 1]; + } + if (j > F[i]) { + s2 = H[I + j0 - 1]; + } + if (s > s1 && (s > s2 || s === s2 && preferMatch)) { + pos.add(j); + if (i === 0) { + break; + } + i--; + } + preferMatch = C[I + j0] > 1 || I + width + j0 + 1 < C.length && C[I + width + j0 + 1] > 0; + j--; + } + } + return [{ start: j, end: maxScorePos + 1, score: maxScore }, pos]; +}; +function calculateScore(caseSensitive, normalize, text, pattern, sidx, eidx, withPos) { + let pidx = 0, score = 0, inGap = false, consecutive = 0, firstBonus = toShort(0); + const pos = createPosSet(withPos); + let prevCharClass = 0; + if (sidx > 0) { + prevCharClass = charClassOf(text[sidx - 1]); + } + for (let idx = sidx; idx < eidx; idx++) { + let rune = text[idx]; + const charClass = charClassOf(rune); + if (!caseSensitive) { + if (rune >= CAPITAL_A_RUNE && rune <= CAPITAL_Z_RUNE) { + rune += 32; + } else if (rune > MAX_ASCII) { + rune = String.fromCodePoint(rune).toLowerCase().codePointAt(0); + } + } + if (normalize) { + rune = normalizeRune(rune); + } + if (rune === pattern[pidx]) { + if (withPos && pos !== null) { + pos.add(idx); + } + score += SCORE_MATCH; + let bonus = bonusFor(prevCharClass, charClass); + if (consecutive === 0) { + firstBonus = bonus; + } else { + if (bonus === BONUS_BOUNDARY) { + firstBonus = bonus; + } + bonus = maxInt16(maxInt16(bonus, firstBonus), BONUS_CONSECUTIVE); + } + if (pidx === 0) { + score += bonus * BONUS_FIRST_CHAR_MULTIPLIER; + } else { + score += bonus; + } + inGap = false; + consecutive++; + pidx++; + } else { + if (inGap) { + score += SCORE_GAP_EXTENTION; + } else { + score += SCORE_GAP_START; + } + inGap = true; + consecutive = 0; + firstBonus = 0; + } + prevCharClass = charClass; + } + return [score, pos]; +} +function fuzzyMatchV1(caseSensitive, normalize, forward, text, pattern, withPos, slab2) { + if (pattern.length === 0) { + return [{ start: 0, end: 0, score: 0 }, null]; + } + if (asciiFuzzyIndex(text, pattern, caseSensitive) < 0) { + return [{ start: -1, end: -1, score: 0 }, null]; + } + let pidx = 0, sidx = -1, eidx = -1; + const lenRunes = text.length; + const lenPattern = pattern.length; + for (let index = 0; index < lenRunes; index++) { + let rune = text[indexAt(index, lenRunes, forward)]; + if (!caseSensitive) { + if (rune >= CAPITAL_A_RUNE && rune <= CAPITAL_Z_RUNE) { + rune += 32; + } else if (rune > MAX_ASCII) { + rune = String.fromCodePoint(rune).toLowerCase().codePointAt(0); + } + } + if (normalize) { + rune = normalizeRune(rune); + } + const pchar = pattern[indexAt(pidx, lenPattern, forward)]; + if (rune === pchar) { + if (sidx < 0) { + sidx = index; + } + pidx++; + if (pidx === lenPattern) { + eidx = index + 1; + break; + } + } + } + if (sidx >= 0 && eidx >= 0) { + pidx--; + for (let index = eidx - 1; index >= sidx; index--) { + const tidx = indexAt(index, lenRunes, forward); + let rune = text[tidx]; + if (!caseSensitive) { + if (rune >= CAPITAL_A_RUNE && rune <= CAPITAL_Z_RUNE) { + rune += 32; + } else if (rune > MAX_ASCII) { + rune = String.fromCodePoint(rune).toLowerCase().codePointAt(0); + } + } + const pidx_ = indexAt(pidx, lenPattern, forward); + const pchar = pattern[pidx_]; + if (rune === pchar) { + pidx--; + if (pidx < 0) { + sidx = index; + break; + } + } + } + if (!forward) { + const sidxTemp = sidx; + sidx = lenRunes - eidx; + eidx = lenRunes - sidxTemp; + } + const [score, pos] = calculateScore( + caseSensitive, + normalize, + text, + pattern, + sidx, + eidx, + withPos + ); + return [{ start: sidx, end: eidx, score }, pos]; + } + return [{ start: -1, end: -1, score: 0 }, null]; +}; +const exactMatchNaive = (caseSensitive, normalize, forward, text, pattern, withPos, slab2) => { + if (pattern.length === 0) { + return [{ start: 0, end: 0, score: 0 }, null]; + } + const lenRunes = text.length; + const lenPattern = pattern.length; + if (lenRunes < lenPattern) { + return [{ start: -1, end: -1, score: 0 }, null]; + } + if (asciiFuzzyIndex(text, pattern, caseSensitive) < 0) { + return [{ start: -1, end: -1, score: 0 }, null]; + } + let pidx = 0; + let bestPos = -1, bonus = toShort(0), bestBonus = toShort(-1); + for (let index = 0; index < lenRunes; index++) { + const index_ = indexAt(index, lenRunes, forward); + let rune = text[index_]; + if (!caseSensitive) { + if (rune >= CAPITAL_A_RUNE && rune <= CAPITAL_Z_RUNE) { + rune += 32; + } else if (rune > MAX_ASCII) { + rune = String.fromCodePoint(rune).toLowerCase().codePointAt(0); + } + } + if (normalize) { + rune = normalizeRune(rune); + } + const pidx_ = indexAt(pidx, lenPattern, forward); + const pchar = pattern[pidx_]; + if (pchar === rune) { + if (pidx_ === 0) { + bonus = bonusAt(text, index_); + } + pidx++; + if (pidx === lenPattern) { + if (bonus > bestBonus) { + bestPos = index; + bestBonus = bonus; + } + if (bonus === BONUS_BOUNDARY) { + break; + } + index -= pidx - 1; + pidx = 0; + bonus = 0; + } + } else { + index -= pidx; + pidx = 0; + bonus = 0; + } + } + if (bestPos >= 0) { + let sidx = 0, eidx = 0; + if (forward) { + sidx = bestPos - lenPattern + 1; + eidx = bestPos + 1; + } else { + sidx = lenRunes - (bestPos + 1); + eidx = lenRunes - (bestPos - lenPattern + 1); + } + const [score] = calculateScore(caseSensitive, normalize, text, pattern, sidx, eidx, false); + return [{ start: sidx, end: eidx, score }, null]; + } + return [{ start: -1, end: -1, score: 0 }, null]; +}; +const prefixMatch = (caseSensitive, normalize, forward, text, pattern, withPos, slab2) => { + if (pattern.length === 0) { + return [{ start: 0, end: 0, score: 0 }, null]; + } + let trimmedLen = 0; + if (!isWhitespace(pattern[0])) { + trimmedLen = whitespacesAtStart(text); + } + if (text.length - trimmedLen < pattern.length) { + return [{ start: -1, end: -1, score: 0 }, null]; + } + for (const [index, r] of pattern.entries()) { + let rune = text[trimmedLen + index]; + if (!caseSensitive) { + rune = String.fromCodePoint(rune).toLowerCase().codePointAt(0); + } + if (normalize) { + rune = normalizeRune(rune); + } + if (rune !== r) { + return [{ start: -1, end: -1, score: 0 }, null]; + } + } + const lenPattern = pattern.length; + const [score] = calculateScore( + caseSensitive, + normalize, + text, + pattern, + trimmedLen, + trimmedLen + lenPattern, + false + ); + return [{ start: trimmedLen, end: trimmedLen + lenPattern, score }, null]; +}; +const suffixMatch = (caseSensitive, normalize, forward, text, pattern, withPos, slab2) => { + const lenRunes = text.length; + let trimmedLen = lenRunes; + if (pattern.length === 0 || !isWhitespace(pattern[pattern.length - 1])) { + trimmedLen -= whitespacesAtEnd(text); + } + if (pattern.length === 0) { + return [{ start: trimmedLen, end: trimmedLen, score: 0 }, null]; + } + const diff = trimmedLen - pattern.length; + if (diff < 0) { + return [{ start: -1, end: -1, score: 0 }, null]; + } + for (const [index, r] of pattern.entries()) { + let rune = text[index + diff]; + if (!caseSensitive) { + rune = String.fromCodePoint(rune).toLowerCase().codePointAt(0); + } + if (normalize) { + rune = normalizeRune(rune); + } + if (rune !== r) { + return [{ start: -1, end: -1, score: 0 }, null]; + } + } + const lenPattern = pattern.length; + const sidx = trimmedLen - lenPattern; + const eidx = trimmedLen; + const [score] = calculateScore(caseSensitive, normalize, text, pattern, sidx, eidx, false); + return [{ start: sidx, end: eidx, score }, null]; +}; +const equalMatch = (caseSensitive, normalize, forward, text, pattern, withPos, slab2) => { + const lenPattern = pattern.length; + if (lenPattern === 0) { + return [{ start: -1, end: -1, score: 0 }, null]; + } + let trimmedLen = 0; + if (!isWhitespace(pattern[0])) { + trimmedLen = whitespacesAtStart(text); + } + let trimmedEndLen = 0; + if (!isWhitespace(pattern[lenPattern - 1])) { + trimmedEndLen = whitespacesAtEnd(text); + } + if (text.length - trimmedLen - trimmedEndLen != lenPattern) { + return [{ start: -1, end: -1, score: 0 }, null]; + } + let match = true; + if (normalize) { + const runes = text; + for (const [idx, pchar] of pattern.entries()) { + let rune = runes[trimmedLen + idx]; + if (!caseSensitive) { + rune = String.fromCodePoint(rune).toLowerCase().codePointAt(0); + } + if (normalizeRune(pchar) !== normalizeRune(rune)) { + match = false; + break; + } + } + } else { + let runesStr = runesToStr(text).substring(trimmedLen, text.length - trimmedEndLen); + if (!caseSensitive) { + runesStr = runesStr.toLowerCase(); + } + match = runesStr === runesToStr(pattern); + } + if (match) { + return [ + { + start: trimmedLen, + end: trimmedLen + lenPattern, + score: (SCORE_MATCH + BONUS_BOUNDARY) * lenPattern + (BONUS_FIRST_CHAR_MULTIPLIER - 1) * BONUS_BOUNDARY + }, + null + ]; + } + return [{ start: -1, end: -1, score: 0 }, null]; +}; +const SLAB_16_SIZE = 100 * 1024; +const SLAB_32_SIZE = 2048; +function makeSlab(size16, size32) { + return { + i16: new Int16Array(size16), + i32: new Int32Array(size32) + }; +} +const slab = makeSlab(SLAB_16_SIZE, SLAB_32_SIZE); +var TermType = /* @__PURE__ */ ((TermType2) => { + TermType2[TermType2["Fuzzy"] = 0] = "Fuzzy"; + TermType2[TermType2["Exact"] = 1] = "Exact"; + TermType2[TermType2["Prefix"] = 2] = "Prefix"; + TermType2[TermType2["Suffix"] = 3] = "Suffix"; + TermType2[TermType2["Equal"] = 4] = "Equal"; + return TermType2; +})(TermType || {}); +const termTypeMap = { + [0]: fuzzyMatchV2, + [1]: exactMatchNaive, + [2]: prefixMatch, + [3]: suffixMatch, + [4]: equalMatch +}; +function buildPatternForExtendedMatch(fuzzy, caseMode, normalize, str) { + let cacheable = true; + str = str.trimLeft(); + { + const trimmedAtRightStr = str.trimRight(); + if (trimmedAtRightStr.endsWith("\\") && str[trimmedAtRightStr.length] === " ") { + str = trimmedAtRightStr + " "; + } else { + str = trimmedAtRightStr; + } + } + let sortable = false; + let termSets = []; + termSets = parseTerms(fuzzy, caseMode, normalize, str); + Loop: + for (const termSet of termSets) { + for (const [idx, term] of termSet.entries()) { + if (!term.inv) { + sortable = true; + } + if (!cacheable || idx > 0 || term.inv || fuzzy && term.typ !== 0 || !fuzzy && term.typ !== 1) { + cacheable = false; + if (sortable) { + break Loop; + } + } + } + } + return { + str, + termSets, + sortable, + cacheable, + fuzzy + }; +} +function parseTerms(fuzzy, caseMode, normalize, str) { + str = str.replace(/\\ /g, " "); + const tokens = str.split(/ +/); + const sets = []; + let set = []; + let switchSet = false; + let afterBar = false; + for (const token of tokens) { + let typ = 0, inv = false, text = token.replace(/\t/g, " "); + const lowerText = text.toLowerCase(); + const caseSensitive = caseMode === "case-sensitive" || caseMode === "smart-case" && text !== lowerText; + const normalizeTerm = normalize && lowerText === runesToStr(strToRunes(lowerText).map(normalizeRune)); + if (!caseSensitive) { + text = lowerText; + } + if (!fuzzy) { + typ = 1; + } + if (set.length > 0 && !afterBar && text === "|") { + switchSet = false; + afterBar = true; + continue; + } + afterBar = false; + if (text.startsWith("!")) { + inv = true; + typ = 1; + text = text.substring(1); + } + if (text !== "$" && text.endsWith("$")) { + typ = 3; + text = text.substring(0, text.length - 1); + } + if (text.startsWith("'")) { + if (fuzzy && !inv) { + typ = 1; + } else { + typ = 0; + } + text = text.substring(1); + } else if (text.startsWith("^")) { + if (typ === 3) { + typ = 4; + } else { + typ = 2; + } + text = text.substring(1); + } + if (text.length > 0) { + if (switchSet) { + sets.push(set); + set = []; + } + let textRunes = strToRunes(text); + if (normalizeTerm) { + textRunes = textRunes.map(normalizeRune); + } + set.push({ + typ, + inv, + text: textRunes, + caseSensitive, + normalize: normalizeTerm + }); + switchSet = true; + } + } + if (set.length > 0) { + sets.push(set); + } + return sets; +} +const buildPatternForBasicMatch = (query, casing, normalize) => { + let caseSensitive = false; + switch (casing) { + case "smart-case": + if (query.toLowerCase() !== query) { + caseSensitive = true; + } + break; + case "case-sensitive": + caseSensitive = true; + break; + case "case-insensitive": + query = query.toLowerCase(); + caseSensitive = false; + break; + } + let queryRunes = strToRunes(query); + if (normalize) { + queryRunes = queryRunes.map(normalizeRune); + } + return { + queryRunes, + caseSensitive + }; +}; +function iter(algoFn, tokens, caseSensitive, normalize, forward, pattern, slab2) { + for (const part of tokens) { + const [res, pos] = algoFn(caseSensitive, normalize, forward, part.text, pattern, true, slab2); + if (res.start >= 0) { + const sidx = res.start + part.prefixLength; + const eidx = res.end + part.prefixLength; + if (pos !== null) { + const newPos = /* @__PURE__ */ new Set(); + pos.forEach((v) => newPos.add(part.prefixLength + v)); + return [[sidx, eidx], res.score, newPos]; + } + return [[sidx, eidx], res.score, pos]; + } + } + return [[-1, -1], 0, null]; +} +function computeExtendedMatch(text, pattern, fuzzyAlgo, forward) { + const input = [ + { + text, + prefixLength: 0 + } + ]; + const offsets = []; + let totalScore = 0; + const allPos = /* @__PURE__ */ new Set(); + for (const termSet of pattern.termSets) { + let offset = [0, 0]; + let currentScore = 0; + let matched = false; + for (const term of termSet) { + let algoFn = termTypeMap[term.typ]; + if (term.typ === TermType.Fuzzy) { + algoFn = fuzzyAlgo; + } + const [off, score, pos] = iter( + algoFn, + input, + term.caseSensitive, + term.normalize, + forward, + term.text, + slab + ); + const sidx = off[0]; + if (sidx >= 0) { + if (term.inv) { + continue; + } + offset = off; + currentScore = score; + matched = true; + if (pos !== null) { + pos.forEach((v) => allPos.add(v)); + } else { + for (let idx = off[0]; idx < off[1]; ++idx) { + allPos.add(idx); + } + } + break; + } else if (term.inv) { + offset = [0, 0]; + currentScore = 0; + matched = true; + continue; + } + } + if (matched) { + offsets.push(offset); + totalScore += currentScore; + } + } + return { offsets, totalScore, allPos }; +} +function getResultFromScoreMap(scoreMap, limit) { + const scoresInDesc = Object.keys(scoreMap).map((v) => parseInt(v, 10)).sort((a, b) => b - a); + let result = []; + for (const score of scoresInDesc) { + result = result.concat(scoreMap[score]); + if (result.length >= limit) { + break; + } + } + return result; +} +function getBasicMatchIter(scoreMap, queryRunes, caseSensitive) { + return (idx) => { + const itemRunes = this.runesList[idx]; + if (queryRunes.length > itemRunes.length) + return; + let [match, positions] = this.algoFn( + caseSensitive, + this.opts.normalize, + this.opts.forward, + itemRunes, + queryRunes, + true, + slab + ); + if (match.start === -1) + return; + if (this.opts.fuzzy === false) { + positions = /* @__PURE__ */ new Set(); + for (let position = match.start; position < match.end; ++position) { + positions.add(position); + } + } + const scoreKey = this.opts.sort ? match.score : 0; + if (scoreMap[scoreKey] === void 0) { + scoreMap[scoreKey] = []; + } + scoreMap[scoreKey].push(Object.assign({ + item: this.items[idx], + positions: positions != null ? positions : /* @__PURE__ */ new Set() + }, match)); + }; +} +function getExtendedMatchIter(scoreMap, pattern) { + return (idx) => { + const runes = this.runesList[idx]; + const match = computeExtendedMatch(runes, pattern, this.algoFn, this.opts.forward); + if (match.offsets.length !== pattern.termSets.length) + return; + let sidx = -1, eidx = -1; + if (match.allPos.size > 0) { + sidx = Math.min(...match.allPos); + eidx = Math.max(...match.allPos) + 1; + } + const scoreKey = this.opts.sort ? match.totalScore : 0; + if (scoreMap[scoreKey] === void 0) { + scoreMap[scoreKey] = []; + } + scoreMap[scoreKey].push({ + score: match.totalScore, + item: this.items[idx], + positions: match.allPos, + start: sidx, + end: eidx + }); + }; +} +function basicMatch(query) { + const { queryRunes, caseSensitive } = buildPatternForBasicMatch( + query, + this.opts.casing, + this.opts.normalize + ); + const scoreMap = {}; + const iter2 = getBasicMatchIter.bind(this)( + scoreMap, + queryRunes, + caseSensitive + ); + for (let i = 0, len = this.runesList.length; i < len; ++i) { + iter2(i); + } + return getResultFromScoreMap(scoreMap, this.opts.limit); +} +function extendedMatch(query) { + const pattern = buildPatternForExtendedMatch( + Boolean(this.opts.fuzzy), + this.opts.casing, + this.opts.normalize, + query + ); + const scoreMap = {}; + const iter2 = getExtendedMatchIter.bind(this)(scoreMap, pattern); + for (let i = 0, len = this.runesList.length; i < len; ++i) { + iter2(i); + } + return getResultFromScoreMap(scoreMap, this.opts.limit); +} +const defaultOpts = { + limit: Infinity, + selector: (v) => v, + casing: "smart-case", + normalize: true, + fuzzy: "v2", + tiebreakers: [], + sort: true, + forward: true, + match: basicMatch +}; +class Finder { + constructor(list, ...optionsTuple) { + this.opts = Object.assign(defaultOpts, optionsTuple[0]); + this.items = list; + this.runesList = list.map((item) => strToRunes(this.opts.selector(item).normalize())); + this.algoFn = exactMatchNaive; + switch (this.opts.fuzzy) { + case "v2": + this.algoFn = fuzzyMatchV2; + break; + case "v1": + this.algoFn = fuzzyMatchV1; + break; + } + } + find(query) { + if (query.length === 0 || this.items.length === 0) + return this.items.slice(0, this.opts.limit).map(createResultItemWithEmptyPos); + query = query.normalize(); + let result = this.opts.match.bind(this)(query); + return postProcessResultItems(result, this.opts); + } +} +function createResultItemWithEmptyPos(item) { + return ({ + item, + start: -1, + end: -1, + score: 0, + positions: /* @__PURE__ */ new Set() + }) +}; +function postProcessResultItems(result, opts) { + if (opts.sort) { + const { selector } = opts; + result.sort((a, b) => { + if (a.score === b.score) { + for (const tiebreaker of opts.tiebreakers) { + const diff = tiebreaker(a, b, selector); + if (diff !== 0) { + return diff; + } + } + } + return 0; + }); + } + if (Number.isFinite(opts.limit)) { + result.splice(opts.limit); + } + return result; +} +function byLengthAsc(a, b, selector) { + return selector(a.item).length - selector(b.item).length; +} +function byStartAsc(a, b) { + return a.start - b.start; +} diff --git a/.config/quickshell/nucleus-shell/CMakeLists.txt b/.config/quickshell/nucleus-shell/CMakeLists.txt new file mode 100644 index 0000000..0d10d08 --- /dev/null +++ b/.config/quickshell/nucleus-shell/CMakeLists.txt @@ -0,0 +1,24 @@ +cmake_minimum_required(VERSION 3.16) + +project(nucleus-shell VERSION 1.0.0 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +find_package(Qt6 6.5 COMPONENTS Quick REQUIRED) + +qt_standard_project_setup(REQUIRES 6.5) + +qt_add_executable(nucleus-shell + main.cpp +) + +qt_add_qml_module(nucleus-shell + URI nucleus-shell + QML_FILES + shell.qml + RESOURCES + img/world.png +) + +target_link_libraries(nucleus-shell PRIVATE Qt6::Quick) diff --git a/.config/quickshell/nucleus-shell/assets/gifs/bongo-cat.gif b/.config/quickshell/nucleus-shell/assets/gifs/bongo-cat.gif new file mode 100644 index 0000000..146c358 Binary files /dev/null and b/.config/quickshell/nucleus-shell/assets/gifs/bongo-cat.gif differ diff --git a/.config/quickshell/nucleus-shell/config/Appearance.qml b/.config/quickshell/nucleus-shell/config/Appearance.qml new file mode 100644 index 0000000..6d82425 --- /dev/null +++ b/.config/quickshell/nucleus-shell/config/Appearance.qml @@ -0,0 +1,262 @@ +import qs.modules.functions +import QtQuick +import Quickshell +pragma Singleton +pragma ComponentBehavior: Bound + +Singleton { + id: root + property QtObject m3colors + property QtObject colors + property QtObject rounding + property QtObject font + property QtObject margin + property QtObject moduleLayouts + property QtObject animation + property string syntaxHighlightingTheme + + readonly property bool darkmode: Config.runtime.appearance.theme === "dark" + readonly property bool transparentize: Config.runtime.appearance.transparency.enabled + readonly property double alpha: Config.runtime.appearance.transparency.alpha + + colors: QtObject { + property color colSubtext: m3colors.m3outline + property color colLayer0: m3colors.m3background + property color colLayer0Border: ColorUtils.mix(root.m3colors.m3outlineVariant, colLayer0, 0.4) + property color colLayer1: m3colors.m3surfaceContainerLow + property color colOnLayer1: m3colors.m3onSurfaceVariant + property color colOnLayer1Inactive: ColorUtils.mix(colOnLayer1, colLayer1, 0.45) + property color colLayer1Hover: ColorUtils.mix(colLayer1, colOnLayer1, 0.92) + property color colLayer1Active: ColorUtils.mix(colLayer1, colOnLayer1, 0.85) + property color colLayer2: m3colors.m3surfaceContainer + property color colOnLayer2: m3colors.m3onSurface + property color colLayer2Hover: ColorUtils.mix(colLayer2, colOnLayer2, 0.90) + property color colLayer2Active: ColorUtils.mix(colLayer2, colOnLayer2, 0.80) + property color colPrimary: m3colors.m3primary + property color colOnPrimary: m3colors.m3onPrimary + property color colSecondary: m3colors.m3secondary + property color colSecondaryContainer: m3colors.m3secondaryContainer + property color colOnSecondaryContainer: m3colors.m3onSecondaryContainer + property color colTooltip: m3colors.m3inverseSurface + property color colOnTooltip: m3colors.m3inverseOnSurface + property color colShadow: ColorUtils.transparentize(m3colors.m3shadow, 0.7) + property color colOutline: m3colors.m3outline + } + + m3colors: QtObject { + function t(c) { + return root.transparentize + ? ColorUtils.transparentize(c, root.alpha) + : c + } + + function tH(c) { + return root.transparentize + ? ColorUtils.transparentize(c, root.alpha + 1) // Totally transparent + : c + } + + readonly property color m3background: t(MaterialColors.colors.background) + readonly property color m3paddingContainer: t(Config.runtime.bar.modules.paddingColor) + readonly property color m3surface: t(MaterialColors.colors.surface) + readonly property color m3surfaceDim: t(MaterialColors.colors.surface_dim) + readonly property color m3surfaceBright: t(MaterialColors.colors.surface_bright) + + readonly property color m3surfaceContainerLowest: tH(MaterialColors.colors.surface_container_lowest) + readonly property color m3surfaceContainerLow: tH(MaterialColors.colors.surface_container_low) + readonly property color m3surfaceContainer: tH(MaterialColors.colors.surface_container) + readonly property color m3surfaceContainerHigh: tH(MaterialColors.colors.surface_container_high) + readonly property color m3surfaceContainerHighest: tH(MaterialColors.colors.surface_container_highest) + + readonly property color m3onSurface: t(MaterialColors.colors.on_surface) + readonly property color m3surfaceVariant: t(MaterialColors.colors.surface_variant) + readonly property color m3onSurfaceVariant: t(MaterialColors.colors.on_surface_variant) + + readonly property color m3inverseSurface: t(MaterialColors.colors.inverse_surface) + readonly property color m3inverseOnSurface: t(MaterialColors.colors.inverse_on_surface) + + readonly property color m3outline: t(MaterialColors.colors.outline) + readonly property color m3outlineVariant: t(MaterialColors.colors.outline_variant) + readonly property color m3shadow: t(MaterialColors.colors.shadow) + readonly property color m3scrim: t(MaterialColors.colors.scrim) + readonly property color m3surfaceTint: t(MaterialColors.colors.surface_tint) + + readonly property color m3primary: t(MaterialColors.colors.primary) + readonly property color m3onPrimary: t(MaterialColors.colors.on_primary) + readonly property color m3primaryContainer: t(MaterialColors.colors.primary_container) + readonly property color m3onPrimaryContainer: t(MaterialColors.colors.on_primary_container) + readonly property color m3inversePrimary: t(MaterialColors.colors.inverse_primary) + + readonly property color m3secondary: t(MaterialColors.colors.secondary) + readonly property color m3onSecondary: t(MaterialColors.colors.on_secondary) + readonly property color m3secondaryContainer: t(MaterialColors.colors.secondary_container) + readonly property color m3onSecondaryContainer: t(MaterialColors.colors.on_secondary_container) + + readonly property color m3tertiary: t(MaterialColors.colors.tertiary) + readonly property color m3onTertiary: t(MaterialColors.colors.on_tertiary) + readonly property color m3tertiaryContainer: t(MaterialColors.colors.tertiary_container) + readonly property color m3onTertiaryContainer: t(MaterialColors.colors.on_tertiary_container) + + readonly property color m3error: t(MaterialColors.colors.error) + readonly property color m3onError: t(MaterialColors.colors.on_error) + readonly property color m3errorContainer: t(MaterialColors.colors.error_container) + readonly property color m3onErrorContainer: t(MaterialColors.colors.on_error_container) + + readonly property color m3primaryFixed: t(MaterialColors.colors.primary_fixed) + readonly property color m3primaryFixedDim: t(MaterialColors.colors.primary_fixed_dim) + readonly property color m3onPrimaryFixed: t(MaterialColors.colors.on_primary_fixed) + readonly property color m3onPrimaryFixedVariant: t(MaterialColors.colors.on_primary_fixed_variant) + + readonly property color m3secondaryFixed: t(MaterialColors.colors.secondary_fixed) + readonly property color m3secondaryFixedDim: t(MaterialColors.colors.secondary_fixed_dim) + readonly property color m3onSecondaryFixed: t(MaterialColors.colors.on_secondary_fixed) + readonly property color m3onSecondaryFixedVariant: t(MaterialColors.colors.on_secondary_fixed_variant) + + readonly property color m3tertiaryFixed: t(MaterialColors.colors.tertiary_fixed) + readonly property color m3tertiaryFixedDim: t(MaterialColors.colors.tertiary_fixed_dim) + readonly property color m3onTertiaryFixed: t(MaterialColors.colors.on_tertiary_fixed) + readonly property color m3onTertiaryFixedVariant: t(MaterialColors.colors.on_tertiary_fixed_variant) + + } + + + margin: QtObject { + property int supertiny: 2 + property int tinier: 3 + property int tiny: 6 + property int verysmall: 8 + property int small: 12 + property int normal: 16 + property int large: 22 + property int verylarge: 30 + property int extraLarge: 35 + } + + animation: QtObject { + + property var easing: Easing.OutExpo + + property QtObject durations: QtObject { + property int supershort: 100 + property int small: 200 + property int normal: 400 + property int large: 600 + property int extraLarge: 1000 + property int expressiveFastSpatial: 350 + property int expressiveDefaultSpatial: 500 + property int expressiveEffects: 200 + } + + property QtObject curves: QtObject { + readonly property list expressiveFastSpatial: [0.42, 1.67, 0.21, 0.90, 1, 1] // Default, 350ms + readonly property list expressiveDefaultSpatial: [0.38, 1.21, 0.22, 1.00, 1, 1] // Default, 500ms + readonly property list expressiveSlowSpatial: [0.39, 1.29, 0.35, 0.98, 1, 1] // Default, 650ms + readonly property list expressiveEffects: [0.34, 0.80, 0.34, 1.00, 1, 1] // Default, 200ms + readonly property list emphasized: [0.05, 0, 2 / 15, 0.06, 1 / 6, 0.4, 5 / 24, 0.82, 0.25, 1, 1, 1] + readonly property list emphasizedFirstHalf: [0.05, 0, 2 / 15, 0.06, 1 / 6, 0.4, 5 / 24, 0.82] + readonly property list emphasizedLastHalf: [5 / 24, 0.82, 0.25, 1, 1, 1] + readonly property list emphasizedAccel: [0.3, 0, 0.8, 0.15, 1, 1] + readonly property list emphasizedDecel: [0.05, 0.7, 0.1, 1, 1, 1] + readonly property list standard: [0.2, 0, 0, 1, 1, 1] + readonly property list standardAccel: [0.3, 0, 1, 1, 1, 1] + readonly property list standardDecel: [0, 0, 0, 1, 1, 1] + readonly property real expressiveFastSpatialDuration: 350 + readonly property real expressiveDefaultSpatialDuration: 500 + readonly property real expressiveSlowSpatialDuration: 650 + readonly property real expressiveEffectsDuration: 200 + } + + property QtObject elementMove: QtObject { + property int duration: animation.curves.expressiveDefaultSpatialDuration + property int type: Easing.BezierSpline + property list bezierCurve: animation.curves.expressiveDefaultSpatial + property Component numberAnimation: Component { + NumberAnimation { + duration: root.animation.elementMove.duration + easing.type: root.animation.elementMove.type + easing.bezierCurve: root.animation.elementMove.bezierCurve + } + } + } + + property QtObject elementMoveEnter: QtObject { + property int duration: 400 + property int type: Easing.BezierSpline + property list bezierCurve: animation.curves.emphasizedDecel + property Component numberAnimation: Component { + NumberAnimation { + duration: root.animation.elementMoveEnter.duration + easing.type: root.animation.elementMoveEnter.type + easing.bezierCurve: root.animation.elementMoveEnter.bezierCurve + } + } + } + + property QtObject elementMoveFast: QtObject { + property int duration: animation.curves.expressiveEffectsDuration + property int type: Easing.BezierSpline + property list bezierCurve: animation.curves.expressiveEffects + property Component numberAnimation: Component { + NumberAnimation { + duration: root.animation.elementMoveFast.duration + easing.type: root.animation.elementMoveFast.type + easing.bezierCurve: root.animation.elementMoveFast.bezierCurve + } + } + } + + } + + rounding: QtObject { + property int unsharpen: 2 * Config.runtime.appearance.rounding.factor + property int unsharpenmore: 6 * Config.runtime.appearance.rounding.factor + property int verysmall: 8 * Config.runtime.appearance.rounding.factor + property int small: 12 * Config.runtime.appearance.rounding.factor + property int normal: 17 * Config.runtime.appearance.rounding.factor + property int large: 23 * Config.runtime.appearance.rounding.factor + property int verylarge: 30 * Config.runtime.appearance.rounding.factor + property int childish: 50 * Config.runtime.appearance.rounding.factor // Idk why did I named this childish + property int full: 9999 + property int screenRounding: large + property int windowRounding: 18 * Config.runtime.appearance.rounding.factor + } + + font: QtObject { + property QtObject family: QtObject { + property string main: Config.runtime.appearance.font.families.main + property string title: Config.runtime.appearance.font.families.title + property string materialIcons: Config.runtime.appearance.font.families.materialIcons + property string nerdIcons: Config.runtime.appearance.font.families.nerdFonts + property string monospace: Config.runtime.appearance.font.families.monospace + property string reading: Config.runtime.appearance.font.families.reading + property string expressive: Config.runtime.appearance.font.families.expressive + } + property QtObject size: QtObject { + property int smallest: 10 + property int smaller: 12 + property int smallie: 13 + property int small: 15 + property int normal: 16 + property int large: 17 + property int larger: 19 + property int big: 21 + property int huge: 22 + property int hugeass: 23 + property int wildass: 40 + property int title: huge + + property QtObject icon: QtObject { + property int smallest: 10 + property int smaller: 12 + property int small: 14 + property int normal: 16 + property int large: 17 + property int larger: 19 + property int huge: 22 + property int hugeass: 23 + } + } + } + + syntaxHighlightingTheme: darkmode ? '#f1ebeb' : "#141333" +} \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/config/Config.qml b/.config/quickshell/nucleus-shell/config/Config.qml new file mode 100644 index 0000000..8feb50e --- /dev/null +++ b/.config/quickshell/nucleus-shell/config/Config.qml @@ -0,0 +1,282 @@ +pragma Singleton +pragma ComponentBehavior: Bound +import QtQuick +import QtCore +import Quickshell +import Quickshell.Io +import qs.plugins +import qs.services + +Singleton { + id: root + property string filePath: Directories.shellConfigPath + property alias runtime: configOptionsJsonAdapter + property bool initialized: false + property int readWriteDelay: 50 + property bool blockWrites: false + + function updateKey(nestedKey, value) { + let keys = nestedKey.split(".") + let obj = root.runtime + if (!obj) { + console.warn("Config.updateKey: adapter not available for key", nestedKey) + return + } + + for (let i = 0; i < keys.length - 1; ++i) { + let k = keys[i] + if (obj[k] === undefined || obj[k] === null || typeof obj[k] !== "object") { + obj[k] = {} // Use Plain JS for serialization + } + obj = obj[k] + if (!obj) { + console.warn("Config.updateKey: failed to resolve", k) + return + } + } + + let convertedValue = value + if (typeof value === "string") { + let trimmed = value.trim() + if (trimmed === "true" || trimmed === "false" || (!isNaN(Number(trimmed)) && trimmed !== "")) { + try { + convertedValue = JSON.parse(trimmed) + } catch (e) { + convertedValue = value + } + } + } + + obj[keys[keys.length - 1]] = convertedValue + configFileView.adapterUpdated() + } + + function loadPluginConfigs(plugins) { + console.log("Loading plugins:", plugins) + + if (!root.runtime) + return + + if (!root.runtime.plugins) + root.runtime.plugins = {} + + function mergeDefaults(target, defaults) { + let changed = false + + for (let key in defaults) { + const defVal = defaults[key] + const tgtVal = target[key] + + if (tgtVal === undefined) { + target[key] = defVal + changed = true + } else if ( + typeof tgtVal === "object" && + typeof defVal === "object" && + tgtVal !== null && + defVal !== null + ) { + if (mergeDefaults(tgtVal, defVal)) + changed = true + } + } + + return changed + } + + let anyChange = false + + for (let i = 0; i < plugins.length; i++) { + const name = plugins[i] + const path = Directories.shellConfig + "/plugins/" + name + "/PluginConfigData.qml" + + const component = Qt.createComponent(path) + if (component.status === Component.Error) { + console.warn("Plugin failed:", path, component.errorString()) + continue + } + + if (component.status !== Component.Ready) + continue + + const pluginObj = component.createObject(root) + if (!pluginObj) { + console.warn("Failed to create plugin object:", name) + component.destroy() + continue + } + + if (!pluginObj.defaults) + pluginObj.defaults = { enabled: false } + + if (!root.runtime.plugins[name]) { + root.runtime.plugins[name] = {} + anyChange = true + } + + if (mergeDefaults(root.runtime.plugins[name], pluginObj.defaults)) + anyChange = true + + console.log("Plugin config injected:", name) + + pluginObj.destroy() + component.destroy() + } + + if (anyChange) { + console.log("Plugin defaults merged, writing config") + configFileView.adapterUpdated() + } else { + console.log("Plugin configs already up to date") + } + } + + Timer { id: fileReloadTimer; interval: root.readWriteDelay; repeat: false; onTriggered: configFileView.reload() } + Timer { id: fileWriteTimer; interval: root.readWriteDelay; repeat: false; onTriggered: configFileView.writeAdapter() } + + Timer { // Used to output all log/debug to the terminal + interval: 1200 + running: true + repeat: false + onTriggered: { + console.log("Injecting plugin configs") + root.loadPluginConfigs(PluginLoader.plugins) + console.log("Detected Compositor:", Compositor.detectedCompositor) + } + } + + FileView { + id: configFileView + path: root.filePath + watchChanges: true + blockWrites: root.blockWrites + onFileChanged: fileReloadTimer.restart() + onAdapterUpdated: fileWriteTimer.restart() + onLoaded: { root.initialized = true } + onLoadFailed: error => { + if (error == FileViewError.FileNotFound) writeAdapter() + } + + JsonAdapter { + id: configOptionsJsonAdapter + + property var plugins: ({}) // dynamic plugins config variable + property var monitors: ({}) // per-monitor configuration for bars and wallpapers + + property JsonObject appearance: JsonObject { + property string theme: "dark" + property bool tintIcons: false + property JsonObject animations: JsonObject { property bool enabled: true; property double durationScale: 1 } + property JsonObject transparency: JsonObject { property bool enabled: false; property double alpha: 0.2 } + property JsonObject rounding: JsonObject { property double factor: 1 } + property JsonObject font: JsonObject { + property double scale: 1 + property JsonObject families: JsonObject { + property string main: "JetBrains Mono" + property string title: "Gabarito" + property string materialIcons: "Material Symbols Rounded" + property string nerdFonts: "JetBrains Mono NF" + property string monospace: "JetBrains Mono NF" + property string reading: "Readex Pro" + property string expressive: "Space Grotesk" + } + } + property JsonObject colors: JsonObject { + property string scheme: "catppuccin-lavender" + property string matugenScheme: "scheme-neutral" + property bool autogenerated: true + property bool runMatugenUserWide: false + } + property JsonObject background: JsonObject { + property bool enabled: true + property url defaultPath: Directories.defaultsPath + "/default.jpg" + property JsonObject parallax: JsonObject { + property bool enabled: true + property bool enableSidebarLeft: true + property bool enableSidebarRight: true + property real zoom: 1.10 + } + property JsonObject clock: JsonObject { + property bool enabled: true + property bool isAnalog: true + property bool rotatePolygonBg: false + property int rotationDuration: 18 // lower the faster + property int edgeSpacing: 50 + property int shape: 1 + property int xPos: 0 + property int yPos: 0 + property bool animateHands: false + } + property JsonObject slideshow: JsonObject { + property bool enabled: false + property bool includeSubfolders: true + property int interval: 5 + property string folder: "" + } + } + } + + property JsonObject misc: JsonObject { + property url pfp: Quickshell.env("HOME") + "/.face.icon" + property JsonObject intelligence: JsonObject { + property bool enabled: true + property string apiKey: "" + } + } + + property JsonObject notifications: JsonObject { + property bool enabled: true + property bool doNotDisturb: false + property string position: "center" + } + property JsonObject shell: JsonObject { + property string version: "0.7.7" + property string releaseChannel: "stable" + property string qsVersion: "0.0.0" + } + property JsonObject overlays: JsonObject { + property bool enabled: true + property bool volumeOverlayEnabled: true + property bool brightnessOverlayEnabled: true + property string volumeOverlayPosition: "top" + property string brightnessOverlayPosition: "top" + } + property JsonObject launcher: JsonObject { + property bool fuzzySearchEnabled: true + property string webSearchEngine: "google" + } + property JsonObject bar: JsonObject { + property string position: "top" + property bool enabled: true + property bool merged: false + property bool floating: false + property bool gothCorners: true + property int radius: Appearance.rounding.large + property int margins: Appearance.margin.normal + property int density: 50 + property JsonObject modules: JsonObject { + property color paddingColor: Appearance.m3colors.m3surfaceContainer + property int radius: Appearance.rounding.normal + property int height: 34 + property JsonObject workspaces: JsonObject { + property bool enabled: true + property int workspaceIndicators: 8 + property bool showAppIcons: true + property bool showJapaneseNumbers: false + } + property JsonObject statusIcons: JsonObject { + property bool enabled: true + property bool networkStatusEnabled: true + property bool bluetoothStatusEnabled: true + } + property JsonObject systemUsage: JsonObject { + property bool enabled: true + property bool cpuStatsEnabled: true + property bool memoryStatsEnabled: true + property bool tempStatsEnabled: true + } + } + } + } + } +} diff --git a/.config/quickshell/nucleus-shell/config/Directories.qml b/.config/quickshell/nucleus-shell/config/Directories.qml new file mode 100644 index 0000000..3f2f3c4 --- /dev/null +++ b/.config/quickshell/nucleus-shell/config/Directories.qml @@ -0,0 +1,39 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.modules.functions +import QtCore +import QtQuick +import Quickshell + +Singleton { + // XDG Dirs, with "file://" + readonly property string home: StandardPaths.standardLocations(StandardPaths.HomeLocation)[0] + readonly property string config: StandardPaths.standardLocations(StandardPaths.ConfigLocation)[0] + readonly property string state: StandardPaths.standardLocations(StandardPaths.StateLocation)[0] + readonly property string cache: StandardPaths.standardLocations(StandardPaths.CacheLocation)[0] + readonly property string genericCache: StandardPaths.standardLocations(StandardPaths.GenericCacheLocation)[0] + readonly property string documents: StandardPaths.standardLocations(StandardPaths.DocumentsLocation)[0] + readonly property string downloads: StandardPaths.standardLocations(StandardPaths.DownloadLocation)[0] + readonly property string pictures: StandardPaths.standardLocations(StandardPaths.PicturesLocation)[0] + readonly property string music: StandardPaths.standardLocations(StandardPaths.MusicLocation)[0] + readonly property string videos: StandardPaths.standardLocations(StandardPaths.MoviesLocation)[0] + + property string shellConfig: FileUtils.trimFileProtocol(`${Directories.config}/nucleus-shell`) + property string shellConfigName: "configuration.json" + property string shellConfigPath: `${Directories.shellConfig}/config/${Directories.shellConfigName}` + property string generatedMaterialThemePath: FileUtils.trimFileProtocol(`${Directories.config}/nucleus-shell/config/colors.json`) + property string defaultsPath: Quickshell.shellPath("defaults") + property string scriptsPath: Quickshell.shellPath("scripts") + property string assetsPath: Quickshell.shellPath("assets") + // Cleanup on init + Component.onCompleted: { + Quickshell.execDetached(["mkdir", "-p", `${shellConfig}`]) + Quickshell.execDetached(["mkdir", "-p", `${shellConfig}/config`]) + Quickshell.execDetached(["mkdir", "-p", `${shellConfig}/plugins`]) + Quickshell.execDetached(["mkdir", "-p", `${FileUtils.trimFileProtocol(Directories.pictures)}/Screenshots`]) + // Create dirs for intelligence shit + Quickshell.execDetached(["mkdir", "-p", FileUtils.trimFileProtocol(`${config}/zenith/`), FileUtils.trimFileProtocol(`${config}/zenith/chats`)]) + Quickshell.execDetached(["touch", FileUtils.trimFileProtocol(`${config}/zenith/chats/default.txt`)]) + } +} \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/config/Globals.qml b/.config/quickshell/nucleus-shell/config/Globals.qml new file mode 100644 index 0000000..653c9a9 --- /dev/null +++ b/.config/quickshell/nucleus-shell/config/Globals.qml @@ -0,0 +1,23 @@ +import QtQuick +pragma Singleton +pragma ComponentBehavior: Bound +import Quickshell + +Singleton { + id: root + property QtObject visiblility + property QtObject states + + visiblility: QtObject { + property bool powermenu: false + property bool launcher: false + property bool sidebarRight: false + property bool sidebarLeft: false + } + + states: QtObject { + property bool settingsOpen: false + property bool intelligenceWindowOpen: false + } + +} diff --git a/.config/quickshell/nucleus-shell/config/Ipc.qml b/.config/quickshell/nucleus-shell/config/Ipc.qml new file mode 100644 index 0000000..78841c4 --- /dev/null +++ b/.config/quickshell/nucleus-shell/config/Ipc.qml @@ -0,0 +1,97 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import qs.services +import qs.modules.interface.settings + +Scope { + id: global + + // Track if notification has already been shown + property bool themeNotificationShown: false + + // Function to show notification once + function notifyPredefinedTheme() { + if (!themeNotificationShown) { + themeNotificationShown = true + Quickshell.execDetached([ + "notify-send", + "Nucleus Shell", + `You are using a predefined theme ${Config.runtime.appearance.colors.scheme}. Color generation/Light Theme is not supported for this theme.`, + "--urgency=normal", + "--expire-time=5000" + ]) + } + } + + // Handle Global IPCs + IpcHandler { + target: "global" + + function toggleTheme() { + const currentTheme = Config.runtime.appearance.theme + const newTheme = currentTheme === "light" ? "dark" : "light" + + // Predefined themes: validate variant BEFORE changing theme + if (!Config.runtime.appearance.colors.autogenerated) { + const scheme = Config.runtime.appearance.colors.scheme + const file = Theme.map[scheme]?.[newTheme] + + if (!file) { + Theme.notifyMissingVariant(scheme, newTheme) + return + } + + Config.updateKey("appearance.theme", newTheme) + + Quickshell.execDetached([ + "bash", + Directories.scriptsPath + "/interface/switchTheme.sh", + file + ]) + return + } + + // Autogenerated themes: safe to toggle freely + Config.updateKey("appearance.theme", newTheme) + genThemeColors.running = true + } + + function regenColors() { + if (Config.runtime.appearance.colors.autogenerated) { + genThemeColors.running = true + } else { + notifyPredefinedTheme() + } + } + } + + property var genColorsCmd: Config.runtime.appearance.colors.runMatugenUserWide + ? [ + "bash", + Directories.scriptsPath + "/interface/gencolors.sh", + "--user-wide", + Config.runtime.appearance.background.path, + Config.runtime.appearance.colors.matugenScheme, + Config.runtime.appearance.theme, + Quickshell.shellPath("extras/matugen/config.toml") + ] + : [ + "bash", + Directories.scriptsPath + "/interface/gencolors.sh", + Config.runtime.appearance.background.path, + Config.runtime.appearance.colors.matugenScheme, + Config.runtime.appearance.theme, + Quickshell.shellPath("extras/matugen/config.toml") + ]; + + + + // Process to generate colors + Process { + id: genThemeColors + + command: genColorsCmd + } + +} diff --git a/.config/quickshell/nucleus-shell/config/MaterialColors.qml b/.config/quickshell/nucleus-shell/config/MaterialColors.qml new file mode 100644 index 0000000..4fe949a --- /dev/null +++ b/.config/quickshell/nucleus-shell/config/MaterialColors.qml @@ -0,0 +1,89 @@ +pragma Singleton +pragma ComponentBehavior: Bound +import QtQuick +import QtCore +import Quickshell +import Quickshell.Io + +Singleton { + id: m3colors + property string filePath: Directories.generatedMaterialThemePath + property alias colors: colorsJsonAdapter + property bool ready: false + + FileView { + id: colorsFileView + path: m3colors.filePath + watchChanges: true + onLoaded: m3colors.ready = true + onFileChanged: colorsFileView.reload() + onLoadFailed: error => { + if (error === FileViewError.FileNotFound) { + console.warn("MaterialColors: colors.json not found, writing defaults") + writeAdapter() + } else { + console.error("MaterialColors: failed to load colors.json:", error) + } + } + + JsonAdapter { + id: colorsJsonAdapter + + // === Default Matugen color scheme === + property string background: "#131313" + property string error: "#ffb4ab" + property string error_container: "#93000a" + property string inverse_on_surface: "#303030" + property string inverse_primary: "#00677f" + property string inverse_surface: "#e2e2e2" + property string on_background: "#e2e2e2" + property string on_error: "#690005" + property string on_error_container: "#ffdad6" + property string on_primary: "#003543" + property string on_primary_container: "#b7eaff" + property string on_primary_fixed: "#001f28" + property string on_primary_fixed_variant: "#004e60" + property string on_secondary: "#1e333b" + property string on_secondary_container: "#cfe6f1" + property string on_secondary_fixed: "#071e26" + property string on_secondary_fixed_variant: "#344a52" + property string on_surface: "#e2e2e2" + property string on_surface_variant: "#c6c6c6" + property string on_tertiary: "#2c2e4d" + property string on_tertiary_container: "#e0e0ff" + property string on_tertiary_fixed: "#171937" + property string on_tertiary_fixed_variant: "#424465" + property string outline: "#919191" + property string outline_variant: "#474747" + property string primary: '#a571f2' + property string primary_container: "#004e60" + property string primary_fixed: "#b7eaff" + property string primary_fixed_dim: "#5cd5fb" + property string scrim: "#000000" + property string secondary: "#b3cad4" + property string secondary_container: "#344a52" + property string secondary_fixed: "#cfe6f1" + property string secondary_fixed_dim: "#b3cad4" + property string shadow: "#000000" + property string source_color: "#829aa4" + property string surface: "#131313" + property string surface_bright: "#393939" + property string surface_container: "#1f1f1f" + property string surface_container_high: "#2a2a2a" + property string surface_container_highest: "#353535" + property string surface_container_low: "#1b1b1b" + property string surface_container_lowest: "#0e0e0e" + property string surface_dim: "#131313" + property string surface_tint: "#5cd5fb" + property string surface_variant: "#474747" + property string tertiary: "#c3c3eb" + property string tertiary_container: "#424465" + property string tertiary_fixed: "#e0e0ff" + property string tertiary_fixed_dim: "#c3c3eb" + } + } + + function reload() { + colorsFileView.reload() + } +} \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/config/Metrics.qml b/.config/quickshell/nucleus-shell/config/Metrics.qml new file mode 100644 index 0000000..cc063ac --- /dev/null +++ b/.config/quickshell/nucleus-shell/config/Metrics.qml @@ -0,0 +1,133 @@ +import QtQuick +import Quickshell +pragma Singleton +pragma ComponentBehavior: Bound + +Singleton { + id: root + + readonly property double durationScale: Config.runtime.appearance.animations.durationScale + readonly property double roundingScale: Config.runtime.appearance.rounding.factor + readonly property double fontScale: Config.runtime.appearance.font.scale + + function spacing(value) { // These will be used with a scale later on... + return value + } + + function padding(value) { + return value + } + + function chronoDuration(value) { + if (typeof value === "number") + return value * durationScale + + switch (value) { + case "supershort": return Appearance.animation.durations.supershort * durationScale + case "small": return Appearance.animation.durations.small * durationScale + case "normal": return Appearance.animation.durations.normal * durationScale + case "large": return Appearance.animation.durations.large * durationScale + case "extraLarge": return Appearance.animation.durations.extraLarge * durationScale + case "expressiveFastSpatial": return Appearance.animation.durations.expressiveFastSpatial * durationScale + case "expressiveDefaultSpatial": return Appearance.animation.durations.expressiveDefaultSpatial * durationScale + case "expressiveEffects": return Appearance.animation.durations.expressiveEffects * durationScale + default: return 0 + } + } + + function margin(value) { + if (typeof value === "number") + return value + + switch (value) { + case "supertiny": return Appearance.margin.supertiny + case "tinier": return Appearance.margin.tinier + case "tiny": return Appearance.margin.tiny + case "verysmall": return Appearance.margin.verysmall + case "small": return Appearance.margin.small + case "normal": return Appearance.margin.normal + case "large": return Appearance.margin.large + case "verylarge": return Appearance.margin.verylarge + case "extraLarge": return Appearance.margin.extraLarge + default: return 0 + } + } + + function radius(value) { + if (typeof value === "number") + return value * roundingScale + + switch (value) { + case "unsharpen": return Appearance.rounding.unsharpen * roundingScale + case "unsharpenmore": return Appearance.rounding.unsharpenmore * roundingScale + case "verysmall": return Appearance.rounding.verysmall * roundingScale + case "small": return Appearance.rounding.small * roundingScale + case "normal": return Appearance.rounding.normal * roundingScale + case "large": return Appearance.rounding.large * roundingScale + case "verylarge": return Appearance.rounding.verylarge * roundingScale + case "childish": return Appearance.rounding.childish * roundingScale + case "full": return Appearance.rounding.full * roundingScale + case "screenRounding": return Appearance.rounding.screenRounding * roundingScale + case "windowRounding": return Appearance.rounding.windowRounding * roundingScale + default: return 0 + } + } + + function fontSize(value) { + if (typeof value === "number") + return value * fontScale + + switch (value) { + case "smallest": return Appearance.font.size.smallest * fontScale + case "smaller": return Appearance.font.size.smaller * fontScale + case "smallie": return Appearance.font.size.smallie * fontScale + case "small": return Appearance.font.size.small * fontScale + case "normal": return Appearance.font.size.normal * fontScale + case "large": return Appearance.font.size.large * fontScale + case "larger": return Appearance.font.size.larger * fontScale + case "big": return Appearance.font.size.big * fontScale + case "huge": return Appearance.font.size.huge * fontScale + case "hugeass": return Appearance.font.size.hugeass * fontScale + case "wildass": return Appearance.font.size.wildass * fontScale + case "title": return Appearance.font.size.title * fontScale + default: return Appearance.font.size.normal * fontScale + } + } + + function iconSize(value) { + if (typeof value === "number") + return value * fontScale + + switch (value) { + case "smallest": return Appearance.font.size.icon.smallest * fontScale + case "smaller": return Appearance.font.size.icon.smaller * fontScale + case "smallie": return Appearance.font.size.icon.smallie * fontScale + case "small": return Appearance.font.size.icon.small * fontScale + case "normal": return Appearance.font.size.icon.normal * fontScale + case "large": return Appearance.font.size.icon.large * fontScale + case "larger": return Appearance.font.size.icon.larger * fontScale + case "big": return Appearance.font.size.icon.big * fontScale + case "huge": return Appearance.font.size.icon.huge * fontScale + case "hugeass": return Appearance.font.size.icon.hugeass * fontScale + case "wildass": return Appearance.font.size.icon.wildass * fontScale + case "title": return Appearance.font.size.icon.title * fontScale + default: return Appearance.font.size.icon.normal * fontScale + } + } + + function fontFamily(value) { + if (typeof value === "string") { + switch (value) { + case "main": return Appearance.font.family.main + case "title": return Appearance.font.family.title + case "materialIcons": return Appearance.font.family.materialIcons + case "nerdIcons": return Appearance.font.family.nerdIcons + case "monospace": return Appearance.font.family.monospace + case "reading": return Appearance.font.family.reading + case "expressive": return Appearance.font.family.expressive + default: return value + } + } + return Appearance.font.family.main + } +} diff --git a/.config/quickshell/nucleus-shell/defaults/default.jpg b/.config/quickshell/nucleus-shell/defaults/default.jpg new file mode 100644 index 0000000..50b694d Binary files /dev/null and b/.config/quickshell/nucleus-shell/defaults/default.jpg differ diff --git a/.config/quickshell/nucleus-shell/extras/matugen/config.toml b/.config/quickshell/nucleus-shell/extras/matugen/config.toml new file mode 100644 index 0000000..113ca5e --- /dev/null +++ b/.config/quickshell/nucleus-shell/extras/matugen/config.toml @@ -0,0 +1,6 @@ +[config] +reload = false + +[templates.nucleus] +input_path = './templates/colors.json' +output_path = '~/.config/nucleus-shell/config/colors.json' \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/extras/matugen/templates/colors.json b/.config/quickshell/nucleus-shell/extras/matugen/templates/colors.json new file mode 100644 index 0000000..ab420fe --- /dev/null +++ b/.config/quickshell/nucleus-shell/extras/matugen/templates/colors.json @@ -0,0 +1,6 @@ +{ + "_comment": "Material Colors generated with Matugen" +<* for name, value in colors *> + , "{{ name }}": "{{ value.default.hex }}" +<* endfor *> +} \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/modules/components/CircularProgressBar.qml b/.config/quickshell/nucleus-shell/modules/components/CircularProgressBar.qml new file mode 100644 index 0000000..1276f8c --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/components/CircularProgressBar.qml @@ -0,0 +1,57 @@ +import QtQuick +import Quickshell +import qs.config + +Item { + id: root + + property real value: 0.65 // 0.0 → 1.0 + property real strokeWidth: 2 + property color bgColor: Appearance.m3colors.m3secondaryContainer + property color fgColor: Appearance.m3colors.m3primary + property string icon: "battery_full" + property int iconSize: Metrics.iconSize(20) + property bool fillIcon: false + + width: 22 + height: 24 + onValueChanged: canvas.requestPaint() + + Canvas { + id: canvas + + anchors.fill: parent + onPaint: { + const ctx = getContext("2d"); + ctx.clearRect(0, 0, width, height); + const cx = width / 2; + const cy = height / 2; + const r = (width - root.strokeWidth) / 2; + const start = -Math.PI / 2; + const end = start + 2 * Math.PI * root.value; + ctx.lineWidth = root.strokeWidth; + ctx.lineCap = "round"; + // background ring + ctx.strokeStyle = root.bgColor; + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, 2 * Math.PI); + ctx.stroke(); + // progress ring + ctx.strokeStyle = root.fgColor; + ctx.beginPath(); + ctx.arc(cx, cy, r, start, end); + ctx.stroke(); + } + } + + // CENTER ICON + MaterialSymbol { + anchors.centerIn: parent + icon: root.icon + iconSize: root.iconSize + font.variableAxes: { + "FILL": root.fillIcon ? 1 : 0 + } + } + +} diff --git a/.config/quickshell/nucleus-shell/modules/components/ContentCard.qml b/.config/quickshell/nucleus-shell/modules/components/ContentCard.qml new file mode 100644 index 0000000..a6f5601 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/components/ContentCard.qml @@ -0,0 +1,38 @@ +import qs.config +import QtQuick +import QtQuick.Layouts + +Item { + id: contentCard + implicitWidth: parent ? parent.width : 600 + implicitHeight: contentArea.implicitHeight + verticalPadding + + default property alias content: contentArea.data + property alias color: bg.color + property alias radius: bg.radius + property int cardMargin: Metrics.margin("normal") + property int cardSpacing: Metrics.margin("small") + property int verticalPadding: Metrics.margin("verylarge") + property bool useAnims: true + + Rectangle { + id: bg + anchors.fill: parent + radius: Metrics.radius("normal") + color: Appearance.colors.colLayer1 + + Behavior on color { + enabled: Config.runtime.appearance.animations.enabled + ColorAnimation { + duration: !contentCard.useAnims ? 0 : Metrics.chronoDuration("fast") + } + } + } + + ColumnLayout { + id: contentArea + anchors.fill: parent + anchors.margins: cardMargin + spacing: cardSpacing + } +} diff --git a/.config/quickshell/nucleus-shell/modules/components/ContentMenu.qml b/.config/quickshell/nucleus-shell/modules/components/ContentMenu.qml new file mode 100644 index 0000000..c80fd1f --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/components/ContentMenu.qml @@ -0,0 +1,101 @@ +import qs.config +import QtQuick +import QtQuick.Layouts + +Item { + id: contentMenu + Layout.fillWidth: true + Layout.fillHeight: true + + opacity: visible ? 1 : 0 + scale: visible ? 1 : 0.95 + + Behavior on opacity { + enabled: Config.runtime.appearance.animations.enabled + NumberAnimation { + duration: Metrics.chronoDuration("normal") + easing.type: Appearance.animation.curves.standard[0] // using standard easing + } + } + Behavior on scale { + enabled: Config.runtime.appearance.animations.enabled + NumberAnimation { + duration: Metrics.chronoDuration("normal") + easing.type: Appearance.animation.curves.standard[0] + } + } + + property string title: "" + property string description: "" + default property alias content: stackedSections.data + + Item { + id: headerArea + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.topMargin: Metrics.margin("verylarge") + anchors.leftMargin: Metrics.margin("verylarge") + anchors.rightMargin: Metrics.margin("verylarge") + width: parent.width + + ColumnLayout { + id: headerContent + anchors.left: parent.left + anchors.right: parent.right + spacing: Metrics.margin("small") + + ColumnLayout { + StyledText { + text: contentMenu.title + font.pixelSize: Metrics.fontSize("huge") + font.bold: true + font.family: Metrics.fontFamily("title") + } + StyledText { + text: contentMenu.description + font.pixelSize: Metrics.fontSize("small") + } + } + + Rectangle { + id: hr + Layout.alignment: Qt.AlignLeft | Qt.AlignRight + implicitHeight: 1 + } + } + + height: headerContent.implicitHeight + } + + Flickable { + id: mainScroll + anchors.left: parent.left + anchors.right: parent.right + anchors.top: headerArea.bottom + anchors.bottom: parent.bottom + anchors.leftMargin: Metrics.margin("verylarge") + anchors.rightMargin: Metrics.margin("verylarge") + anchors.topMargin: Metrics.margin("normal") + clip: true + interactive: true + boundsBehavior: Flickable.StopAtBounds + flickableDirection: Flickable.VerticalFlick + + contentHeight: mainContent.childrenRect.height + Appearance.margin.small + contentWidth: width + + Item { + id: mainContent + width: mainScroll.width + height: mainContent.childrenRect.height + + Column { + id: stackedSections + width: Math.min(mainScroll.width, 1000) + x: (mainContent.width - width) / 2 + spacing: Appearance.margin.normal + } + } + } +} \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/modules/components/ContentRowCard.qml b/.config/quickshell/nucleus-shell/modules/components/ContentRowCard.qml new file mode 100644 index 0000000..467fbeb --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/components/ContentRowCard.qml @@ -0,0 +1,51 @@ +import qs.config +import QtQuick +import QtQuick.Layouts + +Item { + id: baseCard + + Layout.fillWidth: true + + implicitHeight: wpBG.implicitHeight + + default property alias content: contentArea.data + property alias color: wpBG.color + + property int cardMargin: Metrics.margin(20) + property int cardSpacing: Metrics.spacing(10) + property int radius: Metrics.radius("large") + property int verticalPadding: Metrics.padding(40) + + Rectangle { + id: wpBG + anchors.left: parent.left + anchors.right: parent.right + implicitHeight: contentArea.implicitHeight + baseCard.verticalPadding + Behavior on implicitHeight { + enabled: Config.runtime.appearance.animations.enabled + NumberAnimation { + duration: Metrics.chronoDuration("small") + easing.type: Easing.InOutExpo + } + } + color: Appearance.m3colors.m3surfaceContainerLow + Behavior on color { + enabled: Config.runtime.appearance.animations.enabled + ColorAnimation { + duration: Metrics.chronoDuration("small") + easing.type: Easing.InOutExpo + } + } + radius: baseCard.radius + } + + RowLayout { + id: contentArea + anchors.top: wpBG.top + anchors.left: wpBG.left + anchors.right: wpBG.right + anchors.margins: baseCard.cardMargin + spacing: baseCard.cardSpacing + } +} \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/modules/components/InfoCard.qml b/.config/quickshell/nucleus-shell/modules/components/InfoCard.qml new file mode 100644 index 0000000..d3f4116 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/components/InfoCard.qml @@ -0,0 +1,58 @@ +import qs.config +import QtQuick +import QtQuick.Layouts + +ContentRowCard { + id: infoCard + + // --- Properties --- + property string icon: "info" + property color backgroundColor: Appearance.m3colors.darkMode ? Qt.lighter(Appearance.m3colors.m3error, 3.5) : Qt.lighter(Appearance.m3colors.m3error, 1) + property color contentColor: Appearance.m3colors.m3onPrimary + property string title: "Title" + property string description: "Description" + + color: backgroundColor + cardSpacing: Metrics.spacing(12) // nice spacing between elements + + RowLayout { + id: mainLayout + Layout.fillHeight: true + Layout.fillWidth: true + + spacing: Metrics.spacing(16) + Layout.alignment: Qt.AlignVCenter + + // --- Icon --- + MaterialSymbol { + id: infoIcon + icon: infoCard.icon + iconSize: Metrics.iconSize(26) + color: contentColor + Layout.alignment: Qt.AlignVCenter + } + + // --- Text column --- + ColumnLayout { + spacing: Metrics.spacing(2) + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + + StyledText { + text: infoCard.title + font.bold: true + color: contentColor + font.pixelSize: Metrics.fontSize(14) + Layout.fillWidth: true + } + + StyledText { + text: infoCard.description + color: contentColor + font.pixelSize: Metrics.fontSize(12) + Layout.fillWidth: true + wrapMode: Text.WordWrap + } + } + } +} diff --git a/.config/quickshell/nucleus-shell/modules/components/LoadingIcon.qml b/.config/quickshell/nucleus-shell/modules/components/LoadingIcon.qml new file mode 100644 index 0000000..6e0aac1 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/components/LoadingIcon.qml @@ -0,0 +1,26 @@ +import QtQuick +import qs.config + +Item { + id: root + property alias icon: mIcon.icon + property real size: Metrics.iconSize(28) + width: size + height: size + MaterialSymbol { + id: mIcon + anchors.centerIn: parent + icon: "progress_activity" + font.pixelSize: root.size + color: Appearance.m3colors.m3primary + renderType: Text.QtRendering + } + RotationAnimator on rotation { + target: mIcon + running: true + loops: Animation.Infinite + from: 0 + to: 360 + duration: Metrics.chronoDuration(1000) + } +} \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/modules/components/MaterialSymbol.qml b/.config/quickshell/nucleus-shell/modules/components/MaterialSymbol.qml new file mode 100644 index 0000000..229d6bc --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/components/MaterialSymbol.qml @@ -0,0 +1,15 @@ +import QtQuick +import qs.config + +StyledText { + property string icon: "" + property int fill: 0 + property int iconSize: Metrics.iconSize("large") + + font.family: Appearance.font.family.materialIcons + font.pixelSize: iconSize + text: icon + font.variableAxes: { + "FILL": fill + } +} diff --git a/.config/quickshell/nucleus-shell/modules/components/MaterialSymbolButton.qml b/.config/quickshell/nucleus-shell/modules/components/MaterialSymbolButton.qml new file mode 100644 index 0000000..39b5ee8 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/components/MaterialSymbolButton.qml @@ -0,0 +1,56 @@ +import QtQuick +import Quickshell +import qs.config + +MaterialSymbol { + id: root + + // Expose mouse props + property alias enabled: ma.enabled + property alias hoverEnabled: ma.hoverEnabled + property alias pressed: ma.pressed + property string tooltipText: "" + + // Renamed signals (no collisions possible) + signal buttonClicked() + signal buttonEntered() + signal buttonExited() + signal buttonPressAndHold() + signal buttonPressedChanged(bool pressed) + + MouseArea { + id: ma + + anchors.fill: parent + hoverEnabled: true + onClicked: root.buttonClicked() + onEntered: root.buttonEntered() + onExited: root.buttonExited() + onPressAndHold: root.buttonPressAndHold() + onPressedChanged: root.buttonPressedChanged(pressed) + } + + HoverHandler { + id: hover + + enabled: root.tooltipText !== "" + } + + LazyLoader { + active: root.tooltipText !== "" + StyledPopout { + hoverTarget: hover + hoverDelay: Metrics.chronoDuration(500) + + Component { + StyledText { + text: root.tooltipText + } + + } + + } + + } + +} diff --git a/.config/quickshell/nucleus-shell/modules/components/NumberStepper.qml b/.config/quickshell/nucleus-shell/modules/components/NumberStepper.qml new file mode 100644 index 0000000..521b601 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/components/NumberStepper.qml @@ -0,0 +1,88 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import qs.config + +RowLayout { + id: root + + property string label: "" + property string description: "" + property string prefField: "" + property double step: 1.0 + property double minimum: -2.14748e+09 // Largest num I could find and type ig + property double maximum: 2.14748e+09 + + // Floating-point value + property double value: readValue() + + function readValue() { + if (!prefField) + return 0; + + var parts = prefField.split('.'); + var cur = Config.runtime; + + for (var i = 0; i < parts.length; ++i) { + if (cur === undefined || cur === null) + return 0; + cur = cur[parts[i]]; + } + + var n = Number(cur); + return isNaN(n) ? 0 : n; + } + + function writeValue(v) { + if (!prefField) + return; + + var nv = Math.max(minimum, Math.min(maximum, v)); + nv = Number(nv.toFixed(2)); // precision control (adjust if needed) + Config.updateKey(prefField, nv); + } + + spacing: Metrics.spacing(8) + Layout.alignment: Qt.AlignVCenter + + ColumnLayout { + spacing: Metrics.spacing(2) + + StyledText { + text: root.label + font.pixelSize: Metrics.fontSize(14) + } + + StyledText { + text: root.description + font.pixelSize: Metrics.fontSize(10) + } + } + + Item { Layout.fillWidth: true } + + RowLayout { + spacing: Metrics.spacing(6) + + StyledButton { + text: "-" + implicitWidth: 36 + onClicked: writeValue(readValue() - step) + } + + StyledText { + text: value.toFixed(2) + font.pixelSize: Metrics.fontSize(14) + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + width: 72 + elide: Text.ElideRight + } + + StyledButton { + text: "+" + implicitWidth: 36 + onClicked: writeValue(readValue() + step) + } + } +} diff --git a/.config/quickshell/nucleus-shell/modules/components/StyledButton.qml b/.config/quickshell/nucleus-shell/modules/components/StyledButton.qml new file mode 100644 index 0000000..2ab1aa2 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/components/StyledButton.qml @@ -0,0 +1,185 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import qs.config +import qs.modules.functions + +Control { + id: root + + property alias text: label.text + property string icon: "" + property int iconSize: Metrics.iconSize(20) + property alias radius: background.radius + property alias topLeftRadius: background.topLeftRadius + property alias topRightRadius: background.topRightRadius + property alias bottomLeftRadius: background.bottomLeftRadius + property alias bottomRightRadius: background.bottomRightRadius + property bool checkable: false + property bool checked: true + property bool secondary: false + property string tooltipText: "" + property bool usePrimary: secondary ? false : checked + property color base_bg: usePrimary ? Appearance.m3colors.m3primary : Appearance.m3colors.m3secondaryContainer + property color base_fg: usePrimary ? Appearance.m3colors.m3onPrimary : Appearance.m3colors.m3onSecondaryContainer + property color disabled_bg: ColorUtils.transparentize(base_bg, 0.4) + property color disabled_fg: ColorUtils.transparentize(base_fg, 0.4) + property color hover_bg: Qt.lighter(base_bg, 1.1) + property color pressed_bg: Qt.darker(base_bg, 1.2) + property color backgroundColor: !root.enabled ? disabled_bg : mouse_area.pressed ? pressed_bg : mouse_area.containsMouse ? hover_bg : base_bg + property color textColor: !root.enabled ? disabled_fg : base_fg + property bool beingHovered: mouse_area.containsMouse + + signal clicked() + signal toggled(bool checked) + + implicitWidth: (label.text === "" && icon !== "") ? implicitHeight : row.implicitWidth + implicitHeight + implicitHeight: 40 + + MouseArea { + id: mouse_area + + anchors.fill: parent + hoverEnabled: root.enabled + cursorShape: root.enabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor + onClicked: { + if (!root.enabled) + return ; + + if (root.checkable) { + root.checked = !root.checked; + root.toggled(root.checked); + } + root.clicked(); + } + } + + HoverHandler { + id: hover + + enabled: root.tooltipText !== "" + } + + LazyLoader { + active: root.tooltipText !== "" + + StyledPopout { + hoverTarget: hover + hoverDelay: Metrics.chronoDuration(500) + + Component { + StyledText { + text: root.tooltipText + } + + } + + } + + } + + contentItem: Item { + anchors.fill: parent + + Row { + id: row + + anchors.centerIn: parent + spacing: root.icon !== "" && label.text !== "" ? 5 : 0 + + MaterialSymbol { + visible: root.icon !== "" + icon: root.icon + font.pixelSize: root.iconSize + color: root.textColor + anchors.verticalCenter: parent.verticalCenter + + Behavior on color { + ColorAnimation { + duration: Metrics.chronoDuration("small") / 2 + easing.type: Appearance.animation.easing + } + + } + + } + + StyledText { + id: label + + color: root.textColor + anchors.verticalCenter: parent.verticalCenter + elide: Text.ElideRight + + Behavior on color { + ColorAnimation { + duration: Metrics.chronoDuration("small") / 2 + easing.type: Appearance.animation.easing + } + + } + + } + + } + + } + + background: Rectangle { + id: background + + radius: Metrics.radius("large") + color: root.backgroundColor + + Behavior on color { + ColorAnimation { + duration: Metrics.chronoDuration("small") / 2 + easing.type: Appearance.animation.easing + } + + } + + Behavior on radius { + NumberAnimation { + duration: Metrics.chronoDuration("small") / 2 + easing.type: Appearance.animation.easing + } + + } + + Behavior on topLeftRadius { + NumberAnimation { + duration: Metrics.chronoDuration("small") + easing.type: Appearance.animation.easing + } + + } + + Behavior on topRightRadius { + NumberAnimation { + duration: Metrics.chronoDuration("small") + easing.type: Appearance.animation.easing + } + + } + + Behavior on bottomLeftRadius { + NumberAnimation { + duration: Metrics.chronoDuration("small") + easing.type: Appearance.animation.easing + } + + } + + Behavior on bottomRightRadius { + NumberAnimation { + duration: Metrics.chronoDuration("small") + easing.type: Appearance.animation.easing + } + + } + + } + +} diff --git a/.config/quickshell/nucleus-shell/modules/components/StyledDropDown.qml b/.config/quickshell/nucleus-shell/modules/components/StyledDropDown.qml new file mode 100644 index 0000000..ae7080d --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/components/StyledDropDown.qml @@ -0,0 +1,196 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Effects +import qs.config +import qs.modules.functions + +Item { + id: root + width: 200 + height: 56 + + property string label: "Select option" + property var model: ["Option 1", "Option 2", "Option 3", "Option 4", "Option 5"] + property int currentIndex: -1 + property string currentText: { + if (currentIndex < 0) + return "" + + if (textRole && model && model.get) + return model.get(currentIndex)[textRole] ?? "" + + return model[currentIndex] ?? "" + } + property bool enabled: true + property string textRole: "" + + signal selectedIndexChanged(int index) + + Rectangle { + id: container + anchors.fill: parent + color: "transparent" + border.color: dropdown.activeFocus ? Appearance.m3colors.m3primary : Appearance.m3colors.m3outline + border.width: dropdown.activeFocus ? 2 : 1 + radius: Metrics.radius("unsharpen") + + Behavior on border.color { + enabled: Config.runtime.appearance.animations.enabled + ColorAnimation { duration: Metrics.chronoDuration("small") ; easing.type: Easing.InOutCubic } + } + Behavior on border.width { + enabled: Config.runtime.appearance.animations.enabled + NumberAnimation { duration: Metrics.chronoDuration("small") ; easing.type: Easing.InOutCubic } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + enabled: root.enabled + hoverEnabled: true + onClicked: dropdown.popup.visible ? dropdown.popup.close() : dropdown.popup.open() + + Rectangle { + anchors.fill: parent + radius: parent.parent.radius + color: Appearance.m3colors.m3primary + opacity: mouseArea.pressed ? 0.12 : mouseArea.containsMouse ? 0.08 : 0 + + Behavior on opacity { + enabled: Config.runtime.appearance.animations.enabled + NumberAnimation { duration: Metrics.chronoDuration("small") ; easing.type: Easing.InOutCubic } + } + } + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Metrics.margin(16) + anchors.rightMargin: Metrics.margin(12) + spacing: Metrics.spacing(12) + + StyledText { + id: labelText + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + text: root.currentIndex >= 0 ? root.currentText : root.label + color: root.currentIndex >= 0 + ? Appearance.m3colors.m3onSurface + : ColorUtils.transparentize(Appearance.m3colors.m3onSurfaceVariant, 0.7) + font.pixelSize: Metrics.fontSize(16) + elide: Text.ElideRight + verticalAlignment: Text.AlignVCenter + } + + MaterialSymbol { + id: dropdownIcon + Layout.alignment: Qt.AlignVCenter + icon: dropdown.popup.visible ? "arrow_drop_up" : "arrow_drop_down" + iconSize: Metrics.iconSize(20) + color: Appearance.m3colors.m3onSurfaceVariant + } + } + } + + ComboBox { + id: dropdown + visible: false + model: root.model + currentIndex: root.currentIndex >= 0 ? root.currentIndex : -1 + enabled: root.enabled + textRole: root.textRole + + onCurrentIndexChanged: { + if (currentIndex >= 0) { + root.currentIndex = currentIndex + root.selectedIndexChanged(currentIndex) + } + } + + popup: Popup { + y: root.height + 4 + width: root.width + padding: 0 + + background: Rectangle { + color: Appearance.m3colors.m3surfaceContainer + radius: Metrics.radius(4) + border.color: Appearance.m3colors.m3outline + border.width: 1 + layer.enabled: true + layer.effect: MultiEffect { + shadowEnabled: true + shadowColor: ColorUtils.transparentize(Appearance.m3colors.m3shadow, 0.25) + shadowBlur: 0.4 + shadowVerticalOffset: 8 + shadowHorizontalOffset: 0 + } + } + + contentItem: ListView { + id: listView + clip: true + implicitHeight: Math.min(contentHeight, 300) + model: dropdown.popup.visible ? dropdown.model : [] + currentIndex: Math.max(0, dropdown.currentIndex) + + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + + delegate: ItemDelegate { + width: listView.width + height: 48 + + background: Rectangle { + color: { + if (itemMouse.pressed) return ColorUtils.transparentize(Appearance.m3colors.m3primaryContainer, 0.12) + if (itemMouse.containsMouse) return ColorUtils.transparentize(Appearance.m3colors.m3primaryContainer, 0.08) + if (index === root.currentIndex) return ColorUtils.transparentize(Appearance.m3colors.m3primaryContainer, 0.08) + return "transparent" + } + Behavior on color { + ColorAnimation { duration: Metrics.chronoDuration("small") ; easing.type: Easing.InOutCubic } + } + } + + contentItem: StyledText { + text: modelData + color: index === root.currentIndex ? Appearance.m3colors.m3primary : Appearance.m3colors.m3onSurface + font.pixelSize: Metrics.fontSize(16) + verticalAlignment: Text.AlignVCenter + leftPadding: Metrics.fontSize(16) + } + + MouseArea { + id: itemMouse + anchors.fill: parent + hoverEnabled: true + onClicked: { + dropdown.currentIndex = index + dropdown.popup.close() + } + } + } + } + + enter: Transition { + enabled: Config.runtime.appearance.animations.enabled + NumberAnimation {property: "opacity"; from: 0.0; to: 1.0; duration: Metrics.chronoDuration("small") ; easing.type: Easing.InOutCubic } + NumberAnimation {property: "scale"; from: 0.9; to: 1.0; duration: Metrics.chronoDuration("small") ; easing.type: Easing.InOutCubic } + } + + exit: Transition { + enabled: Config.runtime.appearance.animations.enabled + NumberAnimation {property: "opacity"; from: 1.0; to: 0.0; duration: Metrics.chronoDuration(Appearance.animation.fast * 0.67); easing.type: Easing.InOutCubic } + } + } + } + + focus: true + Keys.onPressed: (event) => { + if (event.key === Qt.Key_Space || event.key === Qt.Key_Return) { + dropdown.popup.visible ? dropdown.popup.close() : dropdown.popup.open() + event.accepted = true + } + } +} diff --git a/.config/quickshell/nucleus-shell/modules/components/StyledImage.qml b/.config/quickshell/nucleus-shell/modules/components/StyledImage.qml new file mode 100644 index 0000000..e46e48e --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/components/StyledImage.qml @@ -0,0 +1,13 @@ +import QtQuick +import qs.services +import qs.config + +Image { + asynchronous: true + retainWhileLoading: true + visible: opacity > 0 + opacity: (status === Image.Ready) ? 1 : 0 + Behavior on opacity { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } +} \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/modules/components/StyledPopout.qml b/.config/quickshell/nucleus-shell/modules/components/StyledPopout.qml new file mode 100644 index 0000000..d53339d --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/components/StyledPopout.qml @@ -0,0 +1,327 @@ +import qs.config +import QtQuick +import QtQuick.Effects +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Quickshell.Wayland + +LazyLoader { + id: root + + property string displayName: screen?.name ?? "" + property PanelWindow instance: null + property HoverHandler hoverTarget + property real margin: Metrics.margin(10) + default property list content + property bool startAnim: false + property bool isVisible: false + property bool keepAlive: false + property bool interactable: false + property bool hasHitbox: true + property bool hCenterOnItem: false + property bool followMouse: false + property list childPopouts: [] + + property bool requiresHover: true + property bool _manualControl: false + property int hoverDelay: Metrics.chronoDuration(250) + + property bool targetHovered: hoverTarget && hoverTarget.hovered + property bool containerHovered: interactable && root.item && root.item.containerHovered + property bool selfHovered: targetHovered || containerHovered + + property bool childrenHovered: { + for (let i = 0; i < childPopouts.length; i++) { + if (childPopouts[i].selfHovered) + return true; + } + return false; + } + + property bool hoverActive: selfHovered || childrenHovered + + property Timer showDelayTimer: Timer { + interval: root.hoverDelay + repeat: false + onTriggered: { + root.keepAlive = true; + root.isVisible = true; + root.startAnim = true; + } + } + + property Timer hangTimer: Timer { + interval: Metrics.chronoDuration(200) + repeat: false + onTriggered: { + root.startAnim = false; + cleanupTimer.restart(); + } + } + + property Timer cleanupTimer: Timer { + interval: Metrics.chronoDuration("small") + repeat: false + onTriggered: { + root.isVisible = false; + root.keepAlive = false; + root._manualControl = false; + root.instance = null; + } + } + + onHoverActiveChanged: { + if (_manualControl) + return; + if (!requiresHover) + return; + if (hoverActive) { + hangTimer.stop(); + cleanupTimer.stop(); + if (hoverDelay > 0) { + showDelayTimer.restart(); + } else { + root.keepAlive = true; + root.isVisible = true; + root.startAnim = true; + } + } else { + showDelayTimer.stop(); + hangTimer.restart(); + } + } + + function show() { + hangTimer.stop(); + cleanupTimer.stop(); + showDelayTimer.stop(); + _manualControl = true; + keepAlive = true; + isVisible = true; + startAnim = true; + } + + function hide() { + _manualControl = true; + showDelayTimer.stop(); + startAnim = false; + hangTimer.stop(); + cleanupTimer.restart(); + } + + active: keepAlive + + component: PanelWindow { + id: popoutWindow + + color: "transparent" + visible: root.isVisible + + WlrLayershell.namespace: "whisker:popout" + WlrLayershell.layer: WlrLayer.Overlay + exclusionMode: ExclusionMode.Ignore + exclusiveZone: 0 + + anchors { + left: true + top: true + right: true + bottom: true + } + + property bool exceedingHalf: false + property var parentPopoutWindow: null + property point mousePos: Qt.point(0, 0) + property bool containerHovered: root.interactable && containerHoverHandler.hovered + + HoverHandler { + id: windowHover + onPointChanged: point => { + if (root.followMouse) + popoutWindow.mousePos = point.position; + } + } + + mask: Region { + x: !root.hasHitbox ? 0 : !requiresHover ? 0 : container.x + y: !root.hasHitbox ? 0 : !requiresHover ? 0 : container.y + width: !root.hasHitbox ? 0 : !requiresHover ? popoutWindow.width : container.implicitWidth + height: !root.hasHitbox ? 0 : !requiresHover ? popoutWindow.height : container.implicitHeight + } + MouseArea { + id: mouseArea + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + hoverEnabled: false + + onPressed: mouse => { + if (!containerHoverHandler.containsMouse && root.isVisible) { + root.hide(); + } + } + } + Item { + id: container + + implicitWidth: contentArea.implicitWidth + root.margin * 2 + implicitHeight: contentArea.implicitHeight + root.margin * 2 + + x: { + let xValue; + + if (root.followMouse) + xValue = mousePos.x + 10; + else { + let targetItem = hoverTarget?.parent; + if (!targetItem) + xValue = 0; + else { + let baseX = targetItem.mapToGlobal(Qt.point(0, 0)).x; + if (parentPopoutWindow) + baseX += parentPopoutWindow.x; + + let targetWidth = targetItem.width; + let popupWidth = container.implicitWidth; + + if (root.hCenterOnItem) { + let centeredX = baseX + (targetWidth - popupWidth) / 2; + if (centeredX + popupWidth > screen.width) + centeredX = screen.width - popupWidth - 10; + if (centeredX < 10) + centeredX = 10; + xValue = centeredX; + } else { + let xPos = baseX - ((ConfigResolver.bar(root.displayName).position === "top" || ConfigResolver.bar(root.displayName).position === "top") ? 20 : -40); + if (xPos + popupWidth > screen.width) { + exceedingHalf = true; + xValue = baseX - popupWidth; + } else { + exceedingHalf = false; + xValue = xPos; + } + } + } + } + + return root.cleanupTimer.running ? xValue : Math.round(xValue); + } + + y: { + let yValue; + + if (root.followMouse) + yValue = mousePos.y + 10; + else { + let targetItem = hoverTarget?.parent; + if (!targetItem) + yValue = 0; + else { + let baseY = targetItem.mapToGlobal(Qt.point(0, 0)).y; + if (parentPopoutWindow) + baseY += parentPopoutWindow.y; + + let targetHeight = targetItem.height; + let popupHeight = container.implicitHeight; + + let yPos = baseY + ((ConfigResolver.bar(root.displayName).position === "top" || ConfigResolver.bar(root.displayName).position === "top") ? targetHeight : 0); + + if (yPos > screen.height / 2) + yPos = baseY - popupHeight; + + if (yPos + popupHeight > screen.height) + yPos = screen.height - popupHeight - 10; + if (yPos < 10) + yPos = 10; + + yValue = yPos; + } + } + + return root.cleanupTimer.running ? yValue : Math.round(yValue); + } + + + + opacity: root.startAnim ? 1 : 0 + scale: root.interactable ? 1 : root.startAnim ? 1 : 0.9 + + layer.enabled: true + layer.effect: MultiEffect { + shadowEnabled: true + shadowOpacity: 1 + shadowColor: Appearance.m3colors.m3shadow + shadowBlur: 1 + shadowScale: 1 + } + + Behavior on opacity { + NumberAnimation { + duration: Metrics.chronoDuration("small") + easing.type: Appearance.animation.easing + } + } + Behavior on scale { + enabled: !root.interactable + NumberAnimation { + duration: Metrics.chronoDuration("small") + easing.type: Appearance.animation.easing + } + } + Behavior on implicitWidth { + enabled: root.interactable + NumberAnimation { + duration: Metrics.chronoDuration("small") + easing.type: Appearance.animation.easing + } + } + Behavior on implicitHeight { + enabled: root.interactable + NumberAnimation { + duration: Metrics.chronoDuration("small") + easing.type: Appearance.animation.easing + } + } + + ClippingRectangle { + id: popupBackground + anchors.fill: parent + color: Appearance.m3colors.m3surface + radius: Appearance.rounding.normal + + ColumnLayout { + id: contentArea + anchors.fill: parent + anchors.margins: root.margin + } + } + + HoverHandler { + id: containerHoverHandler + enabled: root.interactable + } + } + + Component.onCompleted: { + root.instance = popoutWindow; + for (let i = 0; i < root.content.length; i++) { + const comp = root.content[i]; + if (comp && comp.createObject) { + comp.createObject(contentArea); + } else { + console.warn("StyledPopout: invalid content:", comp); + } + } + + let parentPopout = root.parent; + while (parentPopout && !parentPopout.childPopouts) + parentPopout = parentPopout.parent; + + if (parentPopout) { + parentPopout.childPopouts.push(root); + if (parentPopout.item) + popoutWindow.parentPopoutWindow = parentPopout.item; + } + } + } +} \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/modules/components/StyledRect.qml b/.config/quickshell/nucleus-shell/modules/components/StyledRect.qml new file mode 100644 index 0000000..20fe500 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/components/StyledRect.qml @@ -0,0 +1,15 @@ +import qs.config +import QtQuick + +Rectangle { + id: root + + Behavior on color { + enabled: Config.runtime.appearance.animations.enabled + ColorAnimation { + duration: Metrics.chronoDuration(600) + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.animation.curves.standard + } + } +} \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/modules/components/StyledSlider.qml b/.config/quickshell/nucleus-shell/modules/components/StyledSlider.qml new file mode 100644 index 0000000..fd6f485 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/components/StyledSlider.qml @@ -0,0 +1,118 @@ +import qs.config +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Slider { + id: root + + property real trackHeightDiff: 15 + property real handleGap: Metrics.spacing(4) + property real trackDotSize: Metrics.iconSize(4) + property real trackNearHandleRadius: Appearance.rounding.unsharpen + property bool useAnim: true + property int iconSize: Appearance.font.size.large + property string icon: "" + + Layout.fillWidth: true + + implicitWidth: 200 + implicitHeight: 40 + from: 0 + to: 100 + value: 0 + stepSize: 0 + snapMode: stepSize > 0 ? Slider.SnapAlways : Slider.NoSnap + + + MouseArea { + anchors.fill: parent + onPressed: (mouse) => mouse.accepted = false + cursorShape: root.pressed ? Qt.ClosedHandCursor : Qt.PointingHandCursor + } + + MaterialSymbol { + id: icon + icon: root.icon + iconSize: root.iconSize + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.rightMargin: Metrics.margin(16) + } + + background: Item { + anchors.verticalCenter: parent.verticalCenter + width: parent.width + height: parent.height + + // Filled Left Segment + Rectangle { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + + width: root.handleGap + (root.visualPosition * (root.width - root.handleGap * 2)) + - ((root.pressed ? 1.5 : 3) / 2 + root.handleGap) + + height: root.height - root.trackHeightDiff + color: Appearance.colors.colPrimary + radius: Metrics.radius("small") + topRightRadius: root.trackNearHandleRadius + bottomRightRadius: root.trackNearHandleRadius + + Behavior on width { + enabled: Config.runtime.appearance.animations.enabled + NumberAnimation { + duration: !root.useAnim ? 0 : Metrics.chronoDuration("small") + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.animation.curves.expressiveEffects + } + } + } + + // Remaining Right Segment + Rectangle { + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + + width: root.handleGap + ((1 - root.visualPosition) * (root.width - root.handleGap * 2)) + - ((root.pressed ? 1.5 : 3) / 2 + root.handleGap) + + height: root.height - root.trackHeightDiff + color: Appearance.colors.colSecondaryContainer + radius: Metrics.radius("small") + topLeftRadius: root.trackNearHandleRadius + bottomLeftRadius: root.trackNearHandleRadius + + Behavior on width { + enabled: Config.runtime.appearance.animations.enabled + NumberAnimation { + duration: !root.useAnim ? 0 : Metrics.chronoDuration("small") + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.animation.curves.expressiveEffects + } + } + } + } + + + handle: Rectangle { + width: 5 + height: root.height + radius: (width / 2) * Config.runtime.appearance.rounding.factor + + x: root.handleGap + (root.visualPosition * (root.width - root.handleGap * 2)) - width / 2 + anchors.verticalCenter: parent.verticalCenter + + color: Appearance.colors.colPrimary + + Behavior on x { + enabled: Config.runtime.appearance.animations.enabled + NumberAnimation { + duration: !root.useAnim ? 0 : Appearance.animation.elementMoveFast.duration + easing.type: Appearance.animation.elementMoveFast.type + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + } + } +} diff --git a/.config/quickshell/nucleus-shell/modules/components/StyledSwitch.qml b/.config/quickshell/nucleus-shell/modules/components/StyledSwitch.qml new file mode 100644 index 0000000..c19b903 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/components/StyledSwitch.qml @@ -0,0 +1,138 @@ +import qs.config +import QtQuick +import QtQuick.Controls + +Item { + id: root + width: 60 + height: 34 + + property bool checked: false + signal toggled(bool checked) + + // Colors + property color trackOn: Appearance.colors.colPrimary + property color trackOff: Appearance.colors.colLayer2 + property color outline: Appearance.colors.colOutline + + property color thumbOn: Appearance.colors.colOnPrimary + property color thumbOff: Appearance.colors.colOnLayer2 + + property color iconOn: Appearance.colors.colPrimary + property color iconOff: Appearance.colors.colOnPrimary + + // Dimensions + property int trackRadius: (height / 2) * Config.runtime.appearance.rounding.factor + property int thumbSize: height - (checked ? 10 : 14) + + Behavior on thumbSize { + enabled: Config.runtime.appearance.animations.enabled + NumberAnimation { + duration: Metrics.chronoDuration("normal") + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.animation.curves.expressiveEffects + } + } + + // TRACK + Rectangle { + id: track + anchors.fill: parent + radius: trackRadius + + color: root.checked ? trackOn : trackOff + border.width: root.checked ? 0 : 2 + border.color: outline + + Behavior on color { + enabled: Config.runtime.appearance.animations.enabled + ColorAnimation { + duration: Metrics.chronoDuration("normal") + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.animation.curves.expressiveEffects + } + } + } + + // THUMB + Rectangle { + id: thumb + width: thumbSize + height: thumbSize + radius: (thumbSize / 2) * Config.runtime.appearance.rounding.factor + + anchors.verticalCenter: parent.verticalCenter + x: root.checked ? parent.width - width - 6 : 6 + + color: root.checked ? thumbOn : thumbOff + + Behavior on x { + enabled: Config.runtime.appearance.animations.enabled + NumberAnimation { + duration: Metrics.chronoDuration("small") + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.animation.curves.expressiveEffects + } + } + + Behavior on color { + enabled: Config.runtime.appearance.animations.enabled + ColorAnimation { + duration: Metrics.chronoDuration("small") + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.animation.curves.expressiveEffects + } + } + + // ✓ CHECK ICON + MaterialSymbol { + anchors.centerIn: parent + icon: "check" + iconSize: parent.width * 0.7 + color: iconOn + + opacity: root.checked ? 1 : 0 + scale: root.checked ? 1 : 0.6 + + Behavior on opacity { NumberAnimation { duration: 120 } } + + Behavior on scale { + NumberAnimation { + duration: 160 + easing.type: Easing.OutBack + } + } + } + + // ✕ CROSS ICON (more visible) + MaterialSymbol { + anchors.centerIn: parent + icon: "close" + iconSize: parent.width * 0.72 + color: iconOff + + opacity: root.checked ? 0 : 1 + scale: root.checked ? 0.6 : 1 + + Behavior on opacity { NumberAnimation { duration: 120 } } + + Behavior on scale { + NumberAnimation { + duration: 160 + easing.type: Easing.OutBack + } + } + } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + + onClicked: { + root.checked = !root.checked + root.toggled(root.checked) + } + } +} diff --git a/.config/quickshell/nucleus-shell/modules/components/StyledSwitchOption.qml b/.config/quickshell/nucleus-shell/modules/components/StyledSwitchOption.qml new file mode 100644 index 0000000..1ae90b7 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/components/StyledSwitchOption.qml @@ -0,0 +1,37 @@ +import qs.config +import Quickshell +import QtQuick +import QtQuick.Layouts + +RowLayout { + id: main + property string title: "Title" + property string description: "Description" + property string prefField: '' + + ColumnLayout { + StyledText { text: main.title; font.pixelSize: Metrics.fontSize(16); } + StyledText { text: main.description; font.pixelSize: Metrics.fontSize(12); } + } + Item { Layout.fillWidth: true } + + StyledSwitch { + // Safely resolve nested key (e.g. "background.showClock" or "bar.modules.some.setting") + checked: { + if (!main.prefField) return false; + var parts = main.prefField.split('.'); + var cur = Config.runtime; + for (var i = 0; i < parts.length; ++i) { + if (cur === undefined || cur === null) return false; + cur = cur[parts[i]]; + } + // If the config value is undefined, default to false + return cur === undefined || cur === null ? false : cur; + } + + onToggled: { + // Persist change (updateKey will create missing objects) + Config.updateKey(main.prefField, checked); + } + } +} \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/modules/components/StyledText.qml b/.config/quickshell/nucleus-shell/modules/components/StyledText.qml new file mode 100644 index 0000000..90b3ba6 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/components/StyledText.qml @@ -0,0 +1,54 @@ +pragma ComponentBehavior: Bound + +import qs.config +import QtQuick + +Text { + id: root + + // from github.com/yannpelletier/twinshell with modifications + + property bool animate: true + property string animateProp: "scale" + property real animateFrom: 0 + property real animateTo: 1 + property int animateDuration: Metrics.chronoDuration("small") + + renderType: Text.NativeRendering + textFormat: Text.PlainText + color: Appearance.syntaxHighlightingTheme + font.family: Metrics.fontFamily("main") + font.pixelSize: Metrics.fontSize("normal") + + Behavior on color { + enabled: Config.runtime.appearance.animations.enabled + ColorAnimation { + duration: Metrics.chronoDuration("small") + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.animation.curves.standard + } + } + + Behavior on text { + enabled: Config.runtime.appearance.animations.enabled && root.animate + + SequentialAnimation { + Anim { + to: root.animateFrom + easing.bezierCurve: Appearance.animation.curves.standardAccel + } + PropertyAction {} + Anim { + to: root.animateTo + easing.bezierCurve: Appearance.animation.curves.standardDecel + } + } + } + + component Anim: NumberAnimation { + target: root + property: root.animateProp + duration: root.animateDuration / 2 + easing.type: Easing.BezierSpline + } +} \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/modules/components/StyledTextField.qml b/.config/quickshell/nucleus-shell/modules/components/StyledTextField.qml new file mode 100755 index 0000000..9e64290 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/components/StyledTextField.qml @@ -0,0 +1,195 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Effects +import qs.config +import qs.modules.functions + +TextField { + id: control + + property string icon: "" + property color iconColor: Appearance.m3colors.m3onSurfaceVariant + property string placeholder: "" + property real iconSize: Metrics.iconSize(24) + property alias radius: bg.radius + property bool outline: true + property alias topLeftRadius: bg.topLeftRadius + property alias topRightRadius: bg.topRightRadius + property alias bottomLeftRadius: bg.bottomLeftRadius + property alias bottomRightRadius: bg.bottomRightRadius + property color backgroundColor: filled ? Appearance.m3colors.m3surfaceContainerHigh : "transparent" + property int fieldPadding: Metrics.padding(20) + property int iconSpacing: Metrics.spacing(14) + property int iconMargin: Metrics.margin(20) + property bool filled: true + property bool highlight: true + + width: parent ? parent.width - 40 : 300 + placeholderText: placeholder + leftPadding: icon !== "" ? iconSize + iconSpacing + iconMargin : fieldPadding + padding: fieldPadding + verticalAlignment: TextInput.AlignVCenter + color: Appearance.m3colors.m3onSurface + placeholderTextColor: Appearance.m3colors.m3onSurfaceVariant + font.family: "Outfit" + font.pixelSize: Metrics.fontSize(14) + cursorVisible: control.focus + + MaterialSymbol { + icon: control.icon + anchors.left: parent.left + anchors.leftMargin: icon !== "" ? iconMargin : 0 + anchors.verticalCenter: parent.verticalCenter + font.pixelSize: control.iconSize + color: control.iconColor + visible: control.icon !== "" + + Behavior on color { + ColorAnimation { + duration: Metrics.chronoDuration("small") + easing.type: Appearance.animation.easing + } + + } + + } + + cursorDelegate: Rectangle { + width: 2 + color: Appearance.m3colors.m3primary + visible: control.focus + + SequentialAnimation on opacity { + loops: Animation.Infinite + running: control.focus && Config.runtime.appearance.animations.enabled + + NumberAnimation { + from: 1 + to: 0 + duration: Metrics.chronoDuration("lrage") * 2 + } + + NumberAnimation { + from: 0 + to: 1 + duration: Metrics.chronoDuration("lrage") * 2 + } + + } + + } + + background: Item { + Rectangle { + id: bg + + anchors.fill: parent + radius: Metrics.radius("unsharpenmore") + color: control.backgroundColor + + Rectangle { + anchors.fill: parent + radius: parent.radius + color: { + if (control.activeFocus && control.highlight) + return ColorUtils.transparentize(Appearance.m3colors.m3primary, 0.8); + + if (control.hovered && control.highlight) + return ColorUtils.transparentize(Appearance.m3colors.m3onSurface, 0.9); + + return "transparent"; + } + + Behavior on color { + enabled: Config.runtime.appearance.animations.enabled + + ColorAnimation { + duration: Metrics.chronoDuration("small") + easing.type: Appearance.animation.easing + } + + } + + } + + } + + Rectangle { + id: indicator + + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: control.activeFocus ? 2 : 1 + color: { + if (control.activeFocus) + return Appearance.m3colors.m3primary; + + if (control.hovered) + return Appearance.m3colors.m3onSurface; + + return Appearance.m3colors.m3onSurface; + } + visible: filled + + Behavior on height { + enabled: Config.runtime.appearance.animations.enabled + NumberAnimation { + duration: Metrics.chronoDuration("small") + easing.type: Appearance.animation.easing + } + + } + + Behavior on color { + enabled: Config.runtime.appearance.animations.enabled + ColorAnimation { + duration: Metrics.chronoDuration("small") + easing.type: Appearance.animation.easing + } + + } + + } + + Rectangle { + id: outline + + anchors.fill: parent + radius: bg.radius + color: "transparent" + border.width: control.activeFocus ? 2 : 1 + border.color: { + if (control.activeFocus) + return Appearance.m3colors.m3primary; + + if (control.hovered) + return Appearance.m3colors.m3onSurface; + + return Appearance.m3colors.m3outline; + } + visible: !filled && control.outline + + Behavior on border.width { + enabled: Config.runtime.appearance.animations.enabled + NumberAnimation { + duration: Metrics.chronoDuration("small") + easing.type: Appearance.animation.easing + } + + } + + Behavior on border.color { + enabled: Config.runtime.appearance.animations.enabled + ColorAnimation { + duration: Metrics.chronoDuration("small") + easing.type: Appearance.animation.easing + } + + } + + } + + } + +} diff --git a/.config/quickshell/nucleus-shell/modules/components/Tint.qml b/.config/quickshell/nucleus-shell/modules/components/Tint.qml new file mode 100644 index 0000000..e88a59a --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/components/Tint.qml @@ -0,0 +1,22 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Effects +import qs.config + +Item { + property var sourceItem: null + + Loader { + active: Config.runtime.appearance.tintIcons + anchors.fill: parent + sourceComponent: MultiEffect { + source: sourceItem + + saturation: -1.0 + contrast: 0.10 + brightness: -0.08 + blur: 0.0 + } + + } +} \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/MorphedPolygon.qml b/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/MorphedPolygon.qml new file mode 100644 index 0000000..11409f9 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/MorphedPolygon.qml @@ -0,0 +1,103 @@ +import QtQuick +import "shapes/morph.js" as Morph +import qs.config + +// From github.com/end-4/rounded-polygons-qmljs + +Canvas { + id: root + property color color: "#685496" + property var roundedPolygon: null + property bool polygonIsNormalized: true + property real borderWidth: 0 + property color borderColor: color + property bool debug: false + + // Internals: size + property var bounds: roundedPolygon.calculateBounds() + implicitWidth: bounds[2] - bounds[0] + implicitHeight: bounds[3] - bounds[1] + + // Internals: anim + property var prevRoundedPolygon: null + property double progress: 1 + property var morph: new Morph.Morph(roundedPolygon, roundedPolygon) + property Animation animation: NumberAnimation { + duration: Metrics.chronoDuration(350) + easing.type: Easing.BezierSpline + easing.bezierCurve: [0.42, 1.67, 0.21, 0.90, 1, 1] // Material 3 Expressive fast spatial (https://m3.material.io/styles/motion/overview/specs) + } + + onRoundedPolygonChanged: { + delete root.morph + root.morph = new Morph.Morph(root.prevRoundedPolygon ?? root.roundedPolygon, root.roundedPolygon) + morphBehavior.enabled = false; + root.progress = 0 + morphBehavior.enabled = true; + root.progress = 1 + root.prevRoundedPolygon = root.roundedPolygon + } + + Behavior on progress { + id: morphBehavior + animation: root.animation + } + + onProgressChanged: requestPaint() + onColorChanged: requestPaint() + onBorderWidthChanged: requestPaint() + onBorderColorChanged: requestPaint() + onDebugChanged: requestPaint() + onPaint: { + var ctx = getContext("2d") + ctx.fillStyle = root.color + ctx.clearRect(0, 0, width, height) + if (!root.morph) return + const cubics = root.morph.asCubics(root.progress) + if (cubics.length === 0) return + + const size = Math.min(root.width, root.height) + + ctx.save() + if (root.polygonIsNormalized) ctx.scale(size, size) + + ctx.beginPath() + ctx.moveTo(cubics[0].anchor0X, cubics[0].anchor0Y) + for (const cubic of cubics) { + ctx.bezierCurveTo( + cubic.control0X, cubic.control0Y, + cubic.control1X, cubic.control1Y, + cubic.anchor1X, cubic.anchor1Y + ) + } + ctx.closePath() + ctx.fill() + + if (root.borderWidth > 0) { + ctx.strokeStyle = root.borderColor + ctx.lineWidth = root.borderWidth + ctx.stroke() + } + + if (root.debug) { + const points = [] + for (let i = 0; i < cubics.length; ++i) { + const c = cubics[i] + if (i === 0) + points.push({ x: c.anchor0X, y: c.anchor0Y }) + points.push({ x: c.anchor1X, y: c.anchor1Y }) + } + + let radius = Metrics.radius(2) + + ctx.fillStyle = "red" + for (const p of points) { + ctx.beginPath() + ctx.arc(p.x, p.y, radius, 0, Math.PI * 2) + ctx.fill() + } + } + + ctx.restore() + } +} \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/geometry/offset.js b/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/geometry/offset.js new file mode 100644 index 0000000..6066605 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/geometry/offset.js @@ -0,0 +1,177 @@ +.pragma library + +/** + * @param {number} x + * @param {number} y + * @returns {Offset} + */ +function createOffset(x, y) { + return new Offset(x, y); +} + +class Offset { + /** + * @param {number} x + * @param {number} y + */ + constructor(x, y) { + this.x = x; + this.y = y; + } + + /** + * @param {number} x + * @param {number} y + * @returns {Offset} + */ + copy(x = this.x, y = this.y) { + return new Offset(x, y); + } + + /** + * @returns {number} + */ + getDistance() { + return Math.sqrt(this.x * this.x + this.y * this.y); + } + + /** + * @returns {number} + */ + getDistanceSquared() { + return this.x * this.x + this.y * this.y; + } + + /** + * @returns {boolean} + */ + isValid() { + return isFinite(this.x) && isFinite(this.y); + } + + /** + * @returns {boolean} + */ + get isFinite() { + return isFinite(this.x) && isFinite(this.y); + } + + /** + * @returns {boolean} + */ + get isSpecified() { + return !this.isUnspecified; + } + + /** + * @returns {boolean} + */ + get isUnspecified() { + return Object.is(this.x, NaN) && Object.is(this.y, NaN); + } + + /** + * @returns {Offset} + */ + negate() { + return new Offset(-this.x, -this.y); + } + + /** + * @param {Offset} other + * @returns {Offset} + */ + minus(other) { + return new Offset(this.x - other.x, this.y - other.y); + } + + /** + * @param {Offset} other + * @returns {Offset} + */ + plus(other) { + return new Offset(this.x + other.x, this.y + other.y); + } + + /** + * @param {number} operand + * @returns {Offset} + */ + times(operand) { + return new Offset(this.x * operand, this.y * operand); + } + + /** + * @param {number} operand + * @returns {Offset} + */ + div(operand) { + return new Offset(this.x / operand, this.y / operand); + } + + /** + * @param {number} operand + * @returns {Offset} + */ + rem(operand) { + return new Offset(this.x % operand, this.y % operand); + } + + /** + * @returns {string} + */ + toString() { + if (this.isSpecified) { + return `Offset(${this.x.toFixed(1)}, ${this.y.toFixed(1)})`; + } else { + return 'Offset.Unspecified'; + } + } + + /** + * @param {Offset} start + * @param {Offset} stop + * @param {number} fraction + * @returns {Offset} + */ + static lerp(start, stop, fraction) { + return new Offset( + start.x + (stop.x - start.x) * fraction, + start.y + (stop.y - start.y) * fraction + ); + } + + /** + * @param {function(): Offset} block + * @returns {Offset} + */ + takeOrElse(block) { + return this.isSpecified ? this : block(); + } + + /** + * @returns {number} + */ + angleDegrees() { + return Math.atan2(this.y, this.x) * 180 / Math.PI; + } + + /** + * @param {number} angle + * @param {Offset} center + * @returns {Offset} + */ + rotateDegrees(angle, center = Offset.Zero) { + const a = angle * Math.PI / 180; + const off = this.minus(center); + const cosA = Math.cos(a); + const sinA = Math.sin(a); + const newX = off.x * cosA - off.y * sinA; + const newY = off.x * sinA + off.y * cosA; + return new Offset(newX, newY).plus(center); + } +} + +Offset.Zero = new Offset(0, 0); +Offset.Infinite = new Offset(Infinity, Infinity); +Offset.Unspecified = new Offset(NaN, NaN); diff --git a/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/graphics/matrix.js b/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/graphics/matrix.js new file mode 100644 index 0000000..4c8181c --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/graphics/matrix.js @@ -0,0 +1,198 @@ +.pragma library + +.import "../geometry/offset.js" as Offset + +class Matrix { + constructor(values = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]) { + this.values = values; + } + + get(row, column) { + return this.values[(row * 4) + column]; + } + + set(row, column, v) { + this.values[(row * 4) + column] = v; + } + + /** Does the 3D transform on [point] and returns the `x` and `y` values in an [Offset]. */ + map(point) { + if (this.values.length < 16) return point; + + const v00 = this.get(0, 0); + const v01 = this.get(0, 1); + const v03 = this.get(0, 3); + const v10 = this.get(1, 0); + const v11 = this.get(1, 1); + const v13 = this.get(1, 3); + const v30 = this.get(3, 0); + const v31 = this.get(3, 1); + const v33 = this.get(3, 3); + + const x = point.x; + const y = point.y; + const z = v03 * x + v13 * y + v33; + const inverseZ = 1 / z; + const pZ = isFinite(inverseZ) ? inverseZ : 0; + + return new Offset.Offset(pZ * (v00 * x + v10 * y + v30), pZ * (v01 * x + v11 * y + v31)); + } + + /** Multiply this matrix by [m] and assign the result to this matrix. */ + timesAssign(m) { + const v = this.values; + if (v.length < 16) return; + if (m.values.length < 16) return; + + const v00 = this.dot(0, m, 0); + const v01 = this.dot(0, m, 1); + const v02 = this.dot(0, m, 2); + const v03 = this.dot(0, m, 3); + const v10 = this.dot(1, m, 0); + const v11 = this.dot(1, m, 1); + const v12 = this.dot(1, m, 2); + const v13 = this.dot(1, m, 3); + const v20 = this.dot(2, m, 0); + const v21 = this.dot(2, m, 1); + const v22 = this.dot(2, m, 2); + const v23 = this.dot(2, m, 3); + const v30 = this.dot(3, m, 0); + const v31 = this.dot(3, m, 1); + const v32 = this.dot(3, m, 2); + const v33 = this.dot(3, m, 3); + + v[0] = v00; + v[1] = v01; + v[2] = v02; + v[3] = v03; + v[4] = v10; + v[5] = v11; + v[6] = v12; + v[7] = v13; + v[8] = v20; + v[9] = v21; + v[10] = v22; + v[11] = v23; + v[12] = v30; + v[13] = v31; + v[14] = v32; + v[15] = v33; + } + + dot(row, m, column) { + return this.get(row, 0) * m.get(0, column) + + this.get(row, 1) * m.get(1, column) + + this.get(row, 2) * m.get(2, column) + + this.get(row, 3) * m.get(3, column); + } + + /** Resets the `this` to the identity matrix. */ + reset() { + const v = this.values; + if (v.length < 16) return; + v[0] = 1; + v[1] = 0; + v[2] = 0; + v[3] = 0; + v[4] = 0; + v[5] = 1; + v[6] = 0; + v[7] = 0; + v[8] = 0; + v[9] = 0; + v[10] = 1; + v[11] = 0; + v[12] = 0; + v[13] = 0; + v[14] = 0; + v[15] = 1; + } + + /** Applies a [degrees] rotation around Z to `this`. */ + rotateZ(degrees) { + if (this.values.length < 16) return; + + const r = degrees * (Math.PI / 180.0); + const s = Math.sin(r); + const c = Math.cos(r); + + const a00 = this.get(0, 0); + const a10 = this.get(1, 0); + const v00 = c * a00 + s * a10; + const v10 = -s * a00 + c * a10; + + const a01 = this.get(0, 1); + const a11 = this.get(1, 1); + const v01 = c * a01 + s * a11; + const v11 = -s * a01 + c * a11; + + const a02 = this.get(0, 2); + const a12 = this.get(1, 2); + const v02 = c * a02 + s * a12; + const v12 = -s * a02 + c * a12; + + const a03 = this.get(0, 3); + const a13 = this.get(1, 3); + const v03 = c * a03 + s * a13; + const v13 = -s * a03 + c * a13; + + this.set(0, 0, v00); + this.set(0, 1, v01); + this.set(0, 2, v02); + this.set(0, 3, v03); + this.set(1, 0, v10); + this.set(1, 1, v11); + this.set(1, 2, v12); + this.set(1, 3, v13); + } + + /** Scale this matrix by [x], [y], [z] */ + scale(x = 1, y = 1, z = 1) { + if (this.values.length < 16) return; + this.set(0, 0, this.get(0, 0) * x); + this.set(0, 1, this.get(0, 1) * x); + this.set(0, 2, this.get(0, 2) * x); + this.set(0, 3, this.get(0, 3) * x); + this.set(1, 0, this.get(1, 0) * y); + this.set(1, 1, this.get(1, 1) * y); + this.set(1, 2, this.get(1, 2) * y); + this.set(1, 3, this.get(1, 3) * y); + this.set(2, 0, this.get(2, 0) * z); + this.set(2, 1, this.get(2, 1) * z); + this.set(2, 2, this.get(2, 2) * z); + this.set(2, 3, this.get(2, 3) * z); + } + + /** Translate this matrix by [x], [y], [z] */ + translate(x = 0, y = 0, z = 0) { + if (this.values.length < 16) return; + const t1 = this.get(0, 0) * x + this.get(1, 0) * y + this.get(2, 0) * z + this.get(3, 0); + const t2 = this.get(0, 1) * x + this.get(1, 1) * y + this.get(2, 1) * z + this.get(3, 1); + const t3 = this.get(0, 2) * x + this.get(1, 2) * y + this.get(2, 2) * z + this.get(3, 2); + const t4 = this.get(0, 3) * x + this.get(1, 3) * y + this.get(2, 3) * z + this.get(3, 3); + this.set(3, 0, t1); + this.set(3, 1, t2); + this.set(3, 2, t3); + this.set(3, 3, t4); + } + + toString() { + return `${this.get(0, 0)} ${this.get(0, 1)} ${this.get(0, 2)} ${this.get(0, 3)}\n` + + `${this.get(1, 0)} ${this.get(1, 1)} ${this.get(1, 2)} ${this.get(1, 3)}\n` + + `${this.get(2, 0)} ${this.get(2, 1)} ${this.get(2, 2)} ${this.get(2, 3)}\n` + + `${this.get(3, 0)} ${this.get(3, 1)} ${this.get(3, 2)} ${this.get(3, 3)}`; + } +} + +// Companion object constants +Matrix.ScaleX = 0; +Matrix.SkewY = 1; +Matrix.Perspective0 = 3; +Matrix.SkewX = 4; +Matrix.ScaleY = 5; +Matrix.Perspective1 = 7; +Matrix.ScaleZ = 10; +Matrix.TranslateX = 12; +Matrix.TranslateY = 13; +Matrix.TranslateZ = 14; +Matrix.Perspective2 = 15; diff --git a/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/material-shapes.js b/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/material-shapes.js new file mode 100644 index 0000000..8743f37 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/material-shapes.js @@ -0,0 +1,712 @@ +.pragma library + +.import "shapes/point.js" as Point +.import "shapes/rounded-polygon.js" as RoundedPolygon +.import "shapes/corner-rounding.js" as CornerRounding +.import "geometry/offset.js" as Offset +.import "graphics/matrix.js" as Matrix + +var _circle = null +var _square = null +var _slanted = null +var _arch = null +var _fan = null +var _arrow = null +var _semiCircle = null +var _oval = null +var _pill = null +var _triangle = null +var _diamond = null +var _clamShell = null +var _pentagon = null +var _gem = null +var _verySunny = null +var _sunny = null +var _cookie4Sided = null +var _cookie6Sided = null +var _cookie7Sided = null +var _cookie9Sided = null +var _cookie12Sided = null +var _ghostish = null +var _clover4Leaf = null +var _clover8Leaf = null +var _burst = null +var _softBurst = null +var _boom = null +var _softBoom = null +var _flower = null +var _puffy = null +var _puffyDiamond = null +var _pixelCircle = null +var _pixelTriangle = null +var _bun = null +var _heart = null + +var cornerRound15 = new CornerRounding.CornerRounding(0.15) +var cornerRound20 = new CornerRounding.CornerRounding(0.2) +var cornerRound30 = new CornerRounding.CornerRounding(0.3) +var cornerRound50 = new CornerRounding.CornerRounding(0.5) +var cornerRound100 = new CornerRounding.CornerRounding(1.0) + +var rotateNeg30 = new Matrix.Matrix(); +rotateNeg30.rotateZ(-30); +var rotateNeg45 = new Matrix.Matrix(); +rotateNeg45.rotateZ(-45); +var rotateNeg90 = new Matrix.Matrix(); +rotateNeg90.rotateZ(-90); +var rotateNeg135 = new Matrix.Matrix(); +rotateNeg135.rotateZ(-135); +var rotate30 = new Matrix.Matrix(); +rotate30.rotateZ(30); +var rotate45 = new Matrix.Matrix(); +rotate45.rotateZ(45); +var rotate60 = new Matrix.Matrix(); +rotate60.rotateZ(60); +var rotate90 = new Matrix.Matrix(); +rotate90.rotateZ(90); +var rotate120 = new Matrix.Matrix(); +rotate120.rotateZ(120); +var rotate135 = new Matrix.Matrix(); +rotate135.rotateZ(135); +var rotate180 = new Matrix.Matrix(); +rotate180.rotateZ(180); + +var rotate28th = new Matrix.Matrix(); +rotate28th.rotateZ(360/28); +var rotateNeg16th = new Matrix.Matrix(); +rotateNeg16th.rotateZ(-360/16); + +function getCircle() { + if (_circle !== null) return _circle; + _circle = circle(); + return _circle; +} + +function getSquare() { + if (_square !== null) return _square; + _square = square(); + return _square; +} + +function getSlanted() { + if (_slanted !== null) return _slanted; + _slanted = slanted(); + return _slanted; +} + +function getArch() { + if (_arch !== null) return _arch; + _arch = arch(); + return _arch; +} + +function getFan() { + if (_fan !== null) return _fan; + _fan = fan(); + return _fan; +} + +function getArrow() { + if (_arrow !== null) return _arrow; + _arrow = arrow(); + return _arrow; +} + +function getSemiCircle() { + if (_semiCircle !== null) return _semiCircle; + _semiCircle = semiCircle(); + return _semiCircle; +} + +function getOval() { + if (_oval !== null) return _oval; + _oval = oval(); + return _oval; +} + +function getPill() { + if (_pill !== null) return _pill; + _pill = pill(); + return _pill; +} + +function getTriangle() { + if (_triangle !== null) return _triangle; + _triangle = triangle(); + return _triangle; +} + +function getDiamond() { + if (_diamond !== null) return _diamond; + _diamond = diamond(); + return _diamond; +} + +function getClamShell() { + if (_clamShell !== null) return _clamShell; + _clamShell = clamShell(); + return _clamShell; +} + +function getPentagon() { + if (_pentagon !== null) return _pentagon; + _pentagon = pentagon(); + return _pentagon; +} + +function getGem() { + if (_gem !== null) return _gem; + _gem = gem(); + return _gem; +} + +function getSunny() { + if (_sunny !== null) return _sunny; + _sunny = sunny(); + return _sunny; +} + +function getVerySunny() { + if (_verySunny !== null) return _verySunny; + _verySunny = verySunny(); + return _verySunny; +} + +function getCookie4Sided() { + if (_cookie4Sided !== null) return _cookie4Sided; + _cookie4Sided = cookie4(); + return _cookie4Sided; +} + +function getCookie6Sided() { + if (_cookie6Sided !== null) return _cookie6Sided; + _cookie6Sided = cookie6(); + return _cookie6Sided; +} + +function getCookie7Sided() { + if (_cookie7Sided !== null) return _cookie7Sided; + _cookie7Sided = cookie7(); + return _cookie7Sided; +} + +function getCookie9Sided() { + if (_cookie9Sided !== null) return _cookie9Sided; + _cookie9Sided = cookie9(); + return _cookie9Sided; +} + +function getCookie12Sided() { + if (_cookie12Sided !== null) return _cookie12Sided; + _cookie12Sided = cookie12(); + return _cookie12Sided; +} + +function getGhostish() { + if (_ghostish !== null) return _ghostish; + _ghostish = ghostish(); + return _ghostish; +} + +function getClover4Leaf() { + if (_clover4Leaf !== null) return _clover4Leaf; + _clover4Leaf = clover4(); + return _clover4Leaf; +} + +function getClover8Leaf() { + if (_clover8Leaf !== null) return _clover8Leaf; + _clover8Leaf = clover8(); + return _clover8Leaf; +} + +function getBurst() { + if (_burst !== null) return _burst; + _burst = burst(); + return _burst; +} + +function getSoftBurst() { + if (_softBurst !== null) return _softBurst; + _softBurst = softBurst(); + return _softBurst; +} + +function getBoom() { + if (_boom !== null) return _boom; + _boom = boom(); + return _boom; +} + +function getSoftBoom() { + if (_softBoom !== null) return _softBoom; + _softBoom = softBoom(); + return _softBoom; +} + +function getFlower() { + if (_flower !== null) return _flower; + _flower = flower(); + return _flower; +} + +function getPuffy() { + if (_puffy !== null) return _puffy; + _puffy = puffy(); + return _puffy; +} + +function getPuffyDiamond() { + if (_puffyDiamond !== null) return _puffyDiamond; + _puffyDiamond = puffyDiamond(); + return _puffyDiamond; +} + +function getPixelCircle() { + if (_pixelCircle !== null) return _pixelCircle; + _pixelCircle = pixelCircle(); + return _pixelCircle; +} + +function getPixelTriangle() { + if (_pixelTriangle !== null) return _pixelTriangle; + _pixelTriangle = pixelTriangle(); + return _pixelTriangle; +} + +function getBun() { + if (_bun !== null) return _bun; + _bun = bun(); + return _bun; +} + +function getHeart() { + if (_heart !== null) return _heart; + _heart = heart(); + return _heart; +} + +function circle() { + return RoundedPolygon.RoundedPolygon.circle(10) + .transformed((x, y) => rotate45.map(new Offset.Offset(x, y))) + .normalized(); +} + +function square() { + return RoundedPolygon.RoundedPolygon.rectangle(1, 1, cornerRound30).normalized(); +} + +function slanted() { + return customPolygon([ + new PointNRound(new Offset.Offset(0.926, 0.970), new CornerRounding.CornerRounding(0.189, 0.811)), + new PointNRound(new Offset.Offset(-0.021, 0.967), new CornerRounding.CornerRounding(0.187, 0.057)), + ], 2).normalized(); +} + +function arch() { + return RoundedPolygon.RoundedPolygon.rectangle(1, 1, CornerRounding.Unrounded, [cornerRound20, cornerRound20, cornerRound100, cornerRound100]) + .normalized(); +} + +function fan() { + return customPolygon([ + new PointNRound(new Offset.Offset(1.004, 1.000), new CornerRounding.CornerRounding(0.148, 0.417)), + new PointNRound(new Offset.Offset(0.000, 1.000), new CornerRounding.CornerRounding(0.151)), + new PointNRound(new Offset.Offset(0.000, -0.003), new CornerRounding.CornerRounding(0.148)), + new PointNRound(new Offset.Offset(0.978, 0.020), new CornerRounding.CornerRounding(0.803)), + ], 1).normalized(); +} + +function arrow() { + return customPolygon([ + new PointNRound(new Offset.Offset(1.225, 1.060), new CornerRounding.CornerRounding(0.211)), + new PointNRound(new Offset.Offset(0.500, 0.892), new CornerRounding.CornerRounding(0.313)), + new PointNRound(new Offset.Offset(-0.216, 1.050), new CornerRounding.CornerRounding(0.207)), + new PointNRound(new Offset.Offset(0.499, -0.160), new CornerRounding.CornerRounding(0.215, 1.000)), + ], 1).normalized(); +} + +function semiCircle() { + return RoundedPolygon.RoundedPolygon.rectangle(1.6, 1, CornerRounding.Unrounded, [cornerRound20, cornerRound20, cornerRound100, cornerRound100]).normalized(); +} + +function oval() { + const scaleMatrix = new Matrix.Matrix(); + scaleMatrix.scale(1, 0.64); + return RoundedPolygon.RoundedPolygon.circle() + .transformed((x, y) => rotateNeg90.map(new Offset.Offset(x, y))) + .transformed((x, y) => scaleMatrix.map(new Offset.Offset(x, y))) + .transformed((x, y) => rotate135.map(new Offset.Offset(x, y))) + .normalized(); +} + +function pill() { + return customPolygon([ + // new PointNRound(new Offset.Offset(0.609, 0.000), new CornerRounding.CornerRounding(1.000)), + new PointNRound(new Offset.Offset(0.428, -0.001), new CornerRounding.CornerRounding(0.426)), + new PointNRound(new Offset.Offset(0.961, 0.039), new CornerRounding.CornerRounding(0.426)), + new PointNRound(new Offset.Offset(1.001, 0.428)), + new PointNRound(new Offset.Offset(1.000, 0.609), new CornerRounding.CornerRounding(1.000)), + ], 2) + .transformed((x, y) => rotate180.map(new Offset.Offset(x, y))) + .normalized(); +} + +function triangle() { + return RoundedPolygon.RoundedPolygon.fromNumVertices(3, 1, 0.5, 0.5, cornerRound20) + .transformed((x, y) => rotate30.map(new Offset.Offset(x, y))) + .normalized() +} + +function diamond() { + return customPolygon([ + new PointNRound(new Offset.Offset(0.500, 1.096), new CornerRounding.CornerRounding(0.151, 0.524)), + new PointNRound(new Offset.Offset(0.040, 0.500), new CornerRounding.CornerRounding(0.159)), + ], 2).normalized(); +} + +function clamShell() { + return customPolygon([ + new PointNRound(new Offset.Offset(0.829, 0.841), new CornerRounding.CornerRounding(0.159)), + new PointNRound(new Offset.Offset(0.171, 0.841), new CornerRounding.CornerRounding(0.159)), + new PointNRound(new Offset.Offset(-0.020, 0.500), new CornerRounding.CornerRounding(0.140)), + ], 2).normalized(); +} + +function pentagon() { + return customPolygon([ + new PointNRound(new Offset.Offset(0.828, 0.970), new CornerRounding.CornerRounding(0.169)), + new PointNRound(new Offset.Offset(0.172, 0.970), new CornerRounding.CornerRounding(0.169)), + new PointNRound(new Offset.Offset(-0.030, 0.365), new CornerRounding.CornerRounding(0.164)), + new PointNRound(new Offset.Offset(0.500, -0.009), new CornerRounding.CornerRounding(0.172)), + new PointNRound(new Offset.Offset(1.030, 0.365), new CornerRounding.CornerRounding(0.164)), + ], 1).normalized(); +} + +function gem() { + return customPolygon([ + new PointNRound(new Offset.Offset(1.005, 0.792), new CornerRounding.CornerRounding(0.208)), + new PointNRound(new Offset.Offset(0.5, 1.023), new CornerRounding.CornerRounding(0.241, 0.778)), + new PointNRound(new Offset.Offset(-0.005, 0.792), new CornerRounding.CornerRounding(0.208)), + new PointNRound(new Offset.Offset(0.073, 0.258), new CornerRounding.CornerRounding(0.228)), + new PointNRound(new Offset.Offset(0.5, 0.000), new CornerRounding.CornerRounding(0.241, 0.778)), + new PointNRound(new Offset.Offset(0.927, 0.258), new CornerRounding.CornerRounding(0.228)), + ], 1).normalized(); +} + +function sunny() { + return RoundedPolygon.RoundedPolygon.star(8, 1, 0.8, cornerRound15) + .transformed((x, y) => rotate45.map(new Offset.Offset(x, y))) + .normalized(); +} + +function verySunny() { + return customPolygon([ + new PointNRound(new Offset.Offset(0.500, 1.080), new CornerRounding.CornerRounding(0.085)), + new PointNRound(new Offset.Offset(0.358, 0.843), new CornerRounding.CornerRounding(0.085)), + ], 8) + .transformed((x, y) => rotateNeg45.map(new Offset.Offset(x, y))) + .normalized(); +} + +function cookie4() { + return customPolygon([ + new PointNRound(new Offset.Offset(1.237, 1.236), new CornerRounding.CornerRounding(0.258)), + new PointNRound(new Offset.Offset(0.500, 0.918), new CornerRounding.CornerRounding(0.233)), + ], 4).normalized(); +} + +function cookie6() { + return customPolygon([ + new PointNRound(new Offset.Offset(0.723, 0.884), new CornerRounding.CornerRounding(0.394)), + new PointNRound(new Offset.Offset(0.500, 1.099), new CornerRounding.CornerRounding(0.398)), + ], 6).normalized(); +} + +function cookie7() { + return RoundedPolygon.RoundedPolygon.star(7, 1, 0.75, cornerRound50) + .normalized() + .transformed((x, y) => rotate28th.map(new Offset.Offset(x, y))) + .transformed((x, y) => rotate28th.map(new Offset.Offset(x, y))) + .transformed((x, y) => rotate28th.map(new Offset.Offset(x, y))) + .transformed((x, y) => rotate28th.map(new Offset.Offset(x, y))) + .transformed((x, y) => rotate28th.map(new Offset.Offset(x, y))) + .normalized(); +} + +function cookie9() { + return RoundedPolygon.RoundedPolygon.star(9, 1, 0.8, cornerRound50) + .transformed((x, y) => rotate30.map(new Offset.Offset(x, y))) + .normalized(); +} + +function cookie12() { + return RoundedPolygon.RoundedPolygon.star(12, 1, 0.8, cornerRound50) + .transformed((x, y) => rotate30.map(new Offset.Offset(x, y))) + .normalized(); +} + +function ghostish() { + return customPolygon([ + new PointNRound(new Offset.Offset(1.000, 1.140), new CornerRounding.CornerRounding(0.254, 0.106)), + new PointNRound(new Offset.Offset(0.575, 0.906), new CornerRounding.CornerRounding(0.253)), + new PointNRound(new Offset.Offset(0.425, 0.906), new CornerRounding.CornerRounding(0.253)), + new PointNRound(new Offset.Offset(0.000, 1.140), new CornerRounding.CornerRounding(0.254, 0.106)), + new PointNRound(new Offset.Offset(0.000, 0.000), new CornerRounding.CornerRounding(1.0)), + new PointNRound(new Offset.Offset(0.500, 0.000), new CornerRounding.CornerRounding(1.0)), + new PointNRound(new Offset.Offset(1.000, 0.000), new CornerRounding.CornerRounding(1.0)), + ], 1).normalized(); +} + +function clover4() { + return customPolygon([ + new PointNRound(new Offset.Offset(1.099, 0.725), new CornerRounding.CornerRounding(0.476)), + new PointNRound(new Offset.Offset(0.725, 1.099), new CornerRounding.CornerRounding(0.476)), + new PointNRound(new Offset.Offset(0.500, 0.926)), + ], 4).normalized(); +} + +function clover8() { + return customPolygon([ + new PointNRound(new Offset.Offset(0.758, 1.101), new CornerRounding.CornerRounding(0.209)), + new PointNRound(new Offset.Offset(0.500, 0.964)), + ], 8).normalized(); +} + +function burst() { + return customPolygon([ + new PointNRound(new Offset.Offset(0.592, 0.842), new CornerRounding.CornerRounding(0.006)), + new PointNRound(new Offset.Offset(0.500, 1.006), new CornerRounding.CornerRounding(0.006)), + ], 12) + .transformed((x, y) => rotateNeg30.map(new Offset.Offset(x, y))) + .transformed((x, y) => rotateNeg30.map(new Offset.Offset(x, y))) + .normalized(); +} + +function softBurst() { + return customPolygon([ + new PointNRound(new Offset.Offset(0.193, 0.277), new CornerRounding.CornerRounding(0.053)), + new PointNRound(new Offset.Offset(0.176, 0.055), new CornerRounding.CornerRounding(0.053)), + ], 10) + .transformed((x, y) => rotate180.map(new Offset.Offset(x, y))) + .normalized(); +} + +function boom() { + return customPolygon([ + new PointNRound(new Offset.Offset(0.457, 0.296), new CornerRounding.CornerRounding(0.007)), + new PointNRound(new Offset.Offset(0.500, -0.051), new CornerRounding.CornerRounding(0.007)), + ], 15) + .transformed((x, y) => rotate120.map(new Offset.Offset(x, y))) + .normalized(); +} + +function softBoom() { + return customPolygon([ + new PointNRound(new Offset.Offset(0.733, 0.454)), + new PointNRound(new Offset.Offset(0.839, 0.437), new CornerRounding.CornerRounding(0.532)), + new PointNRound(new Offset.Offset(0.949, 0.449), new CornerRounding.CornerRounding(0.439, 1.000)), + new PointNRound(new Offset.Offset(0.998, 0.478), new CornerRounding.CornerRounding(0.174)), + // mirrored points + new PointNRound(new Offset.Offset(0.998, 0.522), new CornerRounding.CornerRounding(0.174)), + new PointNRound(new Offset.Offset(0.949, 0.551), new CornerRounding.CornerRounding(0.439, 1.000)), + new PointNRound(new Offset.Offset(0.839, 0.563), new CornerRounding.CornerRounding(0.532)), + new PointNRound(new Offset.Offset(0.733, 0.546)), + ], 16) + .transformed((x, y) => rotate45.map(new Offset.Offset(x, y))) + .transformed((x, y) => rotateNeg16th.map(new Offset.Offset(x, y))) + .normalized(); +} + +function flower() { + return customPolygon([ + new PointNRound(new Offset.Offset(0.370, 0.187)), + new PointNRound(new Offset.Offset(0.416, 0.049), new CornerRounding.CornerRounding(0.381)), + new PointNRound(new Offset.Offset(0.479, 0.001), new CornerRounding.CornerRounding(0.095)), + // mirrored points + new PointNRound(new Offset.Offset(0.521, 0.001), new CornerRounding.CornerRounding(0.095)), + new PointNRound(new Offset.Offset(0.584, 0.049), new CornerRounding.CornerRounding(0.381)), + new PointNRound(new Offset.Offset(0.630, 0.187)), + ], 8) + .transformed((x, y) => rotate135.map(new Offset.Offset(x, y))) + .normalized(); +} + +function puffy() { + const m = new Matrix.Matrix(); + m.scale(1, 0.742); + const shape = customPolygon([ + // mirrored points + new PointNRound(new Offset.Offset(1.003, 0.563), new CornerRounding.CornerRounding(0.255)), + new PointNRound(new Offset.Offset(0.940, 0.656), new CornerRounding.CornerRounding(0.126)), + new PointNRound(new Offset.Offset(0.881, 0.654)), + new PointNRound(new Offset.Offset(0.926, 0.711), new CornerRounding.CornerRounding(0.660)), + new PointNRound(new Offset.Offset(0.914, 0.851), new CornerRounding.CornerRounding(0.660)), + new PointNRound(new Offset.Offset(0.777, 0.998), new CornerRounding.CornerRounding(0.360)), + new PointNRound(new Offset.Offset(0.722, 0.872)), + new PointNRound(new Offset.Offset(0.717, 0.934), new CornerRounding.CornerRounding(0.574)), + new PointNRound(new Offset.Offset(0.670, 1.035), new CornerRounding.CornerRounding(0.426)), + new PointNRound(new Offset.Offset(0.545, 1.040), new CornerRounding.CornerRounding(0.405)), + new PointNRound(new Offset.Offset(0.500, 0.947)), + // original points + new PointNRound(new Offset.Offset(0.500, 1-0.053)), + new PointNRound(new Offset.Offset(1-0.545, 1+0.040), new CornerRounding.CornerRounding(0.405)), + new PointNRound(new Offset.Offset(1-0.670, 1+0.035), new CornerRounding.CornerRounding(0.426)), + new PointNRound(new Offset.Offset(1-0.717, 1-0.066), new CornerRounding.CornerRounding(0.574)), + new PointNRound(new Offset.Offset(1-0.722, 1-0.128)), + new PointNRound(new Offset.Offset(1-0.777, 1-0.002), new CornerRounding.CornerRounding(0.360)), + new PointNRound(new Offset.Offset(1-0.914, 1-0.149), new CornerRounding.CornerRounding(0.660)), + new PointNRound(new Offset.Offset(1-0.926, 1-0.289), new CornerRounding.CornerRounding(0.660)), + new PointNRound(new Offset.Offset(1-0.881, 1-0.346)), + new PointNRound(new Offset.Offset(1-0.940, 1-0.344), new CornerRounding.CornerRounding(0.126)), + new PointNRound(new Offset.Offset(1-1.003, 1-0.437), new CornerRounding.CornerRounding(0.255)), + ], 2); + return shape.transformed((x, y) => m.map(new Offset.Offset(x, y))).normalized(); +} + +function puffyDiamond() { + return customPolygon([ + // original points + new PointNRound(new Offset.Offset(0.870, 0.130), new CornerRounding.CornerRounding(0.146)), + new PointNRound(new Offset.Offset(0.818, 0.357)), + new PointNRound(new Offset.Offset(1.000, 0.332), new CornerRounding.CornerRounding(0.853)), + // mirrored points + new PointNRound(new Offset.Offset(1.000, 1-0.332), new CornerRounding.CornerRounding(0.853)), + new PointNRound(new Offset.Offset(0.818, 1-0.357)), + ], 4) + .transformed((x, y) => rotate90.map(new Offset.Offset(x, y))) + .normalized(); +} + +function pixelCircle() { + return customPolygon([ + new PointNRound(new Offset.Offset(1.000, 0.704)), + new PointNRound(new Offset.Offset(0.926, 0.704)), + new PointNRound(new Offset.Offset(0.926, 0.852)), + new PointNRound(new Offset.Offset(0.843, 0.852)), + new PointNRound(new Offset.Offset(0.843, 0.935)), + new PointNRound(new Offset.Offset(0.704, 0.935)), + new PointNRound(new Offset.Offset(0.704, 1.000)), + new PointNRound(new Offset.Offset(0.500, 1.000)), + new PointNRound(new Offset.Offset(1-0.704, 1.000)), + new PointNRound(new Offset.Offset(1-0.704, 0.935)), + new PointNRound(new Offset.Offset(1-0.843, 0.935)), + new PointNRound(new Offset.Offset(1-0.843, 0.852)), + new PointNRound(new Offset.Offset(1-0.926, 0.852)), + new PointNRound(new Offset.Offset(1-0.926, 0.704)), + new PointNRound(new Offset.Offset(1-1.000, 0.704)), + ], 2) + .normalized(); +} + +function pixelTriangle() { + return customPolygon([ + // mirrored points + new PointNRound(new Offset.Offset(0.888, 1-0.439)), + new PointNRound(new Offset.Offset(0.789, 1-0.439)), + new PointNRound(new Offset.Offset(0.789, 1-0.344)), + new PointNRound(new Offset.Offset(0.675, 1-0.344)), + new PointNRound(new Offset.Offset(0.674, 1-0.265)), + new PointNRound(new Offset.Offset(0.560, 1-0.265)), + new PointNRound(new Offset.Offset(0.560, 1-0.170)), + new PointNRound(new Offset.Offset(0.421, 1-0.170)), + new PointNRound(new Offset.Offset(0.421, 1-0.087)), + new PointNRound(new Offset.Offset(0.287, 1-0.087)), + new PointNRound(new Offset.Offset(0.287, 1-0.000)), + new PointNRound(new Offset.Offset(0.113, 1-0.000)), + // original points + new PointNRound(new Offset.Offset(0.110, 0.500)), + new PointNRound(new Offset.Offset(0.113, 0.000)), + new PointNRound(new Offset.Offset(0.287, 0.000)), + new PointNRound(new Offset.Offset(0.287, 0.087)), + new PointNRound(new Offset.Offset(0.421, 0.087)), + new PointNRound(new Offset.Offset(0.421, 0.170)), + new PointNRound(new Offset.Offset(0.560, 0.170)), + new PointNRound(new Offset.Offset(0.560, 0.265)), + new PointNRound(new Offset.Offset(0.674, 0.265)), + new PointNRound(new Offset.Offset(0.675, 0.344)), + new PointNRound(new Offset.Offset(0.789, 0.344)), + new PointNRound(new Offset.Offset(0.789, 0.439)), + new PointNRound(new Offset.Offset(0.888, 0.439)), + ], 1).normalized(); +} + +function bun() { + return customPolygon([ + // original points + new PointNRound(new Offset.Offset(0.796, 0.500)), + new PointNRound(new Offset.Offset(0.853, 0.518), cornerRound100), + new PointNRound(new Offset.Offset(0.992, 0.631), cornerRound100), + new PointNRound(new Offset.Offset(0.968, 1.000), cornerRound100), + // mirrored points + new PointNRound(new Offset.Offset(0.032, 1-0.000), cornerRound100), + new PointNRound(new Offset.Offset(0.008, 1-0.369), cornerRound100), + new PointNRound(new Offset.Offset(0.147, 1-0.482), cornerRound100), + new PointNRound(new Offset.Offset(0.204, 1-0.500)), + ], 2).normalized(); +} + +function heart() { + return customPolygon([ + new PointNRound(new Offset.Offset(0.782, 0.611)), + new PointNRound(new Offset.Offset(0.499, 0.946), new CornerRounding.CornerRounding(0.000)), + new PointNRound(new Offset.Offset(0.2175, 0.611)), + new PointNRound(new Offset.Offset(-0.064, 0.276), new CornerRounding.CornerRounding(1.000)), + new PointNRound(new Offset.Offset(0.208, -0.066), new CornerRounding.CornerRounding(0.958)), + new PointNRound(new Offset.Offset(0.500, 0.268), new CornerRounding.CornerRounding(0.016)), + new PointNRound(new Offset.Offset(0.792, -0.066), new CornerRounding.CornerRounding(0.958)), + new PointNRound(new Offset.Offset(1.064, 0.276), new CornerRounding.CornerRounding(1.000)), + ], 1) + .normalized(); +} + +class PointNRound { + constructor(o, r = CornerRounding.Unrounded) { + this.o = o; + this.r = r; + } +} + +function doRepeat(points, reps, center, mirroring) { + if (mirroring) { + const result = []; + const angles = points.map(p => p.o.minus(center).angleDegrees()); + const distances = points.map(p => p.o.minus(center).getDistance()); + const actualReps = reps * 2; + const sectionAngle = 360 / actualReps; + for (let it = 0; it < actualReps; it++) { + for (let index = 0; index < points.length; index++) { + const i = (it % 2 === 0) ? index : points.length - 1 - index; + if (i > 0 || it % 2 === 0) { + const baseAngle = angles[i]; + const angle = it * sectionAngle + (it % 2 === 0 ? baseAngle : (2 * angles[0] - baseAngle)); + const dist = distances[i]; + const rad = angle * Math.PI / 180; + const x = center.x + dist * Math.cos(rad); + const y = center.y + dist * Math.sin(rad); + result.push(new PointNRound(new Offset.Offset(x, y), points[i].r)); + } + } + } + return result; + } else { + const np = points.length; + const result = []; + for (let i = 0; i < np * reps; i++) { + const point = points[i % np].o.rotateDegrees(Math.floor(i / np) * 360 / reps, center); + result.push(new PointNRound(point, points[i % np].r)); + } + return result; + } +} + +function customPolygon(pnr, reps = 1, center = new Offset.Offset(0.5, 0.5), mirroring = false) { + const actualPoints = doRepeat(pnr, reps, center, mirroring); + const vertices = []; + for (const p of actualPoints) { + vertices.push(p.o.x); + vertices.push(p.o.y); + } + const perVertexRounding = actualPoints.map(p => p.r); + return RoundedPolygon.RoundedPolygon.fromVertices(vertices, CornerRounding.Unrounded, perVertexRounding, center.x, center.y); +} diff --git a/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/shapes/corner-rounding.js b/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/shapes/corner-rounding.js new file mode 100644 index 0000000..da456fa --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/shapes/corner-rounding.js @@ -0,0 +1,18 @@ +.pragma library + +/** + * Represents corner rounding configuration + */ +class CornerRounding { + /** + * @param {float} [radius=0] + * @param {float} [smoothing=0] + */ + constructor(radius = 0, smoothing = 0) { + this.radius = radius; + this.smoothing = smoothing; + } +} + +// Static property +CornerRounding.Unrounded = new CornerRounding(); \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/shapes/cubic.js b/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/shapes/cubic.js new file mode 100644 index 0000000..1e3b0bd --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/shapes/cubic.js @@ -0,0 +1,371 @@ +.pragma library +.import "point.js" as PointModule +.import "utils.js" as UtilsModule + +var Point = PointModule.Point; +var DistanceEpsilon = UtilsModule.DistanceEpsilon; +var interpolate = UtilsModule.interpolate; +var directionVector = UtilsModule.directionVector; +var distance = UtilsModule.distance; + +/** + * Represents a cubic Bézier curve with anchor and control points + */ +class Cubic { + /** + * @param {Array} points Array of 8 numbers [anchor0X, anchor0Y, control0X, control0Y, control1X, control1Y, anchor1X, anchor1Y] + */ + constructor(points) { + this.points = points; + } + + get anchor0X() { return this.points[0]; } + get anchor0Y() { return this.points[1]; } + get control0X() { return this.points[2]; } + get control0Y() { return this.points[3]; } + get control1X() { return this.points[4]; } + get control1Y() { return this.points[5]; } + get anchor1X() { return this.points[6]; } + get anchor1Y() { return this.points[7]; } + + /** + * @param {Point} anchor0 + * @param {Point} control0 + * @param {Point} control1 + * @param {Point} anchor1 + * @returns {Cubic} + */ + static create(anchor0, control0, control1, anchor1) { + return new Cubic([ + anchor0.x, anchor0.y, + control0.x, control0.y, + control1.x, control1.y, + anchor1.x, anchor1.y + ]); + } + + /** + * @param {float} t + * @returns {Point} + */ + pointOnCurve(t) { + const u = 1 - t; + return new Point( + this.anchor0X * (u * u * u) + + this.control0X * (3 * t * u * u) + + this.control1X * (3 * t * t * u) + + this.anchor1X * (t * t * t), + this.anchor0Y * (u * u * u) + + this.control0Y * (3 * t * u * u) + + this.control1Y * (3 * t * t * u) + + this.anchor1Y * (t * t * t) + ); + } + + /** + * @returns {boolean} + */ + zeroLength() { + return Math.abs(this.anchor0X - this.anchor1X) < DistanceEpsilon && + Math.abs(this.anchor0Y - this.anchor1Y) < DistanceEpsilon; + } + + /** + * @param {Cubic} next + * @returns {boolean} + */ + convexTo(next) { + const prevVertex = new Point(this.anchor0X, this.anchor0Y); + const currVertex = new Point(this.anchor1X, this.anchor1Y); + const nextVertex = new Point(next.anchor1X, next.anchor1Y); + return convex(prevVertex, currVertex, nextVertex); + } + + /** + * @param {float} value + * @returns {boolean} + */ + zeroIsh(value) { + return Math.abs(value) < DistanceEpsilon; + } + + /** + * @param {Array} bounds + * @param {boolean} [approximate=false] + */ + calculateBounds(bounds, approximate = false) { + if (this.zeroLength()) { + bounds[0] = this.anchor0X; + bounds[1] = this.anchor0Y; + bounds[2] = this.anchor0X; + bounds[3] = this.anchor0Y; + return; + } + + let minX = Math.min(this.anchor0X, this.anchor1X); + let minY = Math.min(this.anchor0Y, this.anchor1Y); + let maxX = Math.max(this.anchor0X, this.anchor1X); + let maxY = Math.max(this.anchor0Y, this.anchor1Y); + + if (approximate) { + bounds[0] = Math.min(minX, Math.min(this.control0X, this.control1X)); + bounds[1] = Math.min(minY, Math.min(this.control0Y, this.control1Y)); + bounds[2] = Math.max(maxX, Math.max(this.control0X, this.control1X)); + bounds[3] = Math.max(maxY, Math.max(this.control0Y, this.control1Y)); + return; + } + + // Find extrema using derivatives + const xa = -this.anchor0X + 3 * this.control0X - 3 * this.control1X + this.anchor1X; + const xb = 2 * this.anchor0X - 4 * this.control0X + 2 * this.control1X; + const xc = -this.anchor0X + this.control0X; + + if (this.zeroIsh(xa)) { + if (xb != 0) { + const t = 2 * xc / (-2 * xb); + if (t >= 0 && t <= 1) { + const it = this.pointOnCurve(t).x; + if (it < minX) minX = it; + if (it > maxX) maxX = it; + } + } + } else { + const xs = xb * xb - 4 * xa * xc; + if (xs >= 0) { + const t1 = (-xb + Math.sqrt(xs)) / (2 * xa); + if (t1 >= 0 && t1 <= 1) { + const it = this.pointOnCurve(t1).x; + if (it < minX) minX = it; + if (it > maxX) maxX = it; + } + + const t2 = (-xb - Math.sqrt(xs)) / (2 * xa); + if (t2 >= 0 && t2 <= 1) { + const it = this.pointOnCurve(t2).x; + if (it < minX) minX = it; + if (it > maxX) maxX = it; + } + } + } + + // Repeat for y coord + const ya = -this.anchor0Y + 3 * this.control0Y - 3 * this.control1Y + this.anchor1Y; + const yb = 2 * this.anchor0Y - 4 * this.control0Y + 2 * this.control1Y; + const yc = -this.anchor0Y + this.control0Y; + + if (this.zeroIsh(ya)) { + if (yb != 0) { + const t = 2 * yc / (-2 * yb); + if (t >= 0 && t <= 1) { + const it = this.pointOnCurve(t).y; + if (it < minY) minY = it; + if (it > maxY) maxY = it; + } + } + } else { + const ys = yb * yb - 4 * ya * yc; + if (ys >= 0) { + const t1 = (-yb + Math.sqrt(ys)) / (2 * ya); + if (t1 >= 0 && t1 <= 1) { + const it = this.pointOnCurve(t1).y; + if (it < minY) minY = it; + if (it > maxY) maxY = it; + } + + const t2 = (-yb - Math.sqrt(ys)) / (2 * ya); + if (t2 >= 0 && t2 <= 1) { + const it = this.pointOnCurve(t2).y; + if (it < minY) minY = it; + if (it > maxY) maxY = it; + } + } + } + bounds[0] = minX; + bounds[1] = minY; + bounds[2] = maxX; + bounds[3] = maxY; + } + + /** + * @param {float} t + * @returns {{a: Cubic, b: Cubic}} + */ + split(t) { + const u = 1 - t; + const pointOnCurve = this.pointOnCurve(t); + return { + a: new Cubic([ + this.anchor0X, + this.anchor0Y, + this.anchor0X * u + this.control0X * t, + this.anchor0Y * u + this.control0Y * t, + this.anchor0X * (u * u) + this.control0X * (2 * u * t) + this.control1X * (t * t), + this.anchor0Y * (u * u) + this.control0Y * (2 * u * t) + this.control1Y * (t * t), + pointOnCurve.x, + pointOnCurve.y + ]), + b: new Cubic([ + pointOnCurve.x, + pointOnCurve.y, + this.control0X * (u * u) + this.control1X * (2 * u * t) + this.anchor1X * (t * t), + this.control0Y * (u * u) + this.control1Y * (2 * u * t) + this.anchor1Y * (t * t), + this.control1X * u + this.anchor1X * t, + this.control1Y * u + this.anchor1Y * t, + this.anchor1X, + this.anchor1Y + ]) + }; + } + + /** + * @returns {Cubic} + */ + reverse() { + return new Cubic([ + this.anchor1X, this.anchor1Y, + this.control1X, this.control1Y, + this.control0X, this.control0Y, + this.anchor0X, this.anchor0Y + ]); + } + + /** + * @param {Cubic} other + * @returns {Cubic} + */ + plus(other) { + return new Cubic(other.points.map((_, index) => this.points[index] + other.points[index])); + } + + /** + * @param {float} x + * @returns {Cubic} + */ + times(x) { + return new Cubic(this.points.map(v => v * x)); + } + + /** + * @param {float} x + * @returns {Cubic} + */ + div(x) { + return this.times(1 / x); + } + + /** + * @param {Cubic} other + * @returns {boolean} + */ + equals(other) { + return this.points.every((p, i) => other.points[i] === p); + } + + /** + * @param {function(float, float): Point} f + * @returns {Cubic} + */ + transformed(f) { + const newCubic = new MutableCubic([...this.points]); + newCubic.transform(f); + return newCubic; + } + + /** + * @param {float} x0 + * @param {float} y0 + * @param {float} x1 + * @param {float} y1 + * @returns {Cubic} + */ + static straightLine(x0, y0, x1, y1) { + return new Cubic([ + x0, + y0, + interpolate(x0, x1, 1/3), + interpolate(y0, y1, 1/3), + interpolate(x0, x1, 2/3), + interpolate(y0, y1, 2/3), + x1, + y1 + ]); + } + + /** + * @param {float} centerX + * @param {float} centerY + * @param {float} x0 + * @param {float} y0 + * @param {float} x1 + * @param {float} y1 + * @returns {Cubic} + */ + static circularArc(centerX, centerY, x0, y0, x1, y1) { + const p0d = directionVector(x0 - centerX, y0 - centerY); + const p1d = directionVector(x1 - centerX, y1 - centerY); + const rotatedP0 = p0d.rotate90(); + const rotatedP1 = p1d.rotate90(); + const clockwise = rotatedP0.dotProductScalar(x1 - centerX, y1 - centerY) >= 0; + const cosa = p0d.dotProduct(p1d); + + if (cosa > 0.999) { + return Cubic.straightLine(x0, y0, x1, y1); + } + + const k = distance(x0 - centerX, y0 - centerY) * 4/3 * + (Math.sqrt(2 * (1 - cosa)) - Math.sqrt(1 - cosa * cosa)) / + (1 - cosa) * (clockwise ? 1 : -1); + + return new Cubic([ + x0, y0, + x0 + rotatedP0.x * k, + y0 + rotatedP0.y * k, + x1 - rotatedP1.x * k, + y1 - rotatedP1.y * k, + x1, y1 + ]); + } + + /** + * @param {float} x0 + * @param {float} y0 + * @returns {Cubic} + */ + static empty(x0, y0) { + return new Cubic([x0, y0, x0, y0, x0, y0, x0, y0]); + } +} + +class MutableCubic extends Cubic { + /** + * @param {function(float, float): Point} f + */ + transform(f) { + this.transformOnePoint(f, 0); + this.transformOnePoint(f, 2); + this.transformOnePoint(f, 4); + this.transformOnePoint(f, 6); + } + + /** + * @param {Cubic} c1 + * @param {Cubic} c2 + * @param {float} progress + */ + interpolate(c1, c2, progress) { + for (let i = 0; i < 8; i++) { + this.points[i] = interpolate(c1.points[i], c2.points[i], progress); + } + } + + /** + * @private + * @param {function(float, float): Point} f + * @param {number} ix + */ + transformOnePoint(f, ix) { + const result = f(this.points[ix], this.points[ix + 1]); + this.points[ix] = result.x; + this.points[ix + 1] = result.y; + } +} \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/shapes/feature-mapping.js b/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/shapes/feature-mapping.js new file mode 100644 index 0000000..16db92c --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/shapes/feature-mapping.js @@ -0,0 +1,166 @@ +.pragma library +.import "feature.js" as FeatureModule +.import "float-mapping.js" as MappingModule +.import "point.js" as PointModule +.import "utils.js" as UtilsModule + +var Feature = FeatureModule.Feature; +var Corner = FeatureModule.Corner; +var Point = PointModule.Point; +var DoubleMapper = MappingModule.DoubleMapper; +var progressInRange = MappingModule.progressInRange; +var DistanceEpsilon = UtilsModule.DistanceEpsilon; + +var IdentityMapping = [{ a: 0, b: 0 }, { a: 0.5, b: 0.5 }]; + +class ProgressableFeature { + /** + * @param {float} progress + * @param {Feature} feature + */ + constructor(progress, feature) { + this.progress = progress; + this.feature = feature; + } +} + +class DistanceVertex { + /** + * @param {float} distance + * @param {ProgressableFeature} f1 + * @param {ProgressableFeature} f2 + */ + constructor(distance, f1, f2) { + this.distance = distance; + this.f1 = f1; + this.f2 = f2; + } +} + +class MappingHelper { + constructor() { + this.mapping = []; + this.usedF1 = new Set(); + this.usedF2 = new Set(); + } + + /** + * @param {ProgressableFeature} f1 + * @param {ProgressableFeature} f2 + */ + addMapping(f1, f2) { + if (this.usedF1.has(f1) || this.usedF2.has(f2)) { + return; + } + + const index = this.mapping.findIndex(x => x.a === f1.progress); + const insertionIndex = -index - 1; + const n = this.mapping.length; + + if (n >= 1) { + const { a: before1, b: before2 } = this.mapping[(insertionIndex + n - 1) % n]; + const { a: after1, b: after2 } = this.mapping[insertionIndex % n]; + + if ( + progressDistance(f1.progress, before1) < DistanceEpsilon || + progressDistance(f1.progress, after1) < DistanceEpsilon || + progressDistance(f2.progress, before2) < DistanceEpsilon || + progressDistance(f2.progress, after2) < DistanceEpsilon + ) { + return; + } + + if (n > 1 && !progressInRange(f2.progress, before2, after2)) { + return; + } + } + + this.mapping.splice(insertionIndex, 0, { a: f1.progress, b: f2.progress }); + this.usedF1.add(f1); + this.usedF2.add(f2); + } +} + +/** + * @param {Array} features1 + * @param {Array} features2 + * @returns {DoubleMapper} + */ +function featureMapper(features1, features2) { + const filteredFeatures1 = features1.filter(f => f.feature instanceof Corner); + const filteredFeatures2 = features2.filter(f => f.feature instanceof Corner); + + const featureProgressMapping = doMapping(filteredFeatures1, filteredFeatures2); + return new DoubleMapper(...featureProgressMapping); +} + +/** + * @param {Array} features1 + * @param {Array} features2 + * @returns {Array<{a: float, b: float}>} + */ +function doMapping(features1, features2) { + const distanceVertexList = []; + + for (const f1 of features1) { + for (const f2 of features2) { + const d = featureDistSquared(f1.feature, f2.feature); + if (d !== Number.MAX_VALUE) { + distanceVertexList.push(new DistanceVertex(d, f1, f2)); + } + } + } + + distanceVertexList.sort((a, b) => a.distance - b.distance); + + // Special cases + if (distanceVertexList.length === 0) { + return IdentityMapping; + } else if (distanceVertexList.length === 1) { + const { f1, f2 } = distanceVertexList[0]; + const p1 = f1.progress; + const p2 = f2.progress; + return [ + { a: p1, b: p2 }, + { a: (p1 + 0.5) % 1, b: (p2 + 0.5) % 1 } + ]; + } + + const helper = new MappingHelper(); + distanceVertexList.forEach(({ f1, f2 }) => helper.addMapping(f1, f2)); + return helper.mapping; +} + +/** + * @param {Feature} f1 + * @param {Feature} f2 + * @returns {float} + */ +function featureDistSquared(f1, f2) { + if (f1 instanceof Corner && f2 instanceof Corner && f1.convex != f2.convex) { + return Number.MAX_VALUE; + } + return featureRepresentativePoint(f1).minus(featureRepresentativePoint(f2)).getDistanceSquared(); +} + +/** + * @param {Feature} feature + * @returns {Point} + */ +function featureRepresentativePoint(feature) { + const firstCubic = feature.cubics[0]; + const lastCubic = feature.cubics[feature.cubics.length - 1]; + const x = (firstCubic.anchor0X + lastCubic.anchor1X) / 2; + const y = (firstCubic.anchor0Y + lastCubic.anchor1Y) / 2; + return new Point(x, y); +} + +/** + * @param {float} p1 + * @param {float} p2 + * @returns {float} + */ +function progressDistance(p1, p2) { + const it = Math.abs(p1 - p2); + return Math.min(it, 1 - it); +} \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/shapes/feature.js b/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/shapes/feature.js new file mode 100644 index 0000000..afd5ee5 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/shapes/feature.js @@ -0,0 +1,103 @@ +.pragma library +.import "cubic.js" as CubicModule + +var Cubic = CubicModule.Cubic; + +/** + * Base class for shape features (edges and corners) + */ +class Feature { + /** + * @param {Array} cubics + */ + constructor(cubics) { + this.cubics = cubics; + } + + /** + * @param {Array} cubics + * @returns {Edge} + */ + buildIgnorableFeature(cubics) { + return new Edge(cubics); + } + + /** + * @param {Cubic} cubic + * @returns {Edge} + */ + buildEdge(cubic) { + return new Edge([cubic]); + } + + /** + * @param {Array} cubics + * @returns {Corner} + */ + buildConvexCorner(cubics) { + return new Corner(cubics, true); + } + + /** + * @param {Array} cubics + * @returns {Corner} + */ + buildConcaveCorner(cubics) { + return new Corner(cubics, false); + } +} + +class Edge extends Feature { + constructor(cubics) { + super(cubics); + this.isIgnorableFeature = true; + this.isEdge = true; + this.isConvexCorner = false; + this.isConcaveCorner = false; + } + + /** + * @param {function(float, float): Point} f + * @returns {Feature} + */ + transformed(f) { + return new Edge(this.cubics.map(c => c.transformed(f))); + } + + /** + * @returns {Feature} + */ + reversed() { + return new Edge(this.cubics.map(c => c.reverse())); + } +} + +class Corner extends Feature { + /** + * @param {Array} cubics + * @param {boolean} convex + */ + constructor(cubics, convex) { + super(cubics); + this.convex = convex; + this.isIgnorableFeature = false; + this.isEdge = false; + this.isConvexCorner = convex; + this.isConcaveCorner = !convex; + } + + /** + * @param {function(float, float): Point} f + * @returns {Feature} + */ + transformed(f) { + return new Corner(this.cubics.map(c => c.transformed(f)), this.convex); + } + + /** + * @returns {Feature} + */ + reversed() { + return new Corner(this.cubics.map(c => c.reverse()), !this.convex); + } +} \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/shapes/float-mapping.js b/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/shapes/float-mapping.js new file mode 100644 index 0000000..dc9c94a --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/shapes/float-mapping.js @@ -0,0 +1,86 @@ +.pragma library +.import "utils.js" as UtilsModule + +var positiveModulo = UtilsModule.positiveModulo; + +/** + * Maps values between two ranges + */ +class DoubleMapper { + constructor(...mappings) { + this.sourceValues = []; + this.targetValues = []; + + for (const mapping of mappings) { + this.sourceValues.push(mapping.a); + this.targetValues.push(mapping.b); + } + } + + /** + * @param {float} x + * @returns {float} + */ + map(x) { + return linearMap(this.sourceValues, this.targetValues, x); + } + + /** + * @param {float} x + * @returns {float} + */ + mapBack(x) { + return linearMap(this.targetValues, this.sourceValues, x); + } +} + +// Static property +DoubleMapper.Identity = new DoubleMapper({ a: 0, b: 0 }, { a: 0.5, b: 0.5 }); + +/** + * @param {Array} xValues + * @param {Array} yValues + * @param {float} x + * @returns {float} + */ +function linearMap(xValues, yValues, x) { + let segmentStartIndex = -1; + for (let i = 0; i < xValues.length; i++) { + const nextIndex = (i + 1) % xValues.length; + if (progressInRange(x, xValues[i], xValues[nextIndex])) { + segmentStartIndex = i; + break; + } + } + + if (segmentStartIndex === -1) { + throw new Error("No valid segment found"); + } + + const segmentEndIndex = (segmentStartIndex + 1) % xValues.length; + const segmentSizeX = positiveModulo(xValues[segmentEndIndex] - xValues[segmentStartIndex], 1); + const segmentSizeY = positiveModulo(yValues[segmentEndIndex] - yValues[segmentStartIndex], 1); + + let positionInSegment; + if (segmentSizeX < 0.001) { + positionInSegment = 0.5; + } else { + positionInSegment = positiveModulo(x - xValues[segmentStartIndex], 1) / segmentSizeX; + } + + return positiveModulo(yValues[segmentStartIndex] + segmentSizeY * positionInSegment, 1); +} + +/** + * @param {float} progress + * @param {float} progressFrom + * @param {float} progressTo + * @returns {boolean} + */ +function progressInRange(progress, progressFrom, progressTo) { + if (progressTo >= progressFrom) { + return progress >= progressFrom && progress <= progressTo; + } else { + return progress >= progressFrom || progress <= progressTo; + } +} \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/shapes/morph.js b/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/shapes/morph.js new file mode 100644 index 0000000..ae149b9 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/shapes/morph.js @@ -0,0 +1,94 @@ +.pragma library + +.import "rounded-polygon.js" as RoundedPolygon +.import "cubic.js" as Cubic +.import "polygon-measure.js" as PolygonMeasure +.import "feature-mapping.js" as FeatureMapping +.import "utils.js" as Utils + +class Morph { + constructor(start, end) { + this.morphMatch = this.match(start, end) + } + + asCubics(progress) { + const ret = [] + + // The first/last mechanism here ensures that the final anchor point in the shape + // exactly matches the first anchor point. There can be rendering artifacts introduced + // by those points being slightly off, even by much less than a pixel + let firstCubic = null + let lastCubic = null + for (let i = 0; i < this.morphMatch.length; i++) { + const cubic = new Cubic.Cubic(Array.from({ length: 8 }).map((_, it) => Utils.interpolate( + this.morphMatch[i].a.points[it], + this.morphMatch[i].b.points[it], + progress, + ))) + if (firstCubic == null) + firstCubic = cubic + if (lastCubic != null) + ret.push(lastCubic) + lastCubic = cubic + } + if (lastCubic != null && firstCubic != null) + ret.push( + new Cubic.Cubic([ + lastCubic.anchor0X, + lastCubic.anchor0Y, + lastCubic.control0X, + lastCubic.control0Y, + lastCubic.control1X, + lastCubic.control1Y, + firstCubic.anchor0X, + firstCubic.anchor0Y, + ]) + ) + return ret + } + + forEachCubic(progress, mutableCubic, callback) { + for (let i = 0; i < this.morphMatch.length; i++) { + mutableCubic.interpolate(this.morphMatch[i].a, this.morphMatch[i].b, progress) + callback(mutableCubic) + } + } + + match(p1, p2) { + const measurer = new PolygonMeasure.LengthMeasurer() + const measuredPolygon1 = PolygonMeasure.MeasuredPolygon.measurePolygon(measurer, p1) + const measuredPolygon2 = PolygonMeasure.MeasuredPolygon.measurePolygon(measurer, p2) + + const features1 = measuredPolygon1.features + const features2 = measuredPolygon2.features + + const doubleMapper = FeatureMapping.featureMapper(features1, features2) + + const polygon2CutPoint = doubleMapper.map(0) + + const bs1 = measuredPolygon1 + const bs2 = measuredPolygon2.cutAndShift(polygon2CutPoint) + + const ret = [] + + let i1 = 0 + let i2 = 0 + + let b1 = bs1.cubics[i1++] + let b2 = bs2.cubics[i2++] + + while (b1 != null && b2 != null) { + const b1a = (i1 == bs1.cubics.length) ? 1 : b1.endOutlineProgress + const b2a = (i2 == bs2.cubics.length) ? 1 : doubleMapper.mapBack(Utils.positiveModulo(b2.endOutlineProgress + polygon2CutPoint, 1)) + const minb = Math.min(b1a, b2a) + const { a: seg1, b: newb1 } = b1a > minb + Utils.AngleEpsilon ? b1.cutAtProgress(minb) : { a: b1, b: bs1.cubics[i1++] } + const { a: seg2, b: newb2 } = b2a > minb + Utils.AngleEpsilon ? b2.cutAtProgress(Utils.positiveModulo(doubleMapper.map(minb) - polygon2CutPoint, 1)) : { a: b2, b: bs2.cubics[i2++] } + + ret.push({ a: seg1.cubic, b: seg2.cubic }) + b1 = newb1 + b2 = newb2 + } + + return ret + } +} \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/shapes/point.js b/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/shapes/point.js new file mode 100644 index 0000000..d301974 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/shapes/point.js @@ -0,0 +1,154 @@ +.pragma library + +/** + * @param {number} x + * @param {number} y + * @returns {Point} + */ +function createPoint(x, y) { + return new Point(x, y); +} + +class Point { + /** + * @param {float} x + * @param {float} y + */ + constructor(x, y) { + this.x = x; + this.y = y; + } + + /** + * @param {float} x + * @param {float} y + * @returns {Point} + */ + copy(x = this.x, y = this.y) { + return new Point(x, y); + } + + /** + * @returns {float} + */ + getDistance() { + return Math.sqrt(this.x * this.x + this.y * this.y); + } + + /** + * @returns {float} + */ + getDistanceSquared() { + return this.x * this.x + this.y * this.y; + } + + /** + * @param {Point} other + * @returns {float} + */ + dotProduct(other) { + return this.x * other.x + this.y * other.y; + } + + /** + * @param {float} otherX + * @param {float} otherY + * @returns {float} + */ + dotProductScalar(otherX, otherY) { + return this.x * otherX + this.y * otherY; + } + + /** + * @param {Point} other + * @returns {boolean} + */ + clockwise(other) { + return this.x * other.y - this.y * other.x > 0; + } + + /** + * @returns {Point} + */ + getDirection() { + const d = this.getDistance(); + return this.div(d); + } + + /** + * @returns {Point} + */ + negate() { + return new Point(-this.x, -this.y); + } + + /** + * @param {Point} other + * @returns {Point} + */ + minus(other) { + return new Point(this.x - other.x, this.y - other.y); + } + + /** + * @param {Point} other + * @returns {Point} + */ + plus(other) { + return new Point(this.x + other.x, this.y + other.y); + } + + /** + * @param {float} operand + * @returns {Point} + */ + times(operand) { + return new Point(this.x * operand, this.y * operand); + } + + /** + * @param {float} operand + * @returns {Point} + */ + div(operand) { + return new Point(this.x / operand, this.y / operand); + } + + /** + * @param {float} operand + * @returns {Point} + */ + rem(operand) { + return new Point(this.x % operand, this.y % operand); + } + + /** + * @param {Point} start + * @param {Point} stop + * @param {float} fraction + * @returns {Point} + */ + static interpolate(start, stop, fraction) { + return new Point( + start.x + (stop.x - start.x) * fraction, + start.y + (stop.y - start.y) * fraction + ); + } + + /** + * @param {function(float, float): Point} f + * @returns {Point} + */ + transformed(f) { + const result = f(this.x, this.y); + return new Point(result.x, result.y); + } + + /** + * @returns {Point} + */ + rotate90() { + return new Point(-this.y, this.x); + } +} + diff --git a/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/shapes/polygon-measure.js b/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/shapes/polygon-measure.js new file mode 100644 index 0000000..bbcd7b4 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/shapes/polygon-measure.js @@ -0,0 +1,192 @@ +.pragma library + +.import "cubic.js" as Cubic +.import "point.js" as Point +.import "feature-mapping.js" as FeatureMapping +.import "utils.js" as Utils +.import "feature.js" as Feature + +class MeasuredPolygon { + constructor(measurer, features, cubics, outlineProgress) { + this.measurer = measurer + this.features = features + this.outlineProgress = outlineProgress + this.cubics = [] + + const measuredCubics = [] + let startOutlineProgress = 0 + for(let i = 0; i < cubics.length; i++) { + if ((outlineProgress[i + 1] - outlineProgress[i]) > Utils.DistanceEpsilon) { + measuredCubics.push( + new MeasuredCubic(this, cubics[i], startOutlineProgress, outlineProgress[i + 1]) + ) + // The next measured cubic will start exactly where this one ends. + startOutlineProgress = outlineProgress[i + 1] + } + } + + measuredCubics[measuredCubics.length - 1].updateProgressRange(measuredCubics[measuredCubics.length - 1].startOutlineProgress, 1) + this.cubics = measuredCubics + } + + cutAndShift(cuttingPoint) { + if (cuttingPoint < Utils.DistanceEpsilon) return this + + // Find the index of cubic we want to cut + const targetIndex = this.cubics.findIndex(it => cuttingPoint >= it.startOutlineProgress && cuttingPoint <= it.endOutlineProgress) + const target = this.cubics[targetIndex] + // Cut the target cubic. + // b1, b2 are two resulting cubics after cut + const { a: b1, b: b2 } = target.cutAtProgress(cuttingPoint) + + // Construct the list of the cubics we need: + // * The second part of the target cubic (after the cut) + // * All cubics after the target, until the end + All cubics from the start, before the + // target cubic + // * The first part of the target cubic (before the cut) + const retCubics = [b2.cubic] + for(let i = 1; i < this.cubics.length; i++) { + retCubics.push(this.cubics[(i + targetIndex) % this.cubics.length].cubic) + } + retCubics.push(b1.cubic) + + // Construct the array of outline progress. + // For example, if we have 3 cubics with outline progress [0 .. 0.3], [0.3 .. 0.8] & + // [0.8 .. 1.0], and we cut + shift at 0.6: + // 0. 0123456789 + // |--|--/-|-| + // The outline progresses will start at 0 (the cutting point, that shifs to 0.0), + // then 0.8 - 0.6 = 0.2, then 1 - 0.6 = 0.4, then 0.3 - 0.6 + 1 = 0.7, + // then 1 (the cutting point again), + // all together: (0.0, 0.2, 0.4, 0.7, 1.0) + const retOutlineProgress = [] + for (let i = 0; i < this.cubics.length + 2; i++) { + if (i === 0) { + retOutlineProgress.push(0) + } else if(i === this.cubics.length + 1) { + retOutlineProgress.push(1) + } else { + const cubicIndex = (targetIndex + i - 1) % this.cubics.length + retOutlineProgress.push(Utils.positiveModulo(this.cubics[cubicIndex].endOutlineProgress - cuttingPoint, 1)) + } + } + + // Shift the feature's outline progress too. + const newFeatures = [] + for(let i = 0; i < this.features.length; i++) { + newFeatures.push(new FeatureMapping.ProgressableFeature(Utils.positiveModulo(this.features[i].progress - cuttingPoint, 1), this.features[i].feature)) + } + + // Filter out all empty cubics (i.e. start and end anchor are (almost) the same point.) + return new MeasuredPolygon(this.measurer, newFeatures, retCubics, retOutlineProgress) + } + + static measurePolygon(measurer, polygon) { + const cubics = [] + const featureToCubic = [] + + for (let featureIndex = 0; featureIndex < polygon.features.length; featureIndex++) { + const feature = polygon.features[featureIndex] + for (let cubicIndex = 0; cubicIndex < feature.cubics.length; cubicIndex++) { + if (feature instanceof Feature.Corner && cubicIndex == feature.cubics.length / 2) { + featureToCubic.push({ a: feature, b: cubics.length }) + } + cubics.push(feature.cubics[cubicIndex]) + } + } + + const measures = [0] // Initialize with 0 like in Kotlin's scan + for (const cubic of cubics) { + const measurement = measurer.measureCubic(cubic) + if (measurement < 0) { + throw new Error("Measured cubic is expected to be greater or equal to zero") + } + const lastMeasure = measures[measures.length - 1] + measures.push(lastMeasure + measurement) + } + const totalMeasure = measures[measures.length - 1] + + const outlineProgress = [] + for (let i = 0; i < measures.length; i++) { + outlineProgress.push(measures[i] / totalMeasure) + } + + const features = [] + for (let i = 0; i < featureToCubic.length; i++) { + const ix = featureToCubic[i].b + features.push( + new FeatureMapping.ProgressableFeature(Utils.positiveModulo((outlineProgress[ix] + outlineProgress[ix + 1]) / 2, 1), featureToCubic[i].a)) + } + + return new MeasuredPolygon(measurer, features, cubics, outlineProgress) + } +} + +class MeasuredCubic { + constructor(polygon, cubic, startOutlineProgress, endOutlineProgress) { + this.polygon = polygon + this.cubic = cubic + this.startOutlineProgress = startOutlineProgress + this.endOutlineProgress = endOutlineProgress + this.measuredSize = this.polygon.measurer.measureCubic(cubic) + } + + updateProgressRange( + startOutlineProgress = this.startOutlineProgress, + endOutlineProgress = this.endOutlineProgress, + ) { + this.startOutlineProgress = startOutlineProgress + this.endOutlineProgress = endOutlineProgress + } + + cutAtProgress(cutOutlineProgress) { + const boundedCutOutlineProgress = Utils.coerceIn(cutOutlineProgress, this.startOutlineProgress, this.endOutlineProgress) + const outlineProgressSize = this.endOutlineProgress - this.startOutlineProgress + const progressFromStart = boundedCutOutlineProgress - this.startOutlineProgress + + const relativeProgress = progressFromStart / outlineProgressSize + const t = this.polygon.measurer.findCubicCutPoint(this.cubic, relativeProgress * this.measuredSize) + + const {a: c1, b: c2} = this.cubic.split(t) + return { + a: new MeasuredCubic(this.polygon, c1, this.startOutlineProgress, boundedCutOutlineProgress), + b: new MeasuredCubic(this.polygon, c2, boundedCutOutlineProgress, this.endOutlineProgress) + } + } +} + +class LengthMeasurer { + constructor() { + this.segments = 3 + } + + measureCubic(c) { + return this.closestProgressTo(c, Number.POSITIVE_INFINITY).y + } + + findCubicCutPoint(c, m) { + return this.closestProgressTo(c, m).x + } + + closestProgressTo(cubic, threshold) { + let total = 0 + let remainder = threshold + let prev = new Point.Point(cubic.anchor0X, cubic.anchor0Y) + + for (let i = 1; i < this.segments; i++) { + const progress = i / this.segments + const point = cubic.pointOnCurve(progress) + const segment = point.minus(prev).getDistance() + + if (segment >= remainder) { + return new Point.Point(progress - (1.0 - remainder / segment) / this.segments, threshold) + } + + remainder -= segment + total += segment + prev = point + } + + return new Point.Point(1.0, total) + } +} \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/shapes/rounded-corner.js b/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/shapes/rounded-corner.js new file mode 100644 index 0000000..f2d7b3c --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/shapes/rounded-corner.js @@ -0,0 +1,229 @@ +.pragma library +.import "point.js" as PointModule +.import "corner-rounding.js" as RoundingModule +.import "utils.js" as UtilsModule +.import "cubic.js" as CubicModule + +var Point = PointModule.Point; +var CornerRounding = RoundingModule.CornerRounding; +var DistanceEpsilon = UtilsModule.DistanceEpsilon; +var directionVector = UtilsModule.directionVector; +var Cubic = CubicModule.Cubic; + +class RoundedCorner { + /** + * @param {Point} p0 + * @param {Point} p1 + * @param {Point} p2 + * @param {CornerRounding} [rounding=null] + */ + constructor(p0, p1, p2, rounding = null) { + this.p0 = p0; + this.p1 = p1; + this.p2 = p2; + this.rounding = rounding; + this.center = new Point(0, 0); + + const v01 = p0.minus(p1); + const v21 = p2.minus(p1); + const d01 = v01.getDistance(); + const d21 = v21.getDistance(); + + if (d01 > 0 && d21 > 0) { + this.d1 = v01.div(d01); + this.d2 = v21.div(d21); + this.cornerRadius = rounding?.radius ?? 0; + this.smoothing = rounding?.smoothing ?? 0; + + // cosine of angle at p1 is dot product of unit vectors to the other two vertices + this.cosAngle = this.d1.dotProduct(this.d2); + + // identity: sin^2 + cos^2 = 1 + // sinAngle gives us the intersection + this.sinAngle = Math.sqrt(1 - Math.pow(this.cosAngle, 2)); + + // How much we need to cut, as measured on a side, to get the required radius + // calculating where the rounding circle hits the edge + // This uses the identity of tan(A/2) = sinA/(1 + cosA), where tan(A/2) = radius/cut + this.expectedRoundCut = this.sinAngle > 1e-3 ? this.cornerRadius * (this.cosAngle + 1) / this.sinAngle : 0; + } else { + // One (or both) of the sides is empty, not much we can do. + this.d1 = new Point(0, 0); + this.d2 = new Point(0, 0); + this.cornerRadius = 0; + this.smoothing = 0; + this.cosAngle = 0; + this.sinAngle = 0; + this.expectedRoundCut = 0; + } + } + + get expectedCut() { + return ((1 + this.smoothing) * this.expectedRoundCut); + } + + /** + * @param {float} allowedCut0 + * @param {float} [allowedCut1] + * @returns {Array} + */ + getCubics(allowedCut0, allowedCut1 = allowedCut0) { + // We use the minimum of both cuts to determine the radius, but if there is more space + // in one side we can use it for smoothing. + const allowedCut = Math.min(allowedCut0, allowedCut1); + + // Nothing to do, just use lines, or a point + if ( + this.expectedRoundCut < DistanceEpsilon || + allowedCut < DistanceEpsilon || + this.cornerRadius < DistanceEpsilon + ) { + this.center = this.p1; + return [Cubic.straightLine(this.p1.x, this.p1.y, this.p1.x, this.p1.y)]; + } + + // How much of the cut is required for the rounding part. + const actualRoundCut = Math.min(allowedCut, this.expectedRoundCut); + + // We have two smoothing values, one for each side of the vertex + // Space is used for rounding values first. If there is space left over, then we + // apply smoothing, if it was requested + const actualSmoothing0 = this.calculateActualSmoothingValue(allowedCut0); + const actualSmoothing1 = this.calculateActualSmoothingValue(allowedCut1); + + // Scale the radius if needed + const actualR = this.cornerRadius * actualRoundCut / this.expectedRoundCut; + + // Distance from the corner (p1) to the center + const centerDistance = Math.sqrt(Math.pow(actualR, 2) + Math.pow(actualRoundCut, 2)); + + // Center of the arc we will use for rounding + this.center = this.p1.plus(this.d1.plus(this.d2).div(2).getDirection().times(centerDistance)); + + const circleIntersection0 = this.p1.plus(this.d1.times(actualRoundCut)); + const circleIntersection2 = this.p1.plus(this.d2.times(actualRoundCut)); + + const flanking0 = this.computeFlankingCurve( + actualRoundCut, + actualSmoothing0, + this.p1, + this.p0, + circleIntersection0, + circleIntersection2, + this.center, + actualR + ); + + const flanking2 = this.computeFlankingCurve( + actualRoundCut, + actualSmoothing1, + this.p1, + this.p2, + circleIntersection2, + circleIntersection0, + this.center, + actualR + ).reverse(); + + return [ + flanking0, + Cubic.circularArc( + this.center.x, + this.center.y, + flanking0.anchor1X, + flanking0.anchor1Y, + flanking2.anchor0X, + flanking2.anchor0Y + ), + flanking2 + ]; + } + + /** + * @private + * @param {float} allowedCut + * @returns {float} + */ + calculateActualSmoothingValue(allowedCut) { + if (allowedCut > this.expectedCut) { + return this.smoothing; + } else if (allowedCut > this.expectedRoundCut) { + return this.smoothing * (allowedCut - this.expectedRoundCut) / (this.expectedCut - this.expectedRoundCut); + } else { + return 0; + } + } + + /** + * @private + * @param {float} actualRoundCut + * @param {float} actualSmoothingValues + * @param {Point} corner + * @param {Point} sideStart + * @param {Point} circleSegmentIntersection + * @param {Point} otherCircleSegmentIntersection + * @param {Point} circleCenter + * @param {float} actualR + * @returns {Cubic} + */ + computeFlankingCurve( + actualRoundCut, + actualSmoothingValues, + corner, + sideStart, + circleSegmentIntersection, + otherCircleSegmentIntersection, + circleCenter, + actualR + ) { + // sideStart is the anchor, 'anchor' is actual control point + const sideDirection = (sideStart.minus(corner)).getDirection(); + const curveStart = corner.plus(sideDirection.times(actualRoundCut * (1 + actualSmoothingValues))); + + // We use an approximation to cut a part of the circle section proportional to 1 - smooth, + // When smooth = 0, we take the full section, when smooth = 1, we take nothing. + const p = Point.interpolate( + circleSegmentIntersection, + (circleSegmentIntersection.plus(otherCircleSegmentIntersection)).div(2), + actualSmoothingValues + ); + + // The flanking curve ends on the circle + const curveEnd = circleCenter.plus( + directionVector(p.x - circleCenter.x, p.y - circleCenter.y).times(actualR) + ); + + // The anchor on the circle segment side is in the intersection between the tangent to the + // circle in the circle/flanking curve boundary and the linear segment. + const circleTangent = (curveEnd.minus(circleCenter)).rotate90(); + const anchorEnd = this.lineIntersection(sideStart, sideDirection, curveEnd, circleTangent) ?? circleSegmentIntersection; + + // From what remains, we pick a point for the start anchor. + // 2/3 seems to come from design tools? + const anchorStart = (curveStart.plus(anchorEnd.times(2))).div(3); + + return Cubic.create(curveStart, anchorStart, anchorEnd, curveEnd); + } + + /** + * @private + * @param {Point} p0 + * @param {Point} d0 + * @param {Point} p1 + * @param {Point} d1 + * @returns {Point|null} + */ + lineIntersection(p0, d0, p1, d1) { + const rotatedD1 = d1.rotate90(); + const den = d0.dotProduct(rotatedD1); + if (Math.abs(den) < DistanceEpsilon) return null; + + const num = (p1.minus(p0)).dotProduct(rotatedD1); + // Also check the relative value. This is equivalent to abs(den/num) < DistanceEpsilon, + // but avoid doing a division + if (Math.abs(den) < DistanceEpsilon * Math.abs(num)) return null; + + const k = num / den; + return p0.plus(d0.times(k)); + } +} \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/shapes/rounded-polygon.js b/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/shapes/rounded-polygon.js new file mode 100644 index 0000000..9815875 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/shapes/rounded-polygon.js @@ -0,0 +1,343 @@ +.pragma library + +.import "feature.js" as Feature +.import "point.js" as Point +.import "cubic.js" as Cubic +.import "utils.js" as Utils +.import "corner-rounding.js" as CornerRounding +.import "rounded-corner.js" as RoundedCorner + +class RoundedPolygon { + constructor(features, center) { + this.features = features + this.center = center + this.cubics = this.buildCubicList() + } + + get centerX() { + return this.center.x + } + + get centerY() { + return this.center.y + } + + transformed(f) { + const center = this.center.transformed(f) + return new RoundedPolygon(this.features.map(x => x.transformed(f)), center) + } + + normalized() { + const bounds = this.calculateBounds() + const width = bounds[2] - bounds[0] + const height = bounds[3] - bounds[1] + const side = Math.max(width, height) + // Center the shape if bounds are not a square + const offsetX = (side - width) / 2 - bounds[0] /* left */ + const offsetY = (side - height) / 2 - bounds[1] /* top */ + return this.transformed((x, y) => { + return new Point.Point((x + offsetX) / side, (y + offsetY) / side) + }) + } + + calculateMaxBounds(bounds = []) { + let maxDistSquared = 0 + for (let i = 0; i < this.cubics.length; i++) { + const cubic = this.cubics[i] + const anchorDistance = Utils.distanceSquared(cubic.anchor0X - this.centerX, cubic.anchor0Y - this.centerY) + const middlePoint = cubic.pointOnCurve(.5) + const middleDistance = Utils.distanceSquared(middlePoint.x - this.centerX, middlePoint.y - this.centerY) + maxDistSquared = Math.max(maxDistSquared, Math.max(anchorDistance, middleDistance)) + } + const distance = Math.sqrt(maxDistSquared) + bounds[0] = this.centerX - distance + bounds[1] = this.centerY - distance + bounds[2] = this.centerX + distance + bounds[3] = this.centerY + distance + return bounds + } + + calculateBounds(bounds = [], approximate = true) { + let minX = Number.MAX_SAFE_INTEGER + let minY = Number.MAX_SAFE_INTEGER + let maxX = Number.MIN_SAFE_INTEGER + let maxY = Number.MIN_SAFE_INTEGER + for (let i = 0; i < this.cubics.length; i++) { + const cubic = this.cubics[i] + cubic.calculateBounds(bounds, approximate) + minX = Math.min(minX, bounds[0]) + minY = Math.min(minY, bounds[1]) + maxX = Math.max(maxX, bounds[2]) + maxY = Math.max(maxY, bounds[3]) + } + bounds[0] = minX + bounds[1] = minY + bounds[2] = maxX + bounds[3] = maxY + return bounds + } + + buildCubicList() { + const result = [] + + // The first/last mechanism here ensures that the final anchor point in the shape + // exactly matches the first anchor point. There can be rendering artifacts introduced + // by those points being slightly off, even by much less than a pixel + let firstCubic = null + let lastCubic = null + let firstFeatureSplitStart = null + let firstFeatureSplitEnd = null + + if (this.features.length > 0 && this.features[0].cubics.length == 3) { + const centerCubic = this.features[0].cubics[1] + const { a: start, b: end } = centerCubic.split(.5) + firstFeatureSplitStart = [this.features[0].cubics[0], start] + firstFeatureSplitEnd = [end, this.features[0].cubics[2]] + } + + // iterating one past the features list size allows us to insert the initial split + // cubic if it exists + for (let i = 0; i <= this.features.length; i++) { + let featureCubics + if (i == 0 && firstFeatureSplitEnd != null) { + featureCubics = firstFeatureSplitEnd + } else if (i == this.features.length) { + if (firstFeatureSplitStart != null) { + featureCubics = firstFeatureSplitStart + } else { + break + } + } else { + featureCubics = this.features[i].cubics + } + + for (let j = 0; j < featureCubics.length; j++) { + // Skip zero-length curves; they add nothing and can trigger rendering artifacts + const cubic = featureCubics[j] + if (!cubic.zeroLength()) { + if (lastCubic != null) + result.push(lastCubic) + lastCubic = cubic + if (firstCubic == null) + firstCubic = cubic + } else { + if (lastCubic != null) { + // Dropping several zero-ish length curves in a row can lead to + // enough discontinuity to throw an exception later, even though the + // distances are quite small. Account for that by making the last + // cubic use the latest anchor point, always. + lastCubic = new Cubic.Cubic([...lastCubic.points]) // Make a copy before mutating + lastCubic.points[6] = cubic.anchor1X + lastCubic.points[7] = cubic.anchor1Y + } + } + } + } + if (lastCubic != null && firstCubic != null) { + result.push( + new Cubic.Cubic([ + lastCubic.anchor0X, + lastCubic.anchor0Y, + lastCubic.control0X, + lastCubic.control0Y, + lastCubic.control1X, + lastCubic.control1Y, + firstCubic.anchor0X, + firstCubic.anchor0Y, + ]) + ) + } else { + // Empty / 0-sized polygon. + result.push(new Cubic.Cubic([this.centerX, this.centerY, this.centerX, this.centerY, this.centerX, this.centerY, this.centerX, this.centerY])) + } + + return result + } + + static calculateCenter(vertices) { + let cumulativeX = 0 + let cumulativeY = 0 + let index = 0 + while (index < vertices.length) { + cumulativeX += vertices[index++] + cumulativeY += vertices[index++] + } + return new Point.Point(cumulativeX / (vertices.length / 2), cumulativeY / (vertices.length / 2)) + } + + static verticesFromNumVerts(numVertices, radius, centerX, centerY) { + const result = [] + let arrayIndex = 0 + for (let i = 0; i < numVertices; i++) { + const vertex = Utils.radialToCartesian(radius, (Math.PI / numVertices * 2 * i)).plus(new Point.Point(centerX, centerY)) + result[arrayIndex++] = vertex.x + result[arrayIndex++] = vertex.y + } + return result + } + + static fromNumVertices(numVertices, radius = 1, centerX = 0, centerY = 0, rounding = CornerRounding.Unrounded, perVertexRounding = null) { + return RoundedPolygon.fromVertices(this.verticesFromNumVerts(numVertices, radius, centerX, centerY), rounding, perVertexRounding, centerX, centerY) + } + + static fromVertices(vertices, rounding = CornerRounding.Unrounded, perVertexRounding = null, centerX = Number.MIN_SAFE_INTEGER, centerY = Number.MAX_SAFE_INTEGER) { + const corners = [] + const n = vertices.length / 2 + const roundedCorners = [] + for (let i = 0; i < n; i++) { + const vtxRounding = perVertexRounding?.[i] ?? rounding + const prevIndex = ((i + n - 1) % n) * 2 + const nextIndex = ((i + 1) % n) * 2 + roundedCorners.push( + new RoundedCorner.RoundedCorner( + new Point.Point(vertices[prevIndex], vertices[prevIndex + 1]), + new Point.Point(vertices[i * 2], vertices[i * 2 + 1]), + new Point.Point(vertices[nextIndex], vertices[nextIndex + 1]), + vtxRounding + ) + ) + } + + // For each side, check if we have enough space to do the cuts needed, and if not split + // the available space, first for round cuts, then for smoothing if there is space left. + // Each element in this list is a pair, that represent how much we can do of the cut for + // the given side (side i goes from corner i to corner i+1), the elements of the pair are: + // first is how much we can use of expectedRoundCut, second how much of expectedCut + const cutAdjusts = Array.from({ length: n }).map((_, ix) => { + const expectedRoundCut = roundedCorners[ix].expectedRoundCut + roundedCorners[(ix + 1) % n].expectedRoundCut + const expectedCut = roundedCorners[ix].expectedCut + roundedCorners[(ix + 1) % n].expectedCut + const vtxX = vertices[ix * 2] + const vtxY = vertices[ix * 2 + 1] + const nextVtxX = vertices[((ix + 1) % n) * 2] + const nextVtxY = vertices[((ix + 1) % n) * 2 + 1] + const sideSize = Utils.distance(vtxX - nextVtxX, vtxY - nextVtxY) + + // Check expectedRoundCut first, and ensure we fulfill rounding needs first for + // both corners before using space for smoothing + if (expectedRoundCut > sideSize) { + // Not enough room for fully rounding, see how much we can actually do. + return { a: sideSize / expectedRoundCut, b: 0 } + } else if (expectedCut > sideSize) { + // We can do full rounding, but not full smoothing. + return { a: 1, b: (sideSize - expectedRoundCut) / (expectedCut - expectedRoundCut) } + } else { + // There is enough room for rounding & smoothing. + return { a: 1, b: 1 } + } + }) + + // Create and store list of beziers for each [potentially] rounded corner + for (let i = 0; i < n; i++) { + // allowedCuts[0] is for the side from the previous corner to this one, + // allowedCuts[1] is for the side from this corner to the next one. + const allowedCuts = [] + for(const delta of [0, 1]) { + const { a: roundCutRatio, b: cutRatio } = cutAdjusts[(i + n - 1 + delta) % n] + allowedCuts.push( + roundedCorners[i].expectedRoundCut * roundCutRatio + + (roundedCorners[i].expectedCut - roundedCorners[i].expectedRoundCut) * cutRatio + ) + } + corners.push( + roundedCorners[i].getCubics(allowedCuts[0], allowedCuts[1]) + ) + } + + const tempFeatures = [] + for (let i = 0; i < n; i++) { + // Note that these indices are for pairs of values (points), they need to be + // doubled to access the xy values in the vertices float array + const prevVtxIndex = (i + n - 1) % n + const nextVtxIndex = (i + 1) % n + const currVertex = new Point.Point(vertices[i * 2], vertices[i * 2 + 1]) + const prevVertex = new Point.Point(vertices[prevVtxIndex * 2], vertices[prevVtxIndex * 2 + 1]) + const nextVertex = new Point.Point(vertices[nextVtxIndex * 2], vertices[nextVtxIndex * 2 + 1]) + const cnvx = Utils.convex(prevVertex, currVertex, nextVertex) + tempFeatures.push(new Feature.Corner(corners[i], cnvx)) + tempFeatures.push( + new Feature.Edge([Cubic.Cubic.straightLine( + corners[i][corners[i].length - 1].anchor1X, + corners[i][corners[i].length - 1].anchor1Y, + corners[(i + 1) % n][0].anchor0X, + corners[(i + 1) % n][0].anchor0Y, + )]) + ) + } + + let center + if (centerX == Number.MIN_SAFE_INTEGER || centerY == Number.MIN_SAFE_INTEGER) { + center = RoundedPolygon.calculateCenter(vertices) + } else { + center = new Point.Point(centerX, centerY) + } + + return RoundedPolygon.fromFeatures(tempFeatures, center.x, center.y) + } + + static fromFeatures(features, centerX, centerY) { + const vertices = [] + for (const feature of features) { + for (const cubic of feature.cubics) { + vertices.push(cubic.anchor0X) + vertices.push(cubic.anchor0Y) + } + } + + if (Number.isNaN(centerX)) { + centerX = this.calculateCenter(vertices).x + } + if (Number.isNaN(centerY)) { + centerY = this.calculateCenter(vertices).y + } + + return new RoundedPolygon(features, new Point.Point(centerX, centerY)) + } + + static circle(numVertices = 8, radius = 1, centerX = 0, centerY = 0) { + // Half of the angle between two adjacent vertices on the polygon + const theta = Math.PI / numVertices + // Radius of the underlying RoundedPolygon object given the desired radius of the circle + const polygonRadius = radius / Math.cos(theta) + return RoundedPolygon.fromNumVertices( + numVertices, + polygonRadius, + centerX, + centerY, + new CornerRounding.CornerRounding(radius) + ) + } + + static rectangle(width, height, rounding = CornerRounding.Unrounded, perVertexRounding = null, centerX = 0, centerY = 0) { + const left = centerX - width / 2 + const top = centerY - height / 2 + const right = centerX + width / 2 + const bottom = centerY + height / 2 + + return RoundedPolygon.fromVertices([right, bottom, left, bottom, left, top, right, top], rounding, perVertexRounding, centerX, centerY) + } + + static star(numVerticesPerRadius, radius = 1, innerRadius = .5, rounding = CornerRounding.Unrounded, innerRounding = null, perVertexRounding = null, centerX = 0, centerY = 0) { + let pvRounding = perVertexRounding + // If no per-vertex rounding supplied and caller asked for inner rounding, + // create per-vertex rounding list based on supplied outer/inner rounding parameters + if (pvRounding == null && innerRounding != null) { + pvRounding = Array.from({ length: numVerticesPerRadius * 2 }).flatMap(() => [rounding, innerRounding]) + } + + return RoundedPolygon.fromVertices(RoundedPolygon.starVerticesFromNumVerts(numVerticesPerRadius, radius, innerRadius, centerX, centerY), rounding, perVertexRounding, centerX, centerY) + } + + static starVerticesFromNumVerts(numVerticesPerRadius, radius, innerRadius, centerX, centerY) { + const result = [] + let arrayIndex = 0 + for (let i = 0; i < numVerticesPerRadius; i++) { + let vertex = Utils.radialToCartesian(radius, (Math.PI / numVerticesPerRadius * 2 * i)) + result[arrayIndex++] = vertex.x + centerX + result[arrayIndex++] = vertex.y + centerY + vertex = Utils.radialToCartesian(innerRadius, (Math.PI / numVerticesPerRadius * (2 * i + 1))) + result[arrayIndex++] = vertex.x + centerX + result[arrayIndex++] = vertex.y + centerY + } + return result + } +} \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/shapes/utils.js b/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/shapes/utils.js new file mode 100644 index 0000000..17b56f6 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/shapes/utils.js @@ -0,0 +1,94 @@ +.pragma library +.import "point.js" as PointModule + +var Point = PointModule.Point; +var DistanceEpsilon = 1e-4; +var AngleEpsilon = 1e-6; + +/** + * @param {Point} previous + * @param {Point} current + * @param {Point} next + * @returns {boolean} + */ +function convex(previous, current, next) { + return (current.minus(previous)).clockwise(next.minus(current)); +} + +/** + * @param {float} start + * @param {float} stop + * @param {float} fraction + * @returns {float} + */ +function interpolate(start, stop, fraction) { + return (1 - fraction) * start + fraction * stop; +} + +/** + * @param {float} x + * @param {float} y + * @returns {Point} + */ +function directionVector(x, y) { + const d = distance(x, y); + return new Point(x / d, y / d); +} + +/** + * @param {float} x + * @param {float} y + * @returns {float} + */ +function distance(x, y) { + return Math.sqrt(x * x + y * y); +} + +/** + * @param {float} x + * @param {float} y + * @returns {float} + */ +function distanceSquared(x, y) { + return x * x + y * y; +} + +/** + * @param {float} radius + * @param {float} angleRadians + * @param {Point} [center] + * @returns {Point} + */ +function radialToCartesian(radius, angleRadians, center = new Point(0, 0)) { + return new Point(Math.cos(angleRadians), Math.sin(angleRadians)) + .times(radius) + .plus(center); +} + +/** + * @param {float} value + * @param {float|object} min + * @param {float} [max] + * @returns {float} + */ +function coerceIn(value, min, max) { + if (max === undefined) { + if (typeof min === 'object' && 'start' in min && 'endInclusive' in min) { + return Math.max(min.start, Math.min(min.endInclusive, value)); + } + throw new Error("Invalid arguments for coerceIn"); + } + + const [actualMin, actualMax] = min <= max ? [min, max] : [max, min]; + return Math.max(actualMin, Math.min(actualMax, value)); +} + +/** + * @param {float} value + * @param {float} mod + * @returns {float} + */ +function positiveModulo(value, mod) { + return ((value % mod) + mod) % mod; +} + diff --git a/.config/quickshell/nucleus-shell/modules/functions/ColorUtils.qml b/.config/quickshell/nucleus-shell/modules/functions/ColorUtils.qml new file mode 100644 index 0000000..b036627 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/functions/ColorUtils.qml @@ -0,0 +1,70 @@ +pragma Singleton +import Quickshell + +// From github.com/end-4/dots-hyprland with modifications + +Singleton { + id: root + + function colorWithHueOf(color1, color2) { + var c1 = Qt.color(color1); + var c2 = Qt.color(color2); + var hue = c2.hsvHue; + var sat = c1.hsvSaturation; + var val = c1.hsvValue; + var alpha = c1.a; + return Qt.hsva(hue, sat, val, alpha); + } + + function colorWithSaturationOf(color1, color2) { + var c1 = Qt.color(color1); + var c2 = Qt.color(color2); + var hue = c1.hsvHue; + var sat = c2.hsvSaturation; + var val = c1.hsvValue; + var alpha = c1.a; + return Qt.hsva(hue, sat, val, alpha); + } + + function colorWithLightness(color, lightness) { + var c = Qt.color(color); + return Qt.hsla(c.hslHue, c.hslSaturation, lightness, c.a); + } + + function colorWithLightnessOf(color1, color2) { + var c2 = Qt.color(color2); + return colorWithLightness(color1, c2.hslLightness); + } + + function adaptToAccent(color1, color2) { + var c1 = Qt.color(color1); + var c2 = Qt.color(color2); + var hue = c2.hslHue; + var sat = c2.hslSaturation; + var light = c1.hslLightness; + var alpha = c1.a; + return Qt.hsla(hue, sat, light, alpha); + } + + function mix(color1, color2, percentage = 0.5) { + var c1 = Qt.color(color1); + var c2 = Qt.color(color2); + return Qt.rgba( + percentage * c1.r + (1 - percentage) * c2.r, + percentage * c1.g + (1 - percentage) * c2.g, + percentage * c1.b + (1 - percentage) * c2.b, + percentage * c1.a + (1 - percentage) * c2.a + ); + } + + function transparentize(color, percentage = 1) { + var c = Qt.color(color); + return Qt.rgba(c.r, c.g, c.b, c.a * (1 - percentage)); + } + + function applyAlpha(color, alpha) { + var c = Qt.color(color); + var a = Math.max(0, Math.min(1, alpha)); + return Qt.rgba(c.r, c.g, c.b, a); + } +} \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/modules/functions/DisplayMetrics.qml b/.config/quickshell/nucleus-shell/modules/functions/DisplayMetrics.qml new file mode 100644 index 0000000..5730ae0 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/functions/DisplayMetrics.qml @@ -0,0 +1,15 @@ +pragma Singleton +import Quickshell +import QtQuick +import qs.services + +Singleton { + // Prefer Compositor scales because niri and hyprland have diffrent scaling factors + function scaledWidth(ratio) { + return Compositor.screenW * ratio / Compositor.screenScale + } + + function scaledHeight(ratio) { + return Compositor.screenH * ratio / Compositor.screenScale + } +} diff --git a/.config/quickshell/nucleus-shell/modules/functions/FileUtils.qml b/.config/quickshell/nucleus-shell/modules/functions/FileUtils.qml new file mode 100644 index 0000000..df487ac --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/functions/FileUtils.qml @@ -0,0 +1,106 @@ +import Quickshell +import Quickshell.Io +pragma Singleton + +Singleton { + id: root + + function resolveIcon(className) { + if (!className || className.length === 0) + return ""; + + const original = className; + const normalized = className.toLowerCase(); + // 1. Exact icon name + if (Quickshell.iconPath(original, true).length > 0) + return original; + + // 2. Normalized guess + if (Quickshell.iconPath(normalized, true).length > 0) + return normalized; + + // 3. Dashed guess + const dashed = normalized.replace(/\s+/g, "-"); + if (Quickshell.iconPath(dashed, true).length > 0) + return dashed; + + // 4. Extension guess + const ext = original.split(".").pop().toLowerCase(); + if (Quickshell.iconPath(ext, true).length > 0) + return ext; + + return ""; + } + + function trimFileProtocol(str) { + let s = str; + if (typeof s !== "string") + s = str.toString(); + + // Convert to string if it's an url or whatever + return s.startsWith("file://") ? s.slice(7) : s; + } + + function isVideo(path) { + if (!path) + return false; + + // Convert QUrl → string if needed + let p = path.toString ? path.toString() : path; + // Strip file:// + if (p.startsWith("file://")) + p = p.replace("file://", ""); + + const ext = p.split(".").pop().toLowerCase(); + return ["mp4", "mkv", "webm", "mov", "avi", "m4v"].includes(ext); + } + + function createFile(filePath, callback) { + if (!filePath) + return ; + + let p = Qt.createQmlObject('import QtQuick; import Quickshell.Io; Process {}', root); + p.command = ["touch", filePath]; + p.onExited.connect(function() { + console.debug("Created file:", filePath, "exit code:", p.exitCode); + p.destroy(); + if (callback) + callback(true); + + }); + p.running = true; + } + + function removeFile(filePath, callback) { + if (!filePath) + return ; + + let p = Qt.createQmlObject('import QtQuick; import Quickshell.Io; Process {}', root); + p.command = ["rm", "-f", filePath]; + p.onExited.connect(function() { + console.debug("Removed file:", filePath, "exit code:", p.exitCode); + p.destroy(); + if (callback) + callback(true); + + }); + p.running = true; + } + + function renameFile(oldPath, newPath, callback) { + if (!oldPath || !newPath || oldPath === newPath) + return ; + + let p = Qt.createQmlObject('import QtQuick; import Quickshell.Io; Process {}', root); + p.command = ["mv", oldPath, newPath]; + p.onExited.connect(function() { + console.debug("Renamed file:", oldPath, "→", newPath, "exit code:", p.exitCode); + p.destroy(); + if (callback) + callback(true); + + }); + p.running = true; + } + +} diff --git a/.config/quickshell/nucleus-shell/modules/functions/StringUtils.qml b/.config/quickshell/nucleus-shell/modules/functions/StringUtils.qml new file mode 100644 index 0000000..6ea1fb9 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/functions/StringUtils.qml @@ -0,0 +1,42 @@ +pragma Singleton +import Quickshell + +Singleton { + id: root + + function shortText(str, len = 25) { + if (!str) + return "" + return str.length > len ? str.slice(0, len) + "..." : str + } + + function verticalize(text) { + return text.split("").join("\n") + } + + function markdownToHtml(md) { + if (!md) return ""; + + let html = md + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\*\*(.*?)\*\*/g, "$1") // bold + .replace(/\*(.*?)\*/g, "$1") // italic + .replace(/`([^`]+)`/g, "$1") // inline code + .replace(/^### (.*)$/gm, "

$1

") // headers + .replace(/```([\s\S]+?)```/g, '
$1
') // code blocks + .replace(/^## (.*)$/gm, "

$1

") + .replace(/^# (.*)$/gm, "

$1

") + .replace(/^- (.*)$/gm, "
  • $1
  • "); // simple lists + + // Wrap list items in
      without `s` flag + html = html.replace(/(
    • [\s\S]*?<\/li>)/g, "
        $1
      "); + + // Replace newlines with
      for normal text + html = html.replace(/\n/g, "
      "); + + return html; + } + +} \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/modules/functions/fuzzy/fuzzysort.js b/.config/quickshell/nucleus-shell/modules/functions/fuzzy/fuzzysort.js new file mode 100644 index 0000000..f308fc6 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/functions/fuzzy/fuzzysort.js @@ -0,0 +1,679 @@ +.pragma library + +var single = (search, target) => { + if(!search || !target) return NULL + + var preparedSearch = getPreparedSearch(search) + if(!isPrepared(target)) target = getPrepared(target) + + var searchBitflags = preparedSearch.bitflags + if((searchBitflags & target._bitflags) !== searchBitflags) return NULL + + return algorithm(preparedSearch, target) +} + +var go = (search, targets, options) => { + if(!search) return options?.all ? all(targets, options) : noResults + + var preparedSearch = getPreparedSearch(search) + var searchBitflags = preparedSearch.bitflags + var containsSpace = preparedSearch.containsSpace + + var threshold = denormalizeScore( options?.threshold || 0 ) + var limit = options?.limit || INFINITY + + var resultsLen = 0; var limitedCount = 0 + var targetsLen = targets.length + + function push_result(result) { + if(resultsLen < limit) { q.add(result); ++resultsLen } + else { + ++limitedCount + if(result._score > q.peek()._score) q.replaceTop(result) + } + } + + // This code is copy/pasted 3 times for performance reasons [options.key, options.keys, no keys] + + // options.key + if(options?.key) { + var key = options.key + for(var i = 0; i < targetsLen; ++i) { var obj = targets[i] + var target = getValue(obj, key) + if(!target) continue + if(!isPrepared(target)) target = getPrepared(target) + + if((searchBitflags & target._bitflags) !== searchBitflags) continue + var result = algorithm(preparedSearch, target) + if(result === NULL) continue + if(result._score < threshold) continue + + result.obj = obj + push_result(result) + } + + // options.keys + } else if(options?.keys) { + var keys = options.keys + var keysLen = keys.length + + outer: for(var i = 0; i < targetsLen; ++i) { var obj = targets[i] + + { // early out based on bitflags + var keysBitflags = 0 + for (var keyI = 0; keyI < keysLen; ++keyI) { + var key = keys[keyI] + var target = getValue(obj, key) + if(!target) { tmpTargets[keyI] = noTarget; continue } + if(!isPrepared(target)) target = getPrepared(target) + tmpTargets[keyI] = target + + keysBitflags |= target._bitflags + } + + if((searchBitflags & keysBitflags) !== searchBitflags) continue + } + + if(containsSpace) for(let i=0; i -1000) { + if(keysSpacesBestScores[i] > NEGATIVE_INFINITY) { + var tmp = (keysSpacesBestScores[i] + allowPartialMatchScores[i]) / 4/*bonus score for having multiple matches*/ + if(tmp > keysSpacesBestScores[i]) keysSpacesBestScores[i] = tmp + } + } + if(allowPartialMatchScores[i] > keysSpacesBestScores[i]) keysSpacesBestScores[i] = allowPartialMatchScores[i] + } + } + + if(containsSpace) { + for(let i=0; i -1000) { + if(score > NEGATIVE_INFINITY) { + var tmp = (score + result._score) / 4/*bonus score for having multiple matches*/ + if(tmp > score) score = tmp + } + } + if(result._score > score) score = result._score + } + } + + objResults.obj = obj + objResults._score = score + if(options?.scoreFn) { + score = options.scoreFn(objResults) + if(!score) continue + score = denormalizeScore(score) + objResults._score = score + } + + if(score < threshold) continue + push_result(objResults) + } + + // no keys + } else { + for(var i = 0; i < targetsLen; ++i) { var target = targets[i] + if(!target) continue + if(!isPrepared(target)) target = getPrepared(target) + + if((searchBitflags & target._bitflags) !== searchBitflags) continue + var result = algorithm(preparedSearch, target) + if(result === NULL) continue + if(result._score < threshold) continue + + push_result(result) + } + } + + if(resultsLen === 0) return noResults + var results = new Array(resultsLen) + for(var i = resultsLen - 1; i >= 0; --i) results[i] = q.poll() + results.total = resultsLen + limitedCount + return results +} + + +// this is written as 1 function instead of 2 for minification. perf seems fine ... +// except when minified. the perf is very slow +var highlight = (result, open='', close='') => { + var callback = typeof open === 'function' ? open : undefined + + var target = result.target + var targetLen = target.length + var indexes = result.indexes + var highlighted = '' + var matchI = 0 + var indexesI = 0 + var opened = false + var parts = [] + + for(var i = 0; i < targetLen; ++i) { var char = target[i] + if(indexes[indexesI] === i) { + ++indexesI + if(!opened) { opened = true + if(callback) { + parts.push(highlighted); highlighted = '' + } else { + highlighted += open + } + } + + if(indexesI === indexes.length) { + if(callback) { + highlighted += char + parts.push(callback(highlighted, matchI++)); highlighted = '' + parts.push(target.substr(i+1)) + } else { + highlighted += char + close + target.substr(i+1) + } + break + } + } else { + if(opened) { opened = false + if(callback) { + parts.push(callback(highlighted, matchI++)); highlighted = '' + } else { + highlighted += close + } + } + } + highlighted += char + } + + return callback ? parts : highlighted +} + + +var prepare = (target) => { + if(typeof target === 'number') target = ''+target + else if(typeof target !== 'string') target = '' + var info = prepareLowerInfo(target) + return new_result(target, {_targetLower:info._lower, _targetLowerCodes:info.lowerCodes, _bitflags:info.bitflags}) +} + +var cleanup = () => { preparedCache.clear(); preparedSearchCache.clear() } + + +// Below this point is only internal code +// Below this point is only internal code +// Below this point is only internal code +// Below this point is only internal code + + +class Result { + get ['indexes']() { return this._indexes.slice(0, this._indexes.len).sort((a,b)=>a-b) } + set ['indexes'](indexes) { return this._indexes = indexes } + ['highlight'](open, close) { return highlight(this, open, close) } + get ['score']() { return normalizeScore(this._score) } + set ['score'](score) { this._score = denormalizeScore(score) } +} + +class KeysResult extends Array { + get ['score']() { return normalizeScore(this._score) } + set ['score'](score) { this._score = denormalizeScore(score) } +} + +var new_result = (target, options) => { + const result = new Result() + result['target'] = target + result['obj'] = options.obj ?? NULL + result._score = options._score ?? NEGATIVE_INFINITY + result._indexes = options._indexes ?? [] + result._targetLower = options._targetLower ?? '' + result._targetLowerCodes = options._targetLowerCodes ?? NULL + result._nextBeginningIndexes = options._nextBeginningIndexes ?? NULL + result._bitflags = options._bitflags ?? 0 + return result +} + + +var normalizeScore = score => { + if(score === NEGATIVE_INFINITY) return 0 + if(score > 1) return score + return Math.E ** ( ((-score + 1)**.04307 - 1) * -2) +} +var denormalizeScore = normalizedScore => { + if(normalizedScore === 0) return NEGATIVE_INFINITY + if(normalizedScore > 1) return normalizedScore + return 1 - Math.pow((Math.log(normalizedScore) / -2 + 1), 1 / 0.04307) +} + + +var prepareSearch = (search) => { + if(typeof search === 'number') search = ''+search + else if(typeof search !== 'string') search = '' + search = search.trim() + var info = prepareLowerInfo(search) + + var spaceSearches = [] + if(info.containsSpace) { + var searches = search.split(/\s+/) + searches = [...new Set(searches)] // distinct + for(var i=0; i { + if(target.length > 999) return prepare(target) // don't cache huge targets + var targetPrepared = preparedCache.get(target) + if(targetPrepared !== undefined) return targetPrepared + targetPrepared = prepare(target) + preparedCache.set(target, targetPrepared) + return targetPrepared +} +var getPreparedSearch = (search) => { + if(search.length > 999) return prepareSearch(search) // don't cache huge searches + var searchPrepared = preparedSearchCache.get(search) + if(searchPrepared !== undefined) return searchPrepared + searchPrepared = prepareSearch(search) + preparedSearchCache.set(search, searchPrepared) + return searchPrepared +} + + +var all = (targets, options) => { + var results = []; results.total = targets.length // this total can be wrong if some targets are skipped + + var limit = options?.limit || INFINITY + + if(options?.key) { + for(var i=0;i= limit) return results + } + } else if(options?.keys) { + for(var i=0;i= 0; --keyI) { + var target = getValue(obj, options.keys[keyI]) + if(!target) { objResults[keyI] = noTarget; continue } + if(!isPrepared(target)) target = getPrepared(target) + target._score = NEGATIVE_INFINITY + target._indexes.len = 0 + objResults[keyI] = target + } + objResults.obj = obj + objResults._score = NEGATIVE_INFINITY + results.push(objResults); if(results.length >= limit) return results + } + } else { + for(var i=0;i= limit) return results + } + } + + return results +} + + +var algorithm = (preparedSearch, prepared, allowSpaces=false, allowPartialMatch=false) => { + if(allowSpaces===false && preparedSearch.containsSpace) return algorithmSpaces(preparedSearch, prepared, allowPartialMatch) + + var searchLower = preparedSearch._lower + var searchLowerCodes = preparedSearch.lowerCodes + var searchLowerCode = searchLowerCodes[0] + var targetLowerCodes = prepared._targetLowerCodes + var searchLen = searchLowerCodes.length + var targetLen = targetLowerCodes.length + var searchI = 0 // where we at + var targetI = 0 // where you at + var matchesSimpleLen = 0 + + // very basic fuzzy match; to remove non-matching targets ASAP! + // walk through target. find sequential matches. + // if all chars aren't found then exit + for(;;) { + var isMatch = searchLowerCode === targetLowerCodes[targetI] + if(isMatch) { + matchesSimple[matchesSimpleLen++] = targetI + ++searchI; if(searchI === searchLen) break + searchLowerCode = searchLowerCodes[searchI] + } + ++targetI; if(targetI >= targetLen) return NULL // Failed to find searchI + } + + var searchI = 0 + var successStrict = false + var matchesStrictLen = 0 + + var nextBeginningIndexes = prepared._nextBeginningIndexes + if(nextBeginningIndexes === NULL) nextBeginningIndexes = prepared._nextBeginningIndexes = prepareNextBeginningIndexes(prepared.target) + targetI = matchesSimple[0]===0 ? 0 : nextBeginningIndexes[matchesSimple[0]-1] + + // Our target string successfully matched all characters in sequence! + // Let's try a more advanced and strict test to improve the score + // only count it as a match if it's consecutive or a beginning character! + var backtrackCount = 0 + if(targetI !== targetLen) for(;;) { + if(targetI >= targetLen) { + // We failed to find a good spot for this search char, go back to the previous search char and force it forward + if(searchI <= 0) break // We failed to push chars forward for a better match + + ++backtrackCount; if(backtrackCount > 200) break // exponential backtracking is taking too long, just give up and return a bad match + + --searchI + var lastMatch = matchesStrict[--matchesStrictLen] + targetI = nextBeginningIndexes[lastMatch] + + } else { + var isMatch = searchLowerCodes[searchI] === targetLowerCodes[targetI] + if(isMatch) { + matchesStrict[matchesStrictLen++] = targetI + ++searchI; if(searchI === searchLen) { successStrict = true; break } + ++targetI + } else { + targetI = nextBeginningIndexes[targetI] + } + } + } + + // check if it's a substring match + var substringIndex = searchLen <= 1 ? -1 : prepared._targetLower.indexOf(searchLower, matchesSimple[0]) // perf: this is slow + var isSubstring = !!~substringIndex + var isSubstringBeginning = !isSubstring ? false : substringIndex===0 || prepared._nextBeginningIndexes[substringIndex-1] === substringIndex + + // if it's a substring match but not at a beginning index, let's try to find a substring starting at a beginning index for a better score + if(isSubstring && !isSubstringBeginning) { + for(var i=0; i { + var score = 0 + + var extraMatchGroupCount = 0 + for(var i = 1; i < searchLen; ++i) { + if(matches[i] - matches[i-1] !== 1) {score -= matches[i]; ++extraMatchGroupCount} + } + var unmatchedDistance = matches[searchLen-1] - matches[0] - (searchLen-1) + + score -= (12+unmatchedDistance) * extraMatchGroupCount // penality for more groups + + if(matches[0] !== 0) score -= matches[0]*matches[0]*.2 // penality for not starting near the beginning + + if(!successStrict) { + score *= 1000 + } else { + // successStrict on a target with too many beginning indexes loses points for being a bad target + var uniqueBeginningIndexes = 1 + for(var i = nextBeginningIndexes[0]; i < targetLen; i=nextBeginningIndexes[i]) ++uniqueBeginningIndexes + + if(uniqueBeginningIndexes > 24) score *= (uniqueBeginningIndexes-24)*10 // quite arbitrary numbers here ... + } + + score -= (targetLen - searchLen)/2 // penality for longer targets + + if(isSubstring) score /= 1+searchLen*searchLen*1 // bonus for being a full substring + if(isSubstringBeginning) score /= 1+searchLen*searchLen*1 // bonus for substring starting on a beginningIndex + + score -= (targetLen - searchLen)/2 // penality for longer targets + + return score + } + + if(!successStrict) { + if(isSubstring) for(var i=0; i { + var seen_indexes = new Set() + var score = 0 + var result = NULL + + var first_seen_index_last_search = 0 + var searches = preparedSearch.spaceSearches + var searchesLen = searches.length + var changeslen = 0 + + // Return _nextBeginningIndexes back to its normal state + var resetNextBeginningIndexes = () => { + for(let i=changeslen-1; i>=0; i--) target._nextBeginningIndexes[nextBeginningIndexesChanges[i*2 + 0]] = nextBeginningIndexesChanges[i*2 + 1] + } + + var hasAtLeast1Match = false + for(var i=0; i=0; i--) { + if(toReplace !== target._nextBeginningIndexes[i]) break + target._nextBeginningIndexes[i] = newBeginningIndex + nextBeginningIndexesChanges[changeslen*2 + 0] = i + nextBeginningIndexesChanges[changeslen*2 + 1] = toReplace + changeslen++ + } + } + } + + score += result._score / searchesLen + allowPartialMatchScores[i] = result._score / searchesLen + + // dock points based on order otherwise "c man" returns Manifest.cpp instead of CheatManager.h + if(result._indexes[0] < first_seen_index_last_search) { + score -= (first_seen_index_last_search - result._indexes[0]) * 2 + } + first_seen_index_last_search = result._indexes[0] + + for(var j=0; j score) { + if(allowPartialMatch) { + for(var i=0; i str.replace(/\p{Script=Latin}+/gu, match => match.normalize('NFD')).replace(/[\u0300-\u036f]/g, '') + +var prepareLowerInfo = (str) => { + str = remove_accents(str) + var strLen = str.length + var lower = str.toLowerCase() + var lowerCodes = [] // new Array(strLen) sparse array is too slow + var bitflags = 0 + var containsSpace = false // space isn't stored in bitflags because of how searching with a space works + + for(var i = 0; i < strLen; ++i) { + var lowerCode = lowerCodes[i] = lower.charCodeAt(i) + + if(lowerCode === 32) { + containsSpace = true + continue // it's important that we don't set any bitflags for space + } + + var bit = lowerCode>=97&&lowerCode<=122 ? lowerCode-97 // alphabet + : lowerCode>=48&&lowerCode<=57 ? 26 // numbers + // 3 bits available + : lowerCode<=127 ? 30 // other ascii + : 31 // other utf8 + bitflags |= 1< { + var targetLen = target.length + var beginningIndexes = []; var beginningIndexesLen = 0 + var wasUpper = false + var wasAlphanum = false + for(var i = 0; i < targetLen; ++i) { + var targetCode = target.charCodeAt(i) + var isUpper = targetCode>=65&&targetCode<=90 + var isAlphanum = isUpper || targetCode>=97&&targetCode<=122 || targetCode>=48&&targetCode<=57 + var isBeginning = isUpper && !wasUpper || !wasAlphanum || !isAlphanum + wasUpper = isUpper + wasAlphanum = isAlphanum + if(isBeginning) beginningIndexes[beginningIndexesLen++] = i + } + return beginningIndexes +} +var prepareNextBeginningIndexes = (target) => { + target = remove_accents(target) + var targetLen = target.length + var beginningIndexes = prepareBeginningIndexes(target) + var nextBeginningIndexes = [] // new Array(targetLen) sparse array is too slow + var lastIsBeginning = beginningIndexes[0] + var lastIsBeginningI = 0 + for(var i = 0; i < targetLen; ++i) { + if(lastIsBeginning > i) { + nextBeginningIndexes[i] = lastIsBeginning + } else { + lastIsBeginning = beginningIndexes[++lastIsBeginningI] + nextBeginningIndexes[i] = lastIsBeginning===undefined ? targetLen : lastIsBeginning + } + } + return nextBeginningIndexes +} + +var preparedCache = new Map() +var preparedSearchCache = new Map() + +// the theory behind these being globals is to reduce garbage collection by not making new arrays +var matchesSimple = []; var matchesStrict = [] +var nextBeginningIndexesChanges = [] // allows straw berry to match strawberry well, by modifying the end of a substring to be considered a beginning index for the rest of the search +var keysSpacesBestScores = []; var allowPartialMatchScores = [] +var tmpTargets = []; var tmpResults = [] + +// prop = 'key' 2.5ms optimized for this case, seems to be about as fast as direct obj[prop] +// prop = 'key1.key2' 10ms +// prop = ['key1', 'key2'] 27ms +// prop = obj => obj.tags.join() ??ms +var getValue = (obj, prop) => { + var tmp = obj[prop]; if(tmp !== undefined) return tmp + if(typeof prop === 'function') return prop(obj) // this should run first. but that makes string props slower + var segs = prop + if(!Array.isArray(prop)) segs = prop.split('.') + var len = segs.length + var i = -1 + while (obj && (++i < len)) obj = obj[segs[i]] + return obj +} + +var isPrepared = (x) => { return typeof x === 'object' && typeof x._bitflags === 'number' } +var INFINITY = Infinity; var NEGATIVE_INFINITY = -INFINITY +var noResults = []; noResults.total = 0 +var NULL = null + +var noTarget = prepare('') + +// Hacked version of https://github.com/lemire/FastPriorityQueue.js +var fastpriorityqueue=r=>{var e=[],o=0,a={},v=r=>{for(var a=0,v=e[a],c=1;c>1]=e[a],c=1+(a<<1)}for(var f=a-1>>1;a>0&&v._score>1)e[a]=e[f];e[a]=v};return a.add=(r=>{var a=o;e[o++]=r;for(var v=a-1>>1;a>0&&r._score>1)e[a]=e[v];e[a]=r}),a.poll=(r=>{if(0!==o){var a=e[0];return e[0]=e[--o],v(),a}}),a.peek=(r=>{if(0!==o)return e[0]}),a.replaceTop=(r=>{e[0]=r,v()}),a} +var q = fastpriorityqueue() // reuse this + diff --git a/.config/quickshell/nucleus-shell/modules/interface/background/Background.qml b/.config/quickshell/nucleus-shell/modules/interface/background/Background.qml new file mode 100644 index 0000000..8471556 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/background/Background.qml @@ -0,0 +1,267 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import Quickshell.Hyprland +import qs.config +import qs.modules.functions +import qs.modules.components +import qs.services + +Scope { + id: root + + Variants { + model: Quickshell.screens + + PanelWindow { + id: backgroundContainer + + required property var modelData + property string displayName: modelData.name + + property url wallpaperPath: { + const displays = Config.runtime.monitors + const fallback = Config.runtime.appearance.background.defaultPath + + if (!displays) + return fallback + + const monitor = displays?.[displayName] + return monitor?.wallpaper ?? fallback + } + + // parallax config + property bool parallaxEnabled: Config.runtime.appearance.background.parallax.enabled + property real parallaxZoom: Config.runtime.appearance.background.parallax.zoom + property int workspaceRange: Config.runtime.bar.modules.workspaces.workspaceIndicators + + // hyprland + property int activeWorkspaceId: Hyprland.focusedWorkspace?.id ?? 1 + + // wallpaper geometry + property real wallpaperWidth: bgImg.implicitWidth + property real wallpaperHeight: bgImg.implicitHeight + + property real wallpaperToScreenRatio: { + if (wallpaperWidth <= 0 || wallpaperHeight <= 0) + return 1 + return Math.min( + wallpaperWidth / width, + wallpaperHeight / height + ) + } + + property real effectiveScale: parallaxEnabled ? parallaxZoom : 1 + + property real movableXSpace: Math.max( + 0, + ((wallpaperWidth / wallpaperToScreenRatio * effectiveScale) - width) / 2 + ) + + // workspace mapping + property int lowerWorkspace: Math.floor((activeWorkspaceId - 1) / workspaceRange) * workspaceRange + 1 + property int upperWorkspace: lowerWorkspace + workspaceRange + property int workspaceSpan: Math.max(1, upperWorkspace - lowerWorkspace) + + property real valueX: { + if (!parallaxEnabled) + return 0.5 + return (activeWorkspaceId - lowerWorkspace) / workspaceSpan + } + + // sidebar globals + property bool sidebarLeftOpen: Globals.visiblility.sidebarLeft + && Config.runtime.appearance.background.parallax.enableSidebarLeft + + property bool sidebarRightOpen: Globals.visiblility.sidebarRight + && Config.runtime.appearance.background.parallax.enableSidebarRight + + property real sidebarOffset: { + if (sidebarLeftOpen && !sidebarRightOpen) + if (Config.runtime.bar.position === "right") + return 0.15 + else return -0.15 + if (sidebarRightOpen && !sidebarLeftOpen) + if (Config.runtime.bar.position === "left") + return -0.15 + else return 0.15 + return 0 + } + + property real effectiveValueX: Math.max( + 0, + Math.min( + 1, + valueX + sidebarOffset + ) + ) + + // window + color: (bgImg.status === Image.Error) ? Appearance.colors.colLayer2 : "transparent" + WlrLayershell.namespace: "nucleus:background" + exclusionMode: ExclusionMode.Ignore + WlrLayershell.layer: WlrLayer.Background + screen: modelData + visible: Config.initialized && Config.runtime.appearance.background.enabled + + anchors { + top: true + left: true + right: true + bottom: true + } + + // wallpaper picker + Process { + id: wallpaperProc + + command: ["bash", "-c", Directories.scriptsPath + "/interface/changebg.sh"] + + stdout: StdioCollector { + onStreamFinished: { + const out = text.trim() + + if (out !== "null" && out.length > 0) { + const parts = out.split("|") + + if (parts.length === 2) { + const monitor = parts[0] + const wallpaper = parts[1] + + Config.updateKey( + "monitors." + monitor + ".wallpaper", + wallpaper + ) + } + } + + Quickshell.execDetached([ + "nucleus", "ipc", "call", "clock", "changePosition" + ]) + if (Config.runtime.appearance.colors.autogenerated) { + Quickshell.execDetached([ + "nucleus", "ipc", "call", "global", "regenColors" + ]); + } + } + } + } + + // wallpaper + Item { + anchors.fill: parent + clip: true + + StyledImage { + id: bgImg + + visible: status === Image.Ready + smooth: false + cache: false + fillMode: Image.PreserveAspectCrop + source: wallpaperPath + "?t=" + Date.now() + + width: wallpaperWidth / wallpaperToScreenRatio * effectiveScale + height: wallpaperHeight / wallpaperToScreenRatio * effectiveScale + + x: -movableXSpace - (effectiveValueX - 0.5) * 2 * movableXSpace + y: 0 + + Behavior on x { + NumberAnimation { + duration: Metrics.chronoDuration(600) + easing.type: Easing.OutCubic + } + } + + onStatusChanged: { + if (status === Image.Ready) { + backgroundContainer.wallpaperWidth = implicitWidth + backgroundContainer.wallpaperHeight = implicitHeight + } + } + } + + MouseArea { + id: widgetCanvas + anchors.fill: parent + } + + // error ui + Item { + anchors.centerIn: parent + visible: bgImg.status === Image.Error + + Rectangle { + width: 550 + height: 400 + radius: Appearance.rounding.windowRounding + color: "transparent" + anchors.centerIn: parent + + ColumnLayout { + anchors.centerIn: parent + anchors.margins: Metrics.margin("normal") + spacing: Metrics.margin("small") + + MaterialSymbol { + text: "wallpaper" + font.pixelSize: Metrics.fontSize("wildass") + color: Appearance.colors.colOnLayer2 + Layout.alignment: Qt.AlignHCenter + } + + StyledText { + text: "Wallpaper Missing" + font.pixelSize: Metrics.fontSize("hugeass") + font.bold: true + color: Appearance.colors.colOnLayer2 + horizontalAlignment: Text.AlignHCenter + Layout.alignment: Qt.AlignHCenter + } + + StyledText { + text: "Seems like you haven't set a wallpaper yet." + font.pixelSize: Metrics.fontSize("small") + color: Appearance.colors.colSubtext + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + Layout.alignment: Qt.AlignHCenter + } + + Item { Layout.fillHeight: true } + + StyledButton { + text: "Set wallpaper" + icon: "wallpaper" + secondary: true + radius: Metrics.radius("large") + Layout.alignment: Qt.AlignHCenter + onClicked: wallpaperProc.running = true + } + } + } + } + } + + IpcHandler { + target: "background" + + function change() { + wallpaperProc.running = true + } + + function next() { + WallpaperSlideshow.nextWallpaper() + } + } + } + } + + Clock { + id: clock + } + +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/background/Clock.qml b/.config/quickshell/nucleus-shell/modules/interface/background/Clock.qml new file mode 100644 index 0000000..7c9f80f --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/background/Clock.qml @@ -0,0 +1,287 @@ +import "../../components/morphedPolygons/geometry/offset.js" as Offset +import "../../components/morphedPolygons/material-shapes.js" as MaterialShapes // For polygons +import "../../components/morphedPolygons/shapes/corner-rounding.js" as CornerRounding +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import qs.config +import qs.modules.components +import qs.modules.components.morphedPolygons +import qs.services + +Scope { + id: root + + property bool imageFailed: false + + Variants { + model: Quickshell.screens + + PanelWindow { + id: clock + + required property var modelData + property int padding: Config.runtime.appearance.background.clock.edgeSpacing + property int clockHeight: Config.runtime.appearance.background.clock.isAnalog ? 250 : 160 + property int clockWidth: Config.runtime.appearance.background.clock.isAnalog ? 250 : 360 + + function setRandomPosition() { + const x = Math.floor(Math.random() * (width - clockWidth)); + const y = Math.floor(Math.random() * (height - clockHeight)); + animX.to = x; + animY.to = y; + moveAnim.start(); + Config.updateKey("appearance.background.clock.xPos", x); + Config.updateKey("appearance.background.clock.yPos", y); + } + + color: "transparent" + visible: (Config.runtime.appearance.background.clock.enabled && Config.initialized && !imageFailed) + exclusiveZone: 0 + WlrLayershell.layer: WlrLayer.Bottom + screen: modelData + + ParallelAnimation { + id: moveAnim + + NumberAnimation { + id: animX + + target: rootContentContainer + property: "x" + duration: Metrics.chronoDuration(400) + easing.type: Easing.InOutCubic + } + + NumberAnimation { + id: animY + + target: rootContentContainer + property: "y" + duration: Metrics.chronoDuration(400) + easing.type: Easing.InOutCubic + } + + } + + anchors { + top: true + bottom: true + left: true + right: true + } + + margins { + top: padding + bottom: padding + left: padding + right: padding + } + + Item { + id: rootContentContainer + + property real releasedX: 0 + property real releasedY: 0 + + height: clockHeight + width: clockWidth + Component.onCompleted: { + Qt.callLater(() => { + x = Config.runtime.appearance.background.clock.xPos; + y = Config.runtime.appearance.background.clock.yPos; + }); + } + + MouseArea { + id: ma + + anchors.fill: parent + drag.target: rootContentContainer + drag.axis: Drag.XAndYAxis + acceptedButtons: Qt.RightButton + onReleased: { + if (ma.button === Qt.RightButton) + return + Config.updateKey("appearance.background.clock.xPos", rootContentContainer.x); + Config.updateKey("appearance.background.clock.yPos", rootContentContainer.y); + } + } + + Item { + id: digitalClockContainer + + visible: !Config.runtime.appearance.background.clock.isAnalog + + Column { + spacing: Metrics.spacing(-40) + + StyledText { + animate: false + text: Time.format("hh:mm") + font.pixelSize: Metrics.fontSize(Appearance.font.size.wildass * 3) + font.family: Metrics.fontFamily("main") + font.bold: true + } + + StyledText { + anchors.left: parent.left + anchors.leftMargin: Metrics.margin(8) + animate: false + text: Time.format("dddd, dd/MM") + font.pixelSize: Metrics.fontSize(32) + font.family: Metrics.fontFamily("main") + font.bold: true + } + + } + + } + + Item { + id: analogClockContainer + + property int hours: parseInt(Time.format("hh")) + property int minutes: parseInt(Time.format("mm")) + property int seconds: parseInt(Time.format("ss")) + readonly property real cx: width / 2 + readonly property real cy: height / 2 + property var shapes: [MaterialShapes.getCookie7Sided, MaterialShapes.getCookie9Sided, MaterialShapes.getCookie12Sided, MaterialShapes.getPixelCircle, MaterialShapes.getCircle, MaterialShapes.getGhostish] + + anchors.fill: parent + visible: Config.runtime.appearance.background.clock.isAnalog + width: clock.width / 1.1 + height: clock.height / 1.1 + + // Polygon + MorphedPolygon { + id: shapeCanvas + + anchors.fill: parent + color: Appearance.m3colors.m3secondaryContainer + roundedPolygon: analogClockContainer.shapes[Config.runtime.appearance.background.clock.shape]() + + transform: Rotation { + origin.x: shapeCanvas.width / 2 + origin.y: shapeCanvas.height / 2 + angle: shapeCanvas.rotation + } + + NumberAnimation on rotation { + from: 0 + to: 360 + running: Config.runtime.appearance.animations.enabled && Config.runtime.appearance.background.clock.rotatePolygonBg + duration: Config.runtime.appearance.background.clock.rotationDuration * 1000 + loops: Animation.Infinite + } + } + + ClockDial { + id: dial + anchors.fill: parent + anchors.margins: parent.width * 0.12 + color: Appearance.colors.colOnSecondaryContainer + z: 0 + } + + // Hour hand + StyledRect { + z: 2 + width: 10 + height: parent.height * 0.3 + radius: Metrics.radius("full") + color: Qt.darker(Appearance.m3colors.m3secondary, 0.8) + x: analogClockContainer.cx - width / 2 + y: analogClockContainer.cy - height + transformOrigin: Item.Bottom + rotation: (analogClockContainer.hours % 12 + analogClockContainer.minutes / 60) * 30 + } + + StyledRect { + anchors.centerIn: parent + width: 16 + height: 16 + radius: width / 2 + color: Appearance.m3colors.m3secondary + z: 99 // Ensures its on top of everthing + + // Inner dot + StyledRect { + width: parent.width / 2 + height: parent.height / 2 + radius: width / 2 + anchors.centerIn: parent + z: 100 + color: Appearance.m3colors.m3primaryContainer + } + + } + + // Minute hand + StyledRect { + width: 18 + height: parent.height * 0.35 + radius: Metrics.radius("full") + color: Appearance.m3colors.m3secondary + x: analogClockContainer.cx - width / 2 + y: analogClockContainer.cy - height + transformOrigin: Item.Bottom + rotation: analogClockContainer.minutes * 6 + z: 10 // On top of all hands + } + + // Second hand + StyledRect { + visible: true + width: 4 + height: parent.height * 0.28 + radius: Metrics.radius("full") + color: Appearance.m3colors.m3error + x: analogClockContainer.cx - width / 2 + y: analogClockContainer.cy - height + transformOrigin: Item.Bottom + rotation: analogClockContainer.seconds * 6 + z: 2 + } + + StyledText { + text: Time.format("hh") + anchors.top: parent.top + anchors.horizontalCenter: parent.horizontalCenter + anchors.topMargin: Metrics.margin(30) + font.pixelSize: Metrics.fontSize(80) + font.bold: true + opacity: 0.3 + animate: false + } + + StyledText { + text: Time.format("mm") + anchors.top: parent.top + anchors.horizontalCenter: parent.horizontalCenter + anchors.topMargin: Metrics.margin(110) + font.pixelSize: Metrics.fontSize(80) + font.bold: true + opacity: 0.3 + animate: false + } + + IpcHandler { + function changePosition() { + clock.setRandomPosition(); + } + + target: "clock" + } + + } + + } + + } + + } + +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/background/ClockDial.qml b/.config/quickshell/nucleus-shell/modules/interface/background/ClockDial.qml new file mode 100644 index 0000000..b0f4bbe --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/background/ClockDial.qml @@ -0,0 +1,56 @@ +import QtQuick +import qs.modules.components + +Item { + id: root + + property color color: "white" + readonly property real cx: width / 2 + readonly property real cy: height / 2 + readonly property real radius: Math.min(width, height) / 2 + opacity: 0.4 + + // Hour marks (12 ticks) + Repeater { + model: 12 + + Item { + width: root.width + height: root.height + anchors.centerIn: parent + rotation: index * 30 + transformOrigin: Item.Center + + Rectangle { + width: 3 // thickness of tick + height: 15 // length of tick + color: root.color + anchors.horizontalCenter: parent.horizontalCenter + y: -root.radius * 0.15 / 2 + radius: width / 2 + } + } + } + + // Minute marks (60 ticks) + Repeater { + model: 60 + + Item { + width: root.width + height: root.height + anchors.centerIn: parent + rotation: index * 6 + transformOrigin: Item.Center + + Rectangle { + width: index % 5 === 0 ? 3 : 2 // thicker for 5-minute marks + height: index % 5 === 0 ? 15 : 8 // longer for 5-minute marks + color: root.color + anchors.horizontalCenter: parent.horizontalCenter + y: -root.radius * 0.15 / 2 + radius: width / 2 + } + } + } +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/bar/Bar.qml b/.config/quickshell/nucleus-shell/modules/interface/bar/Bar.qml new file mode 100644 index 0000000..a9c600c --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/bar/Bar.qml @@ -0,0 +1,163 @@ +import QtQuick +import Quickshell +import Quickshell.Wayland +import qs.config +import qs.services +import qs.modules.components + +Scope { + id: root + + GothCorners { + opacity: ConfigResolver.bar(bar.displayName).gothCorners && !ConfigResolver.bar(bar.displayName).floating && ConfigResolver.bar(bar.displayName).enabled && !ConfigResolver.bar(bar.displayName).merged ? 1 : 0 + } + + Variants { + model: Quickshell.screens + + PanelWindow { + // some exclusiveSpacing so it won't look like its sticking into the window when floating + + id: bar + + required property var modelData + property string displayName: modelData.name + property int rd: ConfigResolver.bar(displayName).radius * Config.runtime.appearance.rounding.factor // So it won't be modified when factor is 0 + property int margin: ConfigResolver.bar(displayName).margins + property bool floating: ConfigResolver.bar(displayName).floating + property bool merged: ConfigResolver.bar(displayName).merged + property string pos: ConfigResolver.bar(displayName).position + property bool vertical: pos === "left" || pos === "right" + // Simple position properties + property bool attachedTop: pos === "top" + property bool attachedBottom: pos === "bottom" + property bool attachedLeft: pos === "left" + property bool attachedRight: pos === "right" + + screen: modelData // Show bar on all screens + visible: ConfigResolver.bar(displayName).enabled && Config.initialized + WlrLayershell.namespace: "nucleus:bar" + exclusiveZone: ConfigResolver.bar(displayName).floating ? ConfigResolver.bar(displayName).density + Metrics.margin("tiny") : ConfigResolver.bar(displayName).density + implicitHeight: ConfigResolver.bar(displayName).density // density === height. (horizontal orientation) + implicitWidth: ConfigResolver.bar(displayName).density // density === width. (vertical orientation) + color: "transparent" // Keep panel window's color transparent, so that it can be modified by background rect + + // This is probably a little weird way to set anchors but I think it's the best way. (and it works) + anchors { + top: ConfigResolver.bar(displayName).position === "top" || ConfigResolver.bar(displayName).position === "left" || ConfigResolver.bar(displayName).position === "right" + bottom: ConfigResolver.bar(displayName).position === "bottom" || ConfigResolver.bar(displayName).position === "left" || ConfigResolver.bar(displayName).position === "right" + left: ConfigResolver.bar(displayName).position === "left" || ConfigResolver.bar(displayName).position === "top" || ConfigResolver.bar(displayName).position === "bottom" + right: ConfigResolver.bar(displayName).position === "right" || ConfigResolver.bar(displayName).position === "top" || ConfigResolver.bar(displayName).position === "bottom" + } + + margins { + top: { + if (floating) + return margin; + + if (merged && vertical) + return margin; + + return 0; + } + bottom: { + if (floating) + return margin; + + if (merged && vertical) + return margin; + + return 0; + } + left: { + if (floating) + return margin; + + if (merged && !vertical) + return margin; + + return 0; + } + right: { + if (floating) + return margin; + + if (merged && !vertical) + return margin; + + return 0; + } + } + + StyledRect { + id: background + color: Appearance.m3colors.m3background + anchors.fill: parent + topLeftRadius: { + if (floating) + return rd; + + if (!merged) + return 0; + + return attachedBottom || attachedRight ? rd : 0; + } + topRightRadius: { + if (floating) + return rd; + + if (!merged) + return 0; + + return attachedBottom || attachedLeft ? rd : 0; + } + bottomLeftRadius: { + if (floating) + return rd; + + if (!merged) + return 0; + + return attachedTop || attachedRight ? rd : 0; + } + bottomRightRadius: { + if (floating) + return rd; + + if (!merged) + return 0; + + return attachedTop || attachedLeft ? rd : 0; + } + + BarContent { + anchors.fill: parent + } + + Behavior on bottomLeftRadius { + enabled: Config.runtime.appearance.animations.enabled + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + Behavior on topLeftRadius { + enabled: Config.runtime.appearance.animations.enabled + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + Behavior on bottomRightRadius { + enabled: Config.runtime.appearance.animations.enabled + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + Behavior on topRightRadius { + enabled: Config.runtime.appearance.animations.enabled + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + } + + } + + } + +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/bar/BarContent.qml b/.config/quickshell/nucleus-shell/modules/interface/bar/BarContent.qml new file mode 100644 index 0000000..e5c35aa --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/bar/BarContent.qml @@ -0,0 +1,178 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import "content/" +import qs.config +import qs.services +import qs.modules.components + +Item { + property string displayName: screen?.name ?? "" + property bool isHorizontal: (ConfigResolver.bar(displayName).position === "top" || ConfigResolver.bar(displayName).position === "bottom") + + Row { + id: hCenterRow + visible: isHorizontal + anchors.centerIn: parent + spacing: Metrics.spacing(4) + + SystemUsageModule {} + MediaPlayerModule {} + ActiveWindowModule {} + ClockModule {} + BatteryIndicatorModule {} + } + + RowLayout { + id: hLeftRow + + visible: isHorizontal + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + spacing: Metrics.spacing(4) + anchors.leftMargin: ConfigResolver.bar(displayName).density * 0.3 + + ToggleModule { + icon: "menu" + iconSize: Metrics.iconSize(22) + iconColor: Appearance.m3colors.m3error + toggle: Globals.visiblility.sidebarLeft + + onToggled: function(value) { + Globals.visiblility.sidebarLeft = value + } + } + + WorkspaceModule {} + } + + RowLayout { + id: hRightRow + + visible: isHorizontal + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: Metrics.spacing(4) + anchors.rightMargin: ConfigResolver.bar(displayName).density * 0.3 + + SystemTray { + id: sysTray + } + + StyledText { + id: seperator + visible: (sysTray.items.count > 0) && ConfigResolver.bar(displayName).modules.statusIcons.enabled + Layout.alignment: Qt.AlignLeft + font.pixelSize: Metrics.fontSize("hugeass") + text: "·" + } + + StatusIconsModule {} + + StyledText { + id: seperator2 + Layout.alignment: Qt.AlignLeft + font.pixelSize: Metrics.fontSize("hugeass") + text: "·" + } + + ToggleModule { + icon: "power_settings_new" + iconSize: Metrics.iconSize(22) + iconColor: Appearance.m3colors.m3error + toggle: Globals.visiblility.powermenu + + onToggled: function(value) { + Globals.visiblility.powermenu = value + } + } + } + + // Vertical Layout + Item { + visible: !isHorizontal + anchors.top: parent.top + anchors.topMargin: ConfigResolver.bar(displayName).density * 0.1 + anchors.horizontalCenter: parent.horizontalCenter + implicitWidth: vRow.implicitHeight + implicitHeight: vRow.implicitWidth + + Row { + id: vRow + anchors.centerIn: parent + spacing: Metrics.spacing(8) + rotation: 90 + + ToggleModule { + icon: "menu" + iconSize: Metrics.iconSize(22) + iconColor: Appearance.m3colors.m3error + toggle: Globals.visiblility.sidebarLeft + rotation: 270 + + onToggled: function(value) { + Globals.visiblility.sidebarLeft = value + } + } + + SystemUsageModule {} + MediaPlayerModule {} + + SystemTray { + rotation: 0 + } + } + } + + Item { + visible: !isHorizontal + anchors.centerIn: parent + anchors.verticalCenterOffset: 35 + implicitWidth: centerRow.implicitHeight + implicitHeight: centerRow.implicitWidth + + Row { + id: centerRow + anchors.centerIn: parent + + WorkspaceModule { + rotation: 90 + } + } + } + + Item { + visible: !isHorizontal + anchors.bottom: parent.bottom + anchors.bottomMargin: ConfigResolver.bar(displayName).density * 0.1 + anchors.horizontalCenter: parent.horizontalCenter + implicitWidth: row.implicitHeight + implicitHeight: row.implicitWidth + + Row { + id: row + anchors.centerIn: parent + spacing: Metrics.spacing(6) + rotation: 90 + + ClockModule { + rotation: 270 + } + + StatusIconsModule {} + BatteryIndicatorModule {} + + ToggleModule { + icon: "power_settings_new" + iconSize: Metrics.iconSize(22) + iconColor: Appearance.m3colors.m3error + toggle: Globals.visiblility.powermenu + rotation: 270 + + onToggled: function(value) { + Globals.visiblility.powermenu = value + } + } + } + } +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/bar/GothCorners.qml b/.config/quickshell/nucleus-shell/modules/interface/bar/GothCorners.qml new file mode 100644 index 0000000..ca6fa37 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/bar/GothCorners.qml @@ -0,0 +1,82 @@ +import QtQuick +import QtQuick.Effects +import Quickshell +import Quickshell.Hyprland +import Quickshell.Io +import Quickshell.Wayland +import qs.config +import qs.modules.components + +PanelWindow { + id: root + + property int opacity: 0 + + color: "transparent" + visible: Config.initialized + WlrLayershell.layer: WlrLayer.Top + + anchors { + top: true + left: true + bottom: true + right: true + } + + Item { + id: container + + anchors.fill: parent + + StyledRect { + anchors.fill: parent + color: Appearance.m3colors.m3background + layer.enabled: true + opacity: root.opacity + + layer.effect: MultiEffect { + maskSource: mask + maskEnabled: true + maskInverted: true + maskThresholdMin: 0.5 + maskSpreadAtMin: 1 + } + + Behavior on opacity { + enabled: Config.runtime.appearance.animations.enabled + NumberAnimation { + duration: Metrics.chronoDuration("large") + easing.type: Easing.InOutExpo + easing.bezierCurve: Appearance.animation.elementMove.bezierCurve + } + + } + + } + + Item { + id: mask + + anchors.fill: parent + layer.enabled: true + visible: false + + StyledRect { + anchors.fill: parent + anchors.topMargin: Config.runtime.bar.position === "bottom" ? -15 : 0 + anchors.bottomMargin: Config.runtime.bar.position === "top" ? -15 : 0 + anchors.leftMargin: Config.runtime.bar.position === "right" ? -15 : 0 + anchors.rightMargin: Config.runtime.bar.position === "left" ? -15 : 0 + radius: Metrics.radius("normal") + } + + } + + } + + mask: Region { + item: container + intersection: Intersection.Xor + } + +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/bar/content/ActiveWindowModule.qml b/.config/quickshell/nucleus-shell/modules/interface/bar/content/ActiveWindowModule.qml new file mode 100644 index 0000000..d8612be --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/bar/content/ActiveWindowModule.qml @@ -0,0 +1,136 @@ +import qs.config +import qs.modules.components +import qs.modules.functions +import qs.services +import QtQuick +import Quickshell +import Quickshell.Wayland +import QtQuick.Layouts + +Item { + id: container + property string displayName: screen?.name ?? "" + Layout.alignment: Qt.AlignLeft | Qt.AlignVCenter + + property Toplevel activeToplevel: Compositor.isWorkspaceOccupied(Compositor.focusedWorkspaceId) + ? Compositor.activeToplevel + : null + + implicitWidth: row.implicitWidth + 30 + implicitHeight: ConfigResolver.bar(displayName).modules.height + + function simplifyTitle(title) { + if (!title) + return "" + + title = title.replace(/[●⬤○◉◌◎]/g, "") // Symbols to remove + + // Normalize separators + title = title + .replace(/\s*[|—]\s*/g, " - ") + .replace(/\s+/g, " ") + .trim() + + const parts = title.split(" - ").map(p => p.trim()).filter(Boolean) + + if (parts.length === 1) + return parts[0] + + // Known app names (extend freely my fellow contributors) + const apps = [ + "Firefox", "Mozilla Firefox", + "Chromium", "Google Chrome", + "Neovim", "VS Code", "Code", + "Kitty", "Alacritty", "Terminal", + "Discord", "Spotify", "Steam", + "Settings - Nucleus", "Settings" + ] + + let app = "" + for (let i = parts.length - 1; i >= 0; i--) { // loop over + for (let a of apps) { + if (parts[i].includes(a)) { + app = a + break + } + } + if (app) break + } + + if (!app) + app = parts[parts.length - 1] + + const context = parts.find(p => p !== app) + + return context ? `${app} · ${context}` : app + } + + + function formatAppId(appId) { // Random ass function to make it look good + if (!appId || appId.length === 0) + return ""; + + // split on dashes/underscores + const parts = appId.split(/[-_]/); + // capitalize each segment + for (let i = 0; i < parts.length; i++) { + const p = parts[i]; + parts[i] = p.charAt(0).toUpperCase() + p.slice(1); + } + return parts.join("-"); + } + + /* Column { + id: col + anchors.centerIn: parent + + StyledText { + id: workspaceText + font.pixelSize: Metrics.fontSize("smallie") + text: { + if (!activeToplevel) + return "Desktop" + + const id = activeToplevel.appId || "" + + return id // Just for aesthetics + } + horizontalAlignment: Text.AlignHCenter + } + + StyledText { + id: titleText + text: StringUtils.shortText(simplifyTitle(activeToplevel?.title, 24) || `Workspace ${Hyprland.focusedWorkspaceId}`) + horizontalAlignment: Text.AlignHCenter + font.pixelSize: Metrics.fontSize("smalle") + } + } */ + + Rectangle { + visible: (ConfigResolver.bar(displayName).position === "top" || ConfigResolver.bar(displayName).position === "bottom") + color: Appearance.m3colors.m3paddingContainer + anchors.fill: parent + height: 34 + width: row.height + 30 + radius: ConfigResolver.bar(displayName).modules.radius + } + + + RowLayout { + id: row + spacing: 12 + anchors.centerIn: parent + + MaterialSymbol { + icon: "desktop_windows" + rotation: (ConfigResolver.bar(displayName).position === "left" || ConfigResolver.bar(displayName).position === "right") ? 270 : 0 + } + + StyledText { + text: StringUtils.shortText(simplifyTitle(activeToplevel?.title), 24) || `Workspace ${Hyprland.focusedWorkspaceId}` + horizontalAlignment: Text.AlignHCenter + font.pixelSize: Appearance.font.size.small + } + } + +} \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/modules/interface/bar/content/BatteryIndicatorModule.qml b/.config/quickshell/nucleus-shell/modules/interface/bar/content/BatteryIndicatorModule.qml new file mode 100644 index 0000000..854c600 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/bar/content/BatteryIndicatorModule.qml @@ -0,0 +1,57 @@ +import QtQuick +import QtQuick.Layouts +import qs.config +import qs.modules.components +import qs.services + +Item { + id: batteryIndicatorModuleContainer + + visible: UPower.batteryPresent + Layout.alignment: Qt.AlignVCenter + + // Determine if bar is isVertical + property bool isVertical: ConfigResolver.bar(screen?.name ?? "").position === "left" || ConfigResolver.bar(screen?.name ?? "").position === "right" + + implicitWidth: bgRect.implicitWidth + implicitHeight: bgRect.implicitHeight + + Rectangle { + id: bgRect + color: isVertical ? Appearance.m3colors.m3primary : Appearance.m3colors.m3paddingContainer + radius: ConfigResolver.bar(screen?.name ?? "").modules.radius * Config.runtime.appearance.rounding.factor // No need to use metrics here... + + implicitWidth: child.implicitWidth + Appearance.margin.large - (isVertical ? 10 : 0) + implicitHeight: ConfigResolver.bar(screen?.name ?? "").modules.height + } + + RowLayout { + id: child + anchors.centerIn: parent + spacing: isVertical ? 0 : Metrics.spacing(8) + + // Icon for isVertical bars + MaterialSymbol { + visible: isVertical + icon: UPower.battIcon + iconSize: Metrics.iconSize(20) + } + + // Battery percentage text + StyledText { + animate: false + font.pixelSize: Metrics.fontSize(16) + rotation: isVertical ? 270 : 0 + text: (isVertical ? UPower.percentage : UPower.percentage + "%") + } + + // Circular progress for horizontal bars + CircularProgressBar { + visible: !isVertical + value: UPower.percentage / 100 + icon: UPower.battIcon + iconSize: Metrics.iconSize(18) + Layout.bottomMargin: Metrics.margin(2) + } + } +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/bar/content/BongoCat.qml b/.config/quickshell/nucleus-shell/modules/interface/bar/content/BongoCat.qml new file mode 100644 index 0000000..73eb544 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/bar/content/BongoCat.qml @@ -0,0 +1,27 @@ +import QtQuick +import QtQuick.Layouts +import qs.services +import qs.config +import qs.modules.components + +Item { + id: clockContainer + + property string format: isVertical ? "hh\nmm\nAP" : "hh:mm • dd/MM" + property bool isVertical: (ConfigResolver.bar(screen?.name ?? "").position === "left" || ConfigResolver.bar(screen?.name ?? "").position === "right") + + Layout.alignment: Qt.AlignVCenter + implicitWidth: 37 + implicitHeight: 30 + + AnimatedImage { + id: art + anchors.fill: parent + source: Directories.assetsPath + "/gifs/bongo-cat.gif" + cache: false // this is important + smooth: true // smooooooth + rotation: isVertical ? 270 : 0 + } + + +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/bar/content/ClockModule.qml b/.config/quickshell/nucleus-shell/modules/interface/bar/content/ClockModule.qml new file mode 100644 index 0000000..85020aa --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/bar/content/ClockModule.qml @@ -0,0 +1,36 @@ +import QtQuick +import QtQuick.Layouts +import qs.services +import qs.config +import qs.modules.components + +Item { + id: clockContainer + + property string format: isVertical ? "hh\nmm\nAP" : "hh:mm • dd/MM" + property bool isVertical: (ConfigResolver.bar(screen?.name ?? "").position === "left" || ConfigResolver.bar(screen?.name ?? "").position === "right") + + Layout.alignment: Qt.AlignVCenter + implicitWidth: bgRect.implicitWidth + implicitHeight: bgRect.implicitHeight + + // Let the layout compute size automatically + + Rectangle { + id: bgRect + + color: isVertical ? "transparent" : Appearance.m3colors.m3paddingContainer + radius: ConfigResolver.bar(screen?.name ?? "").modules.radius * Config.runtime.appearance.rounding.factor + // Padding around the text + implicitWidth: isVertical ? textItem.implicitWidth + 40 : textItem.implicitWidth + Metrics.margin("large") + implicitHeight: ConfigResolver.bar(screen?.name ?? "").modules.height + } + + StyledText { + id: textItem + anchors.centerIn: parent + animate: false + text: Time.format(clockContainer.format) + } + +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/bar/content/MediaPlayerModule.qml b/.config/quickshell/nucleus-shell/modules/interface/bar/content/MediaPlayerModule.qml new file mode 100644 index 0000000..2c73743 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/bar/content/MediaPlayerModule.qml @@ -0,0 +1,127 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Quickshell.Io + +import qs.config +import qs.modules.functions +import qs.modules.components +import qs.services + +Item { + id: mediaPlayer + + property bool isVertical: ( + ConfigResolver.bar(screen?.name ?? "").position === "left" || + ConfigResolver.bar(screen?.name ?? "").position === "right" + ) + + Layout.alignment: Qt.AlignCenter | Qt.AlignVCenter + + implicitWidth: bgRect.implicitWidth + implicitHeight: bgRect.implicitHeight + + + Rectangle { + id: bgRect + + color: Appearance.m3colors.m3paddingContainer + radius: ConfigResolver.bar(screen?.name ?? "").modules.radius * + Config.runtime.appearance.rounding.factor + + implicitWidth: isVertical + ? row.implicitWidth + Metrics.margin("large") - 10 + : row.implicitWidth + Metrics.margin("large") + + implicitHeight: ConfigResolver.bar(screen?.name ?? "").modules.height + } + + + Row { + id: row + + anchors.centerIn: parent + spacing: Metrics.margin("small") + + + ClippingRectangle { + id: iconButton + + width: 24 + height: 24 + radius: ConfigResolver.bar(screen?.name ?? "").modules.radius / 1.2 + + color: Appearance.colors.colLayer1Hover + opacity: 0.9 + + clip: true + layer.enabled: true + + + Item { + anchors.fill: parent + + + Image { + id: art + + anchors.fill: parent + visible: Mpris.artUrl !== "" + + source: Mpris.artUrl + fillMode: Image.PreserveAspectCrop + smooth: true + mipmap: true + } + + + MaterialSymbol { + anchors.centerIn: parent + + visible: Mpris.artUrl === "" + icon: "music_note" + + iconSize: 18 + color: Config.runtime.appearance.theme === "dark" + ? "#b1a4a4" + : "grey" + } + } + + + MouseArea { + anchors.fill: parent + hoverEnabled: true + + onClicked: Mpris.playPause() + + onEntered: iconButton.opacity = 1 + onExited: iconButton.opacity = 0.9 + } + + + RotationAnimation on rotation { + from: 0 + to: 360 + + duration: Metrics.chronoDuration(4000) + loops: Animation.Infinite + + running: Mpris.isPlaying && + Config.runtime.appearance.animations.enabled + } + } + + + StyledText { + id: textItem + + anchors.verticalCenter: parent.verticalCenter + + text: StringUtils.shortText(Mpris.title, 16) + + visible: !mediaPlayer.isVertical + } + } +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/bar/content/StatusIconsModule.qml b/.config/quickshell/nucleus-shell/modules/interface/bar/content/StatusIconsModule.qml new file mode 100644 index 0000000..531c3bc --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/bar/content/StatusIconsModule.qml @@ -0,0 +1,65 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import qs.config +import qs.modules.components +import qs.services + +Item { + id: statusIconsContainer + + property bool isVertical: (ConfigResolver.bar(screen?.name ?? "").position === "left" || ConfigResolver.bar(screen?.name ?? "").position === "right") + + Layout.alignment: Qt.AlignVCenter + visible: ConfigResolver.bar(screen?.name ?? "").modules.statusIcons.enabled + implicitWidth: bgRect.implicitWidth + implicitHeight: bgRect.implicitHeight + + StyledRect { + id: bgRect + + color: Globals.visiblility.sidebarRight ? Appearance.m3colors.m3paddingContainer : "transparent" + radius: ConfigResolver.bar(screen?.name ?? "").modules.radius * Config.runtime.appearance.rounding.factor + implicitWidth: isVertical ? contentRow.implicitWidth + Metrics.margin("large") - 8 : contentRow.implicitWidth + Metrics.margin("large") + implicitHeight: ConfigResolver.bar(screen?.name ?? "").modules.height + + RowLayout { + id: contentRow + + anchors.centerIn: parent + spacing: isVertical ? Metrics.spacing(8) : Metrics.spacing(16) + + + MaterialSymbol { + id: wifi + animate: false + visible: ConfigResolver.bar(screen?.name ?? "").modules.statusIcons.networkStatusEnabled + rotation: isVertical ? 270 : 0 + icon: Network.icon + iconSize: Metrics.fontSize("huge") + } + + MaterialSymbol { + id: btIcon + animate: false + visible: ConfigResolver.bar(screen?.name ?? "").modules.statusIcons.bluetoothStatusEnabled + rotation: isVertical ? 270 : 0 + icon: Bluetooth.icon + iconSize: Metrics.fontSize("huge") + } + + + } + + MouseArea { + anchors.fill: parent + onClicked: { + if (Globals.visiblility.sidebarLeft) + return + Globals.visiblility.sidebarRight = !Globals.visiblility.sidebarRight + } + } + + } + +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/bar/content/SystemTray.qml b/.config/quickshell/nucleus-shell/modules/interface/bar/content/SystemTray.qml new file mode 100644 index 0000000..c252f73 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/bar/content/SystemTray.qml @@ -0,0 +1,165 @@ +import qs.modules.components +import qs.config +import qs.services +import Quickshell.Services.SystemTray +import QtQuick +import Quickshell +import Quickshell.Widgets +import QtQuick.Layouts + +Item { + id: root + readonly property Repeater items: items + property bool horizontalMode: (ConfigResolver.bar(screen?.name ?? "").position === "top" || ConfigResolver.bar(screen?.name ?? "").position === "bottom") + clip: true + implicitWidth: layout.implicitWidth + Metrics.margin("verylarge") + implicitHeight: 34 + + Rectangle { + visible: (items.count > 0) ? 1 : 0 + id: padding + implicitHeight: padding.height + anchors.fill: parent + radius: ConfigResolver.bar(screen?.name ?? "").modules.radius + color: "transparent" + } + + GridLayout { + id: layout + anchors.centerIn: parent + rows: 1 + columns: items.count + rowSpacing: Metrics.spacing(10) + columnSpacing: Metrics.spacing(10) + + Repeater { + id: items + model: SystemTray.items + + delegate: Item { + id: trayItemRoot + required property SystemTrayItem modelData + implicitWidth: 20 + implicitHeight: 20 + + IconImage { + visible: trayItemRoot.modelData.icon !== "" + source: trayItemRoot.modelData.icon + asynchronous: true + anchors.fill: parent + rotation: root.horizontalMode ? 0 : 270 + } + + HoverHandler { + id: hover + } + + QsMenuOpener { + id: menuOpener + menu: trayItemRoot.modelData.menu + } + + StyledPopout { + id: popout + hoverTarget: hover + interactable: true + hCenterOnItem: true + requiresHover: false + + Component { + Item { + width: childColumn.implicitWidth + height: childColumn.height + + ColumnLayout { + id: childColumn + spacing: Metrics.spacing(5) + + Repeater { + model: menuOpener.children + delegate: TrayMenuItem { + parentColumn: childColumn + Layout.preferredWidth: childColumn.width > 0 ? childColumn.width : implicitWidth + } + } + } + } + } + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.RightButton + hoverEnabled: true + + onClicked: { + if (popout.isVisible) + popout.hide(); + else + popout.show(); + } + } + } + } + } + + component TrayMenuItem: Item { + id: itemRoot + required property QsMenuEntry modelData + required property ColumnLayout parentColumn + + Layout.fillWidth: true + implicitWidth: rowLayout.implicitWidth + 10 + implicitHeight: !itemRoot.modelData.isSeparator ? rowLayout.implicitHeight + 10 : 1 + + MouseArea { + id: hover + hoverEnabled: itemRoot.modelData.enabled + anchors.fill: parent + onClicked: { + if (!itemRoot.modelData.hasChildren) + itemRoot.modelData.triggered(); + } + } + + Rectangle { + id: itemBg + anchors.fill: parent + opacity: itemRoot.modelData.isSeparator ? 0.5 : 1 + color: itemRoot.modelData.isSeparator ? Appearance.m3colors.m3outline : hover.containsMouse ? Appearance.m3colors.m3surfaceContainer : Appearance.m3colors.m3surface + } + + RowLayout { + id: rowLayout + visible: !itemRoot.modelData.isSeparator + opacity: itemRoot.modelData.isSeparator ? 0.5 : 1 + spacing: Metrics.spacing(5) + anchors { + left: itemBg.left + leftMargin: Metrics.margin(5) + top: itemBg.top + topMargin:Metrics.margin(5) + } + + IconImage { + visible: itemRoot.modelData.icon !== "" + source: itemRoot.modelData.icon + width: 15 + height: 15 + } + + StyledText { + text: itemRoot.modelData.text + font.pixelSize: Metrics.fontSize(14) + color: Appearance.m3colors.m3onSurface + } + + MaterialSymbol { + visible: itemRoot.modelData.hasChildren + icon: "chevron_right" + iconSize: Metrics.iconSize(16) + color: Appearance.m3colors.m3onSurface + } + } + } +} \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/modules/interface/bar/content/SystemUsageModule.qml b/.config/quickshell/nucleus-shell/modules/interface/bar/content/SystemUsageModule.qml new file mode 100644 index 0000000..3d0e267 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/bar/content/SystemUsageModule.qml @@ -0,0 +1,99 @@ +import QtQuick +import QtQuick.Layouts +import qs.config +import qs.modules.components +import qs.services + +Item { + id: systemUsageContainer + property string displayName: screen?.name ?? "" + property bool isHorizontal: (ConfigResolver.bar(displayName).position === "top" || ConfigResolver.bar(displayName).position === "bottom") + + visible: ConfigResolver.bar(displayName).modules.systemUsage.enabled && haveWidth + Layout.alignment: Qt.AlignVCenter + + implicitWidth: bgRect.implicitWidth + implicitHeight: bgRect.implicitHeight + + property bool haveWidth: + ConfigResolver.bar(displayName).modules.systemUsage.tempStatsEnabled || + ConfigResolver.bar(displayName).modules.systemUsage.cpuStatsEnabled || + ConfigResolver.bar(displayName).modules.systemUsage.memoryStatsEnabled + + + // Normalize values so UI always receives correct ranges + function normalize(v) { + if (v > 1) return v / 100 + return v + } + + function percent(v) { + if (v <= 1) return Math.round(v * 100) + return Math.round(v) + } + + Rectangle { + id: bgRect + color: Appearance.m3colors.m3paddingContainer + radius: ConfigResolver.bar(displayName).modules.radius * Config.runtime.appearance.rounding.factor + + implicitWidth: child.implicitWidth + Metrics.margin("large") + implicitHeight: ConfigResolver.bar(displayName).modules.height + } + + RowLayout { + id: child + anchors.centerIn: parent + spacing: Metrics.spacing(4) + + // CPU + CircularProgressBar { + rotation: !isHorizontal ? 270 : 0 + icon: "developer_board" + visible: ConfigResolver.bar(displayName).modules.systemUsage.cpuStatsEnabled + iconSize: Metrics.iconSize(14) + value: normalize(SystemDetails.cpuPercent) + Layout.bottomMargin: Metrics.margin(2) + } + + StyledText { + visible: ConfigResolver.bar(displayName).modules.systemUsage.cpuStatsEnabled && isHorizontal + animate: false + text: percent(SystemDetails.cpuPercent) + "%" + } + + // RAM + CircularProgressBar { + rotation: !isHorizontal ? 270 : 0 + Layout.leftMargin: Metrics.margin(4) + icon: "memory_alt" + visible: ConfigResolver.bar(displayName).modules.systemUsage.memoryStatsEnabled + iconSize: Metrics.iconSize(14) + value: normalize(SystemDetails.ramPercent) + Layout.bottomMargin: Metrics.margin(2) + } + + StyledText { + visible: ConfigResolver.bar(displayName).modules.systemUsage.memoryStatsEnabled && isHorizontal + animate: false + text: percent(SystemDetails.ramPercent) + "%" + } + + // Temperature + CircularProgressBar { + rotation: !isHorizontal ? 270 : 0 + visible: ConfigResolver.bar(displayName).modules.systemUsage.tempStatsEnabled + Layout.leftMargin: Metrics.margin(4) + icon: "device_thermostat" + iconSize: Metrics.iconSize(14) + value: normalize(SystemDetails.cpuTempPercent) + Layout.bottomMargin: Metrics.margin(2) + } + + StyledText { + visible: ConfigResolver.bar(displayName).modules.systemUsage.tempStatsEnabled && isHorizontal + animate: false + text: percent(SystemDetails.cpuTempPercent) + "%" + } + } +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/bar/content/ToggleModule.qml b/.config/quickshell/nucleus-shell/modules/interface/bar/content/ToggleModule.qml new file mode 100644 index 0000000..239ff38 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/bar/content/ToggleModule.qml @@ -0,0 +1,43 @@ +import qs.config +import qs.modules.components +import QtQuick +import Quickshell +import QtQuick.Layouts + +StyledRect { + id: bg + + property string icon + property color iconColor: Appearance.syntaxHighlightingTheme + property int iconSize + property bool toggle + property bool transparentBg: false + + signal toggled(bool value) + + color: (ma.containsMouse && !transparentBg) + ? Appearance.m3colors.m3paddingContainer + : "transparent" + + radius: Metrics.radius("childish") + + implicitWidth: textItem.implicitWidth + 12 + implicitHeight: textItem.implicitHeight + 6 + + MaterialSymbol { + id: textItem + anchors.centerIn: parent + anchors.verticalCenterOffset: 0.4 + anchors.horizontalCenterOffset: 0.499 + iconSize: bg.iconSize + icon: bg.icon + color: bg.iconColor + } + + MouseArea { + id: ma + anchors.fill: parent + hoverEnabled: true + onClicked: bg.toggled(!bg.toggle) + } +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/bar/content/WorkspaceModule.qml b/.config/quickshell/nucleus-shell/modules/interface/bar/content/WorkspaceModule.qml new file mode 100644 index 0000000..910ca86 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/bar/content/WorkspaceModule.qml @@ -0,0 +1,254 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Effects +import Quickshell +import Quickshell.Widgets +import qs.config +import qs.modules.components +import qs.modules.functions +import qs.services + +Item { + id: workspaceContainer + property string displayName: screen?.name ?? "" + property int numWorkspaces: ConfigResolver.bar(displayName).modules.workspaces.workspaceIndicators + property var workspaceOccupied: [] + property var occupiedRanges: [] + + function japaneseNumber(num) { + var kanjiMap = { + "0": "零", + "1": "一", + "2": "二", + "3": "三", + "4": "四", + "5": "五", + "6": "六", + "7": "七", + "8": "八", + "9": "九", + "10": "十" + }; + return kanjiMap[num] !== undefined ? kanjiMap[num] : "Number out of range"; + } + + function updateWorkspaceOccupied() { + const offset = 1; + workspaceOccupied = Array.from({ + "length": numWorkspaces + }, (_, i) => { + return Compositor.isWorkspaceOccupied(i + 1); + }); + const ranges = []; + let start = -1; + for (let i = 0; i < workspaceOccupied.length; i++) { + if (workspaceOccupied[i]) { + if (start === -1) + start = i; + + } else if (start !== -1) { + ranges.push({ + "start": start, + "end": i - 1 + }); + start = -1; + } + } + if (start !== -1) + ranges.push({ + "start": start, + "end": workspaceOccupied.length - 1 + }); + + occupiedRanges = ranges; + } + + visible: ConfigResolver.bar(displayName).modules.workspaces.enabled + implicitWidth: bg.implicitWidth + implicitHeight: ConfigResolver.bar(displayName).modules.height + Component.onCompleted: updateWorkspaceOccupied() + + Connections { + function onStateChanged() { + updateWorkspaceOccupied(); + } + + target: Compositor + } + + Rectangle { + id: bg + + color: Appearance.m3colors.m3paddingContainer + radius: ConfigResolver.bar(displayName).modules.radius * Config.runtime.appearance.rounding.factor + implicitWidth: workspaceRow.implicitWidth + Metrics.margin("large") - 8 + implicitHeight: ConfigResolver.bar(displayName).modules.height + + // occupied background highlight + Item { + id: occupiedStretchLayer + + anchors.centerIn: workspaceRow + width: workspaceRow.width + height: 26 + z: 0 + visible: Compositor.require("hyprland") // Hyprland only + + Repeater { + model: occupiedRanges + + Rectangle { + height: 26 + radius: ConfigResolver.bar(displayName).modules.radius * Config.runtime.appearance.rounding.factor + color: ColorUtils.mix(Appearance.m3colors.m3tertiary, Appearance.m3colors.m3surfaceContainerLowest) + opacity: 0.8 + x: modelData.start * (26 + workspaceRow.spacing) + width: (modelData.end - modelData.start + 1) * 26 + (modelData.end - modelData.start) * workspaceRow.spacing + } + + } + + } + + // workspace highlight + Rectangle { + id: highlight + + property int offset: Compositor.require("hyprland") ? 1 : 0 + property int index: Math.max(0, Compositor.focusedWorkspaceId - 1 - offset) + property real itemWidth: 26 + property real spacing: workspaceRow.spacing + property int highlightIndex: { + if (!Compositor.focusedWorkspaceId) + return 0; + + if (Compositor.require("hyprland")) + return Compositor.focusedWorkspaceId - 1; + // Hyprland starts at 2 internally + return Compositor.focusedWorkspaceId - 2; // Niri or default + } + property real targetX: Math.min(highlightIndex, numWorkspaces - 1) * (itemWidth + spacing) + 7.3 + property real animatedX1: targetX + property real animatedX2: targetX + + x: Math.min(animatedX1, animatedX2) + anchors.verticalCenter: parent.verticalCenter + width: Math.abs(animatedX2 - animatedX1) + itemWidth - 1 + height: 24 + radius: ConfigResolver.bar(displayName).modules.radius * Config.runtime.appearance.rounding.factor + color: Appearance.m3colors.m3tertiary + onTargetXChanged: { + animatedX1 = targetX; + animatedX2 = targetX; + } + + Behavior on animatedX1 { + enabled: Config.runtime.appearance.animations.enabled + + NumberAnimation { + duration: Metrics.chronoDuration(400) + easing.type: Easing.OutSine + } + + } + + Behavior on animatedX2 { + enabled: Config.runtime.appearance.animations.enabled + + NumberAnimation { + duration: Metrics.chronoDuration(133) + easing.type: Easing.OutSine + } + + } + + } + + RowLayout { + id: workspaceRow + + anchors.centerIn: parent + spacing: Metrics.spacing(10) + + Repeater { + model: numWorkspaces + + Item { + property int wsIndex: index + 1 + property bool occupied: Compositor.isWorkspaceOccupied(wsIndex) + property bool focused: wsIndex === Compositor.focusedWorkspaceId + + width: 26 + height: 26 + + // Icon container — only used on Hyprland + ClippingRectangle { + id: iconContainer + + anchors.centerIn: parent + width: 20 + height: 20 + color: "transparent" + radius: Appearance.rounding.small + clip: true + + IconImage { + id: appIcon + + anchors.fill: parent + visible: Compositor.require("hyprland") && ConfigResolver.bar(displayName).modules.workspaces.showAppIcons && occupied + rotation: (ConfigResolver.bar(displayName).position === "left" || ConfigResolver.bar(displayName).position === "right") ? 270 : 0 + source: { + const win = Compositor.focusedWindowForWorkspace(wsIndex); + return win ? AppRegistry.iconForClass(win.class) : ""; + } + layer.enabled: true + layer.effect: MultiEffect { + saturation: (Config.runtime.appearance.tintIcons || (Config.runtime.appearance.colors.matugenScheme === "scheme-monochrome" && Config.runtime.appearance.colors.autogenerated) || Config.runtime.appearance.colors.scheme.toLowerCase() === "monochrome") ? -1.0 : 1.0 + } + } + + } + + // Kanji mode — only if not Hyprland + StyledText { + anchors.centerIn: parent + visible: ConfigResolver.bar(displayName).modules.workspaces.showJapaneseNumbers && !ConfigResolver.bar(displayName).modules.workspaces.showAppIcons + text: japaneseNumber(index + 1) + rotation: (ConfigResolver.bar(displayName).position === "left" || ConfigResolver.bar(displayName).position === "right") ? 270 : 0 + } + + // Numbers mode — only if not Hyprland + StyledText { + anchors.centerIn: parent + visible: !ConfigResolver.bar(displayName).modules.workspaces.showJapaneseNumbers && !ConfigResolver.bar(displayName).modules.workspaces.showAppIcons + text: index + 1 + rotation: (ConfigResolver.bar(displayName).position === "left" || ConfigResolver.bar(displayName).position === "right") ? 270 : 0 + } + + // Symbols for unoccupied workspaces — only for Hyprland icons + MaterialSymbol { + property string displayText: Config.runtime.appearance.rounding.factor === 0 ? "crop_square" : "fiber_manual_record" + + anchors.centerIn: parent + visible: Compositor.require("hyprland") && ConfigResolver.bar(displayName).modules.workspaces.showAppIcons && !occupied + text: displayText + rotation: (ConfigResolver.bar(displayName).position === "left" || ConfigResolver.bar(displayName).position === "right") ? 270 : 0 + font.pixelSize: Metrics.iconSize(10) + fill: 1 + } + + MouseArea { + anchors.fill: parent + onClicked: Compositor.changeWorkspace(wsIndex) + } + + } + + } + + } + + } + +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/intelligence/Intelligence.qml b/.config/quickshell/nucleus-shell/modules/interface/intelligence/Intelligence.qml new file mode 100644 index 0000000..406f6a6 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/intelligence/Intelligence.qml @@ -0,0 +1,576 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Window +import Quickshell +import Quickshell.Io +import qs.config +import qs.modules.functions +import qs.modules.components +import qs.services + +FloatingWindow { + id: appWin + color: Appearance.m3colors.m3background + property bool initialChatSelected: false + property bool chatsInitialized: false + + function appendMessage(sender, message) { + messageModel.append({ + "sender": sender, + "message": message + }); + scrollToBottom(); + } + + function updateChatsList(files) { + let existing = { + }; + for (let i = 0; i < chatListModel.count; i++) existing[chatListModel.get(i).name] = true + for (let file of files) { + let name = file.trim(); + if (!name.length) + continue; + + if (name.endsWith(".txt")) + name = name.slice(0, -4); + + if (!existing[name]) + chatListModel.append({ + "name": name + }); + + delete existing[name]; + } + // remove chats that no longer exist + for (let name in existing) { + for (let i = 0; i < chatListModel.count; i++) { + if (chatListModel.get(i).name === name) { + chatListModel.remove(i); + break; + } + } + } + // ensure default exists + let hasDefault = false; + for (let i = 0; i < chatListModel.count; i++) if (chatListModel.get(i).name === "default") { + hasDefault = true; + } + if (!hasDefault) { + chatListModel.insert(0, { + "name": "default" + }); + FileUtils.createFile(FileUtils.trimFileProtocol(Directories.config) + "/zenith/chats/default.txt"); + } + } + + function scrollToBottom() { + // Always scroll to end after appending + chatView.forceLayout(); + chatView.positionViewAtEnd(); + } + + function sendMessage() { + if (userInput.text === "" || Zenith.loading) + return ; + + Zenith.pendingInput = userInput.text; + appendMessage("You", userInput.text); + userInput.text = ""; + Zenith.loading = true; + Zenith.send(); + } + + function loadChatHistory(chatName) { + messageModel.clear(); + Zenith.loadChat(chatName); + } + + function selectDefaultChat() { + let defaultIndex = -1; + for (let i = 0; i < chatListModel.count; i++) { + if (chatListModel.get(i).name === "default") { + defaultIndex = i; + break; + } + } + if (defaultIndex !== -1) { + chatSelector.currentIndex = defaultIndex; + Zenith.currentChat = "default"; + loadChatHistory("default"); + } else if (chatListModel.count > 0) { + chatSelector.currentIndex = 0; + Zenith.currentChat = chatListModel.get(0).name; + loadChatHistory(Zenith.currentChat); + } + } + + visible: Globals.states.intelligenceWindowOpen + + onVisibleChanged: { + if (!visible) + return ; + + chatsInitialized = false; + messageModel.clear(); + } + + IpcHandler { + function openWindow() { + Globals.states.intelligenceWindowOpen = true; + } + + function closeWindow() { + Globals.states.intelligenceWindowOpen = false; + } + + target: "intelligence" + } + + ListModel { + // { sender: "You" | "AI", message: string } + + id: messageModel + } + + ListModel { + id: chatListModel + } + + ColumnLayout { + spacing: Metrics.spacing(8) + anchors.centerIn: parent + + StyledText { + visible: !Config.runtime.misc.intelligence.enabled + text: "Intelligence is disabled!" + Layout.leftMargin: Metrics.margin(24) + font.pixelSize: Metrics.fontSize("huge") + } + + StyledText { + visible: !Config.runtime.misc.intelligence.enabled + text: "Go to the settings to enable intelligence" + } + + } + + StyledRect { + anchors.fill: parent + color: "transparent" + visible: Config.runtime.misc.intelligence.enabled + + ColumnLayout { + anchors.fill: parent + anchors.margins: Metrics.margin(16) + spacing: Metrics.spacing(10) + + RowLayout { + Layout.fillWidth: true + spacing: Metrics.spacing(10) + + StyledDropDown { + id: chatSelector + + Layout.fillWidth: true + model: chatListModel + textRole: "name" + Layout.preferredHeight: 40 + onCurrentIndexChanged: { + if (currentIndex < 0) + return ; + + let name = chatListModel.get(currentIndex).name; + if (name === Zenith.currentChat) + return ; + + Zenith.currentChat = name; + loadChatHistory(name); + } + } + + StyledButton { + icon: "add" + Layout.preferredWidth: 40 + onClicked: { + let name = "new-chat-" + chatListModel.count; + let path = FileUtils.trimFileProtocol(Directories.config) + "/zenith/chats/" + name + ".txt"; + FileUtils.createFile(path, function(success) { + if (success) { + chatListModel.append({ + "name": name + }); + chatSelector.currentIndex = chatListModel.count - 1; + Zenith.currentChat = name; + messageModel.clear(); + } + }); + } + } + + StyledButton { + icon: "edit" + Layout.preferredWidth: 40 + enabled: chatSelector.currentIndex >= 0 + onClicked: renameDialog.open() + } + + StyledButton { + icon: "delete" + Layout.preferredWidth: 40 + enabled: chatSelector.currentIndex >= 0 && chatSelector.currentText !== "default" + onClicked: { + let name = chatSelector.currentText; + let path = FileUtils.trimFileProtocol(Directories.config) + "/zenith/chats/" + name + ".txt"; + FileUtils.removeFile(path, function(success) { + if (success) { + chatListModel.remove(chatSelector.currentIndex); + selectDefaultChat(); + } + }); + } + } + + } + + RowLayout { + Layout.fillWidth: true + spacing: Metrics.spacing(10) + + StyledDropDown { + id: modelSelector + + Layout.fillWidth: true + model: ["openai/gpt-4o","openai/gpt-4","openai/gpt-3.5-turbo","openai/gpt-4o-mini","anthropic/claude-3.5-sonnet","anthropic/claude-3-haiku","meta-llama/llama-3.3-70b-instruct:free","deepseek/deepseek-r1-0528:free","qwen/qwen3-coder:free"] + currentIndex: 0 + Layout.preferredHeight: 40 + onCurrentTextChanged: Zenith.currentModel = currentText + } + + StyledButton { + icon: "close_fullscreen" + Layout.preferredWidth: 40 + onClicked: { + Quickshell.execDetached(["nucleus", "ipc", "call", "intelligence", "closeWindow"]); + Globals.visiblility.sidebarLeft = false; + } + } + + } + + StyledRect { + Layout.fillWidth: true + Layout.fillHeight: true + radius: Metrics.radius("normal") + color: Appearance.m3colors.m3surfaceContainerLow + + ScrollView { + anchors.fill: parent + clip: true + + ListView { + id: chatView + + model: messageModel + spacing: Metrics.spacing(8) + anchors.fill: parent + anchors.margins: Metrics.margin(12) + clip: true + + delegate: Item { + property bool isCodeBlock: message.split("\n").length > 2 && message.includes("import ") // simple heuristic + + width: chatView.width + height: bubble.implicitHeight + 6 + Component.onCompleted: { + chatView.forceLayout(); + } + + Row { + width: parent.width + spacing: Metrics.spacing(8) + + Item { + width: sender === "AI" ? 0 : parent.width * 0.2 + } + + StyledRect { + id: bubble + + radius: Metrics.radius("normal") + color: sender === "You" ? Appearance.m3colors.m3primaryContainer : Appearance.m3colors.m3surfaceContainerHigh + implicitWidth: Math.min(textItem.implicitWidth + 20, chatView.width * 0.8) + implicitHeight: textItem.implicitHeight + anchors.right: sender === "You" ? parent.right : undefined + anchors.left: sender === "AI" ? parent.left : undefined + anchors.topMargin: Metrics.margin(2) + + TextEdit { + id: textItem + + text: StringUtils.markdownToHtml(message) + wrapMode: TextEdit.Wrap + textFormat: TextEdit.RichText + readOnly: true // make it selectable but not editable + font.pixelSize: Metrics.fontSize(16) + color: Appearance.syntaxHighlightingTheme + padding: Metrics.padding(8) + anchors.fill: parent + } + + MouseArea { + id: ma + + anchors.fill: parent + acceptedButtons: Qt.RightButton + onClicked: { + let p = Qt.createQmlObject('import Quickshell; import Quickshell.Io; Process { command: ["wl-copy", "' + message + '"] }', parent); + p.running = true; + } + } + + } + + Item { + width: sender === "You" ? 0 : parent.width * 0.2 + } + + } + + } + + } + + } + + } + + StyledRect { + Layout.fillWidth: true + height: 50 + radius: Metrics.radius("normal") + color: Appearance.m3colors.m3surfaceContainer + + RowLayout { + anchors.fill: parent + anchors.margins: Metrics.margin(6) + spacing: Metrics.spacing(10) + + StyledTextField { + // Shift+Enter → insert newline + // Enter → send message + + id: userInput + + Layout.fillWidth: true + placeholderText: "Type your message..." + font.pixelSize: Metrics.iconSize(14) + padding: Metrics.spacing(8) + Keys.onPressed: { + if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + if (event.modifiers & Qt.ShiftModifier) + insert("\n"); + else + sendMessage(); + event.accepted = true; + } + } + } + + StyledButton { + text: "Send" + enabled: userInput.text.trim().length > 0 && !Zenith.loading + opacity: enabled ? 1 : 0.5 + onClicked: sendMessage() + } + + } + + } + + } + + Dialog { + id: renameDialog + + title: "Rename Chat" + modal: true + visible: false + standardButtons: Dialog.NoButton + x: (appWin.width - 360) / 2 // center horizontally + y: (appWin.height - 160) / 2 // center vertically + width: 360 + height: 200 + + ColumnLayout { + anchors.fill: parent + anchors.margins: Metrics.margin(16) + spacing: Metrics.spacing(12) + + StyledText { + text: "Enter a new name for the chat" + font.pixelSize: Metrics.fontSize(18) + horizontalAlignment: Text.AlignHCenter + Layout.fillWidth: true + } + + StyledTextField { + id: renameInput + + Layout.fillWidth: true + placeholderText: "New name" + filled: false + highlight: false + text: chatSelector.currentText + font.pixelSize: Metrics.fontSize(16) + Layout.preferredHeight: 45 + padding: Metrics.padding(8) + } + + RowLayout { + Layout.fillWidth: true + spacing: Metrics.spacing(12) + Layout.alignment: Qt.AlignRight + + StyledButton { + text: "Cancel" + Layout.preferredWidth: 80 + onClicked: renameDialog.close() + } + + StyledButton { + text: "Rename" + Layout.preferredWidth: 100 + enabled: renameInput.text.trim().length > 0 && renameInput.text !== chatSelector.currentText + onClicked: { + let oldName = chatSelector.currentText; + let newName = renameInput.text.trim(); + let oldPath = FileUtils.trimFileProtocol(Directories.config) + "/zenith/chats/" + oldName + ".txt"; + let newPath = FileUtils.trimFileProtocol(Directories.config) + "/zenith/chats/" + newName + ".txt"; + FileUtils.renameFile(oldPath, newPath, function(success) { + if (success) { + chatListModel.set(chatSelector.currentIndex, { + "name": newName + }); + Zenith.currentChat = newName; + renameDialog.close(); + } + }); + } + } + + } + + } + + background: StyledRect { + color: Appearance.m3colors.m3surfaceContainer + radius: Metrics.radius("normal") + border.color: Appearance.colors.colOutline + border.width: 1 + } + + header: StyledRect { + color: Appearance.m3colors.m3surfaceContainer + radius: Metrics.radius("normal") + border.color: Appearance.colors.colOutline + border.width: 1 + } + + } + + StyledText { + text: "Thinking…" + visible: Zenith.loading + color: Appearance.colors.colSubtext + font.pixelSize: Metrics.fontSize(14) + + anchors { + left: parent.left + bottom: parent.bottom + leftMargin: Metrics.margin(22) + bottomMargin: Metrics.margin(76) + } + + } + + } + + Connections { + // only auto-select once + function onChatsListed(text) { + let lines = text.split(/\r?\n/); + let previousChat = Zenith.currentChat; + updateChatsList(lines); + // select & load once + if (!chatsInitialized) { + chatsInitialized = true; + let index = -1; + for (let i = 0; i < chatListModel.count; i++) { + if (chatListModel.get(i).name === previousChat) { + index = i; + break; + } + } + if (index === -1 && chatListModel.count > 0) + index = 0; + + if (index !== -1) { + chatSelector.currentIndex = index; + Zenith.currentChat = chatListModel.get(index).name; + loadChatHistory(Zenith.currentChat); + } + return ; + } + // AFTER init: only react if current chat vanished + let stillExists = false; + for (let i = 0; i < chatListModel.count; i++) { + if (chatListModel.get(i).name === Zenith.currentChat) { + stillExists = true; + break; + } + } + if (!stillExists && chatListModel.count > 0) { + chatSelector.currentIndex = 0; + Zenith.currentChat = chatListModel.get(0).name; + loadChatHistory(Zenith.currentChat); + } + } + + function onAiReply(text) { + appendMessage("AI", text.slice(5)); + Zenith.loading = false; + } + + function onChatLoaded(text) { + let lines = text.split(/\r?\n/); + let batch = []; + for (let l of lines) { + let line = l.trim(); + if (!line.length) + continue; + + let u = line.match(/^\[\d{4}-.*\] User: (.*)$/); + let a = line.match(/^\[\d{4}-.*\] AI: (.*)$/); + if (u) + batch.push({ + "sender": "You", + "message": u[1] + }); + else if (a) + batch.push({ + "sender": "AI", + "message": a[1] + }); + else if (batch.length) + batch[batch.length - 1].message += "\n" + line; + } + messageModel.clear(); + for (let m of batch) messageModel.append(m) + scrollToBottom(); + } + + target: Zenith + } + +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/launcher/AppItem.qml b/.config/quickshell/nucleus-shell/modules/interface/launcher/AppItem.qml new file mode 100644 index 0000000..3700654 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/launcher/AppItem.qml @@ -0,0 +1,122 @@ +import Quickshell +import Quickshell.Io +import Quickshell.Widgets + +import QtQuick +import QtQuick.Layouts +import QtQuick.Effects +import QtQuick.Controls + +import qs.config +import qs.modules.components +import qs.modules.functions + +StyledRect { + id: root + property bool hovered: false + property bool selected: false + + required property int parentWidth + + width: parentWidth + height: 50 + color: { + if (selected || hovered) + return Appearance.m3colors.m3surfaceContainerHigh + else + return Appearance.m3colors.m3surface + } + radius: Metrics.radius(15) + + Behavior on color { + PropertyAnimation { + duration: Metrics.chronoDuration(200) + easing.type: Easing.InSine + } + } + + ClippingWrapperRectangle { + id: entryIcon + anchors.left: parent.left + anchors.leftMargin: Metrics.margin(10) + + anchors.top: parent.top + anchors.topMargin: (parent.height / 2) - (size / 2) + + property int size: 25 + height: size + width: size + radius: Metrics.radius(1000) + + color: "transparent" + + child: Image { + source: Quickshell.iconPath(modelData.icon, "application-x-executable") + layer.enabled: true + layer.effect: MultiEffect { // Tint if needed, ngl this looks fucking cool when you use monochrome + saturation: (Config.runtime.appearance.tintIcons || (Config.runtime.appearance.colors.matugenScheme === "scheme-monochrome" && Config.runtime.appearance.colors.autogenerated) || Config.runtime.appearance.colors.scheme.toLowerCase() === "monochrome") ? -1.0 : 1.0 + } + } + } + + ColumnLayout { + anchors.left: entryIcon.right + anchors.leftMargin: Metrics.margin(10) + anchors.top: parent.top + anchors.topMargin: (parent.height / 2) - (height / 2) + + height: 40 + spacing: Metrics.spacing(-5) + + StyledText { + font.weight: 400 + text: modelData.name + font.pixelSize: Metrics.fontSize(14) + color: { + if (root.hovered || root.selected) + return Appearance.m3colors.m3onSurface + else + return Appearance.colors.colOutline + } + + Behavior on color { + PropertyAnimation { + duration: Metrics.chronoDuration(200) + easing.type: Easing.InSine + } + } + } + + StyledText { + font.weight: 400 + text: StringUtils.shortText(modelData.comment, 65) // Limit maximum chars to 65 + font.pixelSize: Metrics.fontSize(12) + color: { + if (root.hovered || root.selected) + return Qt.alpha(Appearance.m3colors.m3onSurface, 0.7) + else + return Qt.alpha(Appearance.colors.colOutline, 0.7) + } + + Behavior on color { + PropertyAnimation { + duration: Metrics.chronoDuration(200) + easing.type: Easing.InSine + } + } + } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + + onEntered: root.hovered = true + onExited: root.hovered = false + onClicked: { + modelData.execute() + IPCLoader.toggleLauncher() + } + } +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/launcher/Launcher.qml b/.config/quickshell/nucleus-shell/modules/interface/launcher/Launcher.qml new file mode 100644 index 0000000..f692af0 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/launcher/Launcher.qml @@ -0,0 +1,170 @@ +import Quickshell +import Quickshell.Io +import Quickshell.Widgets +import Quickshell.Wayland + +import QtQuick +import QtQuick.Layouts +import QtQuick.Effects +import QtQuick.Controls + +import qs.modules.components +import qs.modules.functions +import qs.config +import qs.services + +PanelWindow { + id: launcherWindow + + readonly property bool launcherOpen: Globals.visiblility.launcher + + visible: launcherOpen + focusable: true + aboveWindows: true // btw I never knew this was a property (read docs) + color: "transparent" + + anchors { + top: true + bottom: true + left: true + right: true + } + + exclusionMode: ExclusionMode.Ignore // why this? idk but it works atleast + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + + ScrollView { + id: maskId + + implicitHeight: DisplayMetrics.scaledHeight(0.623) + implicitWidth: DisplayMetrics.scaledWidth(0.3) + + anchors.top: parent.top + anchors.left: parent.left + anchors.leftMargin: (parent.width / 2) - (implicitWidth / 2) + anchors.topMargin: (parent.height / 2) - (implicitHeight / 2) + + clip: true + focus: true + + Rectangle { + id: launcher + property string currentSearch: "" + property int entryIndex: 0 + property list appList: Apps.list + + Connections { + target: launcherWindow + function onLauncherOpenChanged() { + if (!launcherWindow.launcherOpen) { + launcher.currentSearch = "" + launcher.entryIndex = 0 + launcher.appList = Apps.list + } + } + } + + anchors.fill: parent + color: Appearance.m3colors.m3surface + radius: Metrics.radius(21) + + StyledRect { + id: searchBox + anchors.top: parent.top + anchors.topMargin: Metrics.margin(10) + + color: Appearance.m3colors.m3surfaceContainerLow + width: parent.width - 20 + anchors.left: parent.left + anchors.leftMargin: (parent.width / 2) - (width / 2) + height: 45 + radius: Metrics.radius(15) + z: 2 + + focus: true + + Keys.onDownPressed: launcher.entryIndex += 1 + Keys.onUpPressed: { + if (launcher.entryIndex != 0) + launcher.entryIndex -= 1 + } + Keys.onEscapePressed: Globals.visiblility.launcher = false + + Keys.onPressed: event => { + if (event.key === Qt.Key_Enter || event.key === Qt.Key_Return) { + launcher.appList[launcher.entryIndex].execute() + Globals.visiblility.launcher = false + } else if (event.key === Qt.Key_Backspace) { + launcher.currentSearch = launcher.currentSearch.slice(0, -1) + } else if (" abcdefghijklmnopqrstuvwxyz1234567890`~!@#$%^&*()-_=+[{]}\\|;:'\",<.>/?".includes(event.text.toLowerCase())) { + launcher.currentSearch += event.text + } + + launcher.appList = Apps.fuzzyQuery(launcher.currentSearch) + launcher.entryIndex = 0 + } + + MaterialSymbol { + id: iconText + anchors.left: parent.left + anchors.leftMargin: Metrics.margin(10) + icon: "search" + font.pixelSize: Metrics.fontSize(14) + font.weight: 600 + anchors.top: parent.top + anchors.topMargin: (parent.height / 2) - ((font.pixelSize + 5) / 2) + opacity: 0.8 + } + + StyledText { + id: placeHolderText + anchors.left: iconText.right + anchors.leftMargin: Metrics.margin(10) + color: (launcher.currentSearch != "") ? Appearance.m3colors.m3onSurface : Appearance.colors.colOutline + text: (launcher.currentSearch != "") ? launcher.currentSearch : "Start typing to search ..." + font.pixelSize: Metrics.fontSize(13) + anchors.top: parent.top + anchors.topMargin: (parent.height / 2) - ((font.pixelSize + 5) / 2) + animate: false + opacity: 0.8 + } + } + + ScrollView { + anchors.top: searchBox.bottom + anchors.topMargin: Metrics.margin(10) + + anchors.left: parent.left + anchors.leftMargin: (parent.width / 2) - (width / 2) + width: parent.width - 20 + height: parent.height - searchBox.height - 20 + + ListView { + id: appList + anchors.fill: parent + spacing: Metrics.spacing(10) + anchors.bottomMargin: Metrics.margin(4) + + model: launcher.appList + currentIndex: launcher.entryIndex + + delegate: AppItem { + required property int index + required property DesktopEntry modelData + selected: index === launcher.entryIndex + parentWidth: appList.width + } + } + } + } + } + + IpcHandler { + function toggle() { + Globals.visiblility.launcher = !Globals.visiblility.launcher; + } + + target: "launcher" + } + +} \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/modules/interface/launcher/LauncherContent.qml b/.config/quickshell/nucleus-shell/modules/interface/launcher/LauncherContent.qml new file mode 100644 index 0000000..e85d42b --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/launcher/LauncherContent.qml @@ -0,0 +1,313 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import qs.config +import qs.modules.functions +import qs.modules.components +import qs.services + +/* + +This LauncherContent has been depricated. +And yet not used. (4/3/26) + +*/ + +Item { + id: content + + property int selectedIndex: -1 + property string searchQuery: "" + property var calcVars: ({}) + + property alias listView: listView + property alias filteredModel: filteredModel + + function launchCurrent() { + launchApp(listView.currentIndex) + } + + function webSearchUrl(query) { + const engine = (Config.runtime.launcher.webSearchEngine || "").toLowerCase() + if (engine.startsWith("http")) + return engine.replace("%s", encodeURIComponent(query)) + + const engines = { + "google": "https://www.google.com/search?q=%s", + "duckduckgo": "https://duckduckgo.com/?q=%s", + "brave": "https://search.brave.com/search?q=%s", + "bing": "https://www.bing.com/search?q=%s", + "startpage": "https://www.startpage.com/search?q=%s" + } + const template = engines[engine] || engines["duckduckgo"] + return template.replace("%s", encodeURIComponent(query)) + } + + function moveSelection(delta) { + if (filteredModel.count === 0) return + + selectedIndex = Math.max(0, Math.min(selectedIndex + delta, filteredModel.count - 1)) + listView.currentIndex = selectedIndex + listView.positionViewAtIndex(selectedIndex, ListView.Contain) + } + + function fuzzyMatch(text, pattern) { + text = text.toLowerCase() + pattern = pattern.toLowerCase() + let ti = 0, pi = 0 + while (ti < text.length && pi < pattern.length) { + if (text[ti] === pattern[pi]) pi++ + ti++ + } + return pi === pattern.length + } + + function evalExpression(expr) { + try { + const fn = new Function("vars", ` + with (vars) { with (Math) { return (${expr}); } } + `) + const res = fn(calcVars) + if (res === undefined || Number.isNaN(res)) return null + return res + } catch (e) { + return null + } + } + + function updateFilter() { + filteredModel.clear() + const query = searchQuery.toLowerCase().trim() + + const calcVal = evalExpression(query) + if (calcVal !== null && query !== "") { + filteredModel.append({ + name: String(calcVal), + displayName: String(calcVal), + comment: "Calculation", + icon: "", + exec: "", + isCalc: true, + isWeb: false + }) + } + + const sourceApps = AppRegistry.apps + + if (query === "") { + for (let app of sourceApps) { + filteredModel.append({ + name: app.name, + displayName: app.name, + comment: app.comment, + icon: AppRegistry.iconForDesktopIcon(app.icon), + exec: app.exec, + isCalc: false, + isWeb: false + }) + } + selectedIndex = filteredModel.count > 0 ? 0 : -1 + listView.currentIndex = selectedIndex + return + } + + let exactMatches = [] + let startsWithMatches = [] + let containsMatches = [] + let fuzzyMatches = [] + + for (let app of sourceApps) { + const name = app.name ? app.name.toLowerCase() : "" + const comment = app.comment ? app.comment.toLowerCase() : "" + + if (name === query) exactMatches.push(app) + else if (name.startsWith(query)) startsWithMatches.push(app) + else if (name.includes(query) || comment.includes(query)) containsMatches.push(app) + else if (Config.runtime.launcher.fuzzySearchEnabled && fuzzyMatch(name, query)) fuzzyMatches.push(app) + } + + const sortedResults = [ + ...exactMatches, + ...startsWithMatches, + ...containsMatches, + ...fuzzyMatches + ] + + for (let app of sortedResults) { + filteredModel.append({ + name: app.name, + displayName: app.name, + comment: app.comment, + icon: AppRegistry.iconForDesktopIcon(app.icon), + exec: app.exec, + isCalc: false, + isWeb: false + }) + } + + if (filteredModel.count === 0 && query !== "") { + filteredModel.append({ + name: query, + displayName: "Search the web for \"" + query + "\"", + comment: "Web search", + icon: "public", + exec: webSearchUrl(query), + isCalc: false, + isWeb: true + }) + } + + selectedIndex = filteredModel.count > 0 ? 0 : -1 + listView.currentIndex = selectedIndex + listView.positionViewAtBeginning() + } + + function launchApp(idx) { + if (idx < 0 || idx >= filteredModel.count) return + + const app = filteredModel.get(idx) + if (app.isCalc) return + if (app.isWeb) + Quickshell.execDetached(["xdg-open", app.exec]) + else + Quickshell.execDetached(["bash", "-c", app.exec + " &"]) + + closeLauncher() + } + + function closeLauncher() { + Globals.visiblility.launcher = false + } + + function resetSearch() { + searchQuery = "" + updateFilter() + selectedIndex = -1 + listView.currentIndex = -1 + } + + Connections { + target: AppRegistry + function onReady() { + updateFilter() + } + } + + anchors.fill: parent + opacity: Globals.visiblility.launcher ? 1 : 0 + anchors.margins: Metrics.margin(10) + + ListModel { id: filteredModel } + + ColumnLayout { + anchors.fill: parent + anchors.margins: Metrics.margin(16) + spacing: Metrics.spacing(12) + + ScrollView { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + + ListView { + id: listView + model: filteredModel + spacing: Metrics.spacing(8) + clip: true + boundsBehavior: Flickable.StopAtBounds + highlightRangeMode: ListView.StrictlyEnforceRange + preferredHighlightBegin: 0 + preferredHighlightEnd: height + highlightMoveDuration: 120 + currentIndex: selectedIndex + + delegate: Rectangle { + property bool isSelected: listView.currentIndex === index + + width: listView.width + height: 60 + radius: Appearance.rounding.normal + color: isSelected ? Appearance.m3colors.m3surfaceContainerHighest : "transparent" + + Row { + anchors.fill: parent + anchors.margins: Metrics.margin(10) + spacing: Metrics.spacing(12) + + Item { + width: 32 + height: 32 + + Image { + anchors.fill: parent + visible: !model.isCalc && !model.isWeb + smooth: true + mipmap: true + antialiasing: true + fillMode: Image.PreserveAspectFit + sourceSize.width: 128 + sourceSize.height: 128 + source: model.icon + } + + MaterialSymbol { + anchors.centerIn: parent + visible: model.isCalc + icon: "calculate" + iconSize: Metrics.iconSize(28) + color: Appearance.m3colors.m3onSurfaceVariant + } + + MaterialSymbol { + anchors.centerIn: parent + visible: model.isWeb + icon: "public" + iconSize: Metrics.iconSize(28) + color: Appearance.m3colors.m3onSurfaceVariant + } + } + + Column { + anchors.verticalCenter: parent.verticalCenter + width: listView.width - 120 + spacing: Metrics.spacing(4) + + Text { + text: model.displayName + font.pixelSize: Metrics.fontSize(14) + font.bold: true + elide: Text.ElideRight + color: Appearance.m3colors.m3onSurface + } + + Text { + text: model.comment + font.pixelSize: Metrics.fontSize(11) + elide: Text.ElideRight + color: Appearance.m3colors.m3onSurfaceVariant + } + } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: launchApp(index) + onEntered: listView.currentIndex = index + } + } + } + } + } + + Behavior on opacity { + enabled: Config.runtime.appearance.animations.enabled + NumberAnimation { + duration: Metrics.chronoDuration(400) + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.animation.curves.standard + } + } +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/lockscreen/LockContext.qml b/.config/quickshell/nucleus-shell/modules/interface/lockscreen/LockContext.qml new file mode 100644 index 0000000..802f3fe --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/lockscreen/LockContext.qml @@ -0,0 +1,56 @@ +import QtQuick +import Quickshell +import Quickshell.Services.Pam + +// I just copied the default example and modified it. lol + +Scope { + id: root + signal unlocked() + signal failed() + + // These properties are in the context and not individual lock surfaces + // so all surfaces can share the same state. + property string currentText: "" + property bool unlockInProgress: false + property bool showFailure: false + + // Clear the failure text once the user starts typing. + onCurrentTextChanged: showFailure = false; + + function tryUnlock() { + if (currentText === "") return; + + root.unlockInProgress = true; + pam.start(); + } + + PamContext { + id: pam + + // Its best to have a custom pam config for quickshell, as the system one + // might not be what your interface expects, and break in some way. + // This particular example only supports passwords. + configDirectory: "pam" + config: "password.conf" + + // pam_unix will ask for a response for the password prompt + onPamMessage: { + if (this.responseRequired) { + this.respond(root.currentText); + } + } + + // pam_unix won't send any important messages so all we need is the completion status. + onCompleted: result => { + if (result == PamResult.Success) { + root.unlocked(); + } else { + root.currentText = ""; + root.showFailure = true; + } + + root.unlockInProgress = false; + } + } +} \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/modules/interface/lockscreen/LockScreen.qml b/.config/quickshell/nucleus-shell/modules/interface/lockscreen/LockScreen.qml new file mode 100644 index 0000000..f48340d --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/lockscreen/LockScreen.qml @@ -0,0 +1,41 @@ +import Quickshell +import Quickshell.Io +import Quickshell.Wayland + +Scope { + // This stores all the information shared between the lock surfaces on each screen. + LockContext { + id: lockContext + + onUnlocked: { + // Unlock the screen before exiting, or the compositor will display a + // fallback lock you can't interact with. + lock.locked = false; + + } + } + + WlSessionLock { + id: lock + + // Lock the session immediately when quickshell starts. + locked: false + + WlSessionLockSurface { + LockSurface { + anchors.fill: parent + context: lockContext + } + } + } + + IpcHandler { + target: "lockscreen" + function lock() { + lock.locked = true; + } + function unlock() { + lock.locked = false; + } + } +} \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/modules/interface/lockscreen/LockSurface.qml b/.config/quickshell/nucleus-shell/modules/interface/lockscreen/LockSurface.qml new file mode 100644 index 0000000..8c28aa5 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/lockscreen/LockSurface.qml @@ -0,0 +1,267 @@ +import "../../components/morphedPolygons/geometry/offset.js" as Offset +import "../../components/morphedPolygons/material-shapes.js" as MaterialShapes // For polygons +import "../../components/morphedPolygons/shapes/corner-rounding.js" as CornerRounding +import QtQuick +import QtQuick.Controls.Fusion +import QtQuick.Layouts +import Quickshell.Wayland +import qs.config +import qs.modules.functions +import qs.modules.interface.background +import qs.modules.components +import qs.modules.components.morphedPolygons +import qs.services + +Rectangle { + id: root + + required property LockContext context + + color: "transparent" + + Image { + anchors.fill: parent + z: -1 + source: Config.runtime.appearance.background.path + } + + RowLayout { + spacing: Metrics.spacing(20) + + anchors { + top: parent.top + right: parent.right + topMargin: Metrics.spacing(20) + rightMargin: Metrics.spacing(30) + } + + MaterialSymbol { + id: themeIcon + + fill: 1 + icon: Config.runtime.appearance.theme === "light" ? "light_mode" : "dark_mode" + iconSize: Metrics.fontSize("hugeass") + } + + MaterialSymbol { + id: wifi + + icon: Network.icon + iconSize: Metrics.fontSize("hugeass") + } + + MaterialSymbol { + id: btIcon + + icon: Bluetooth.icon + iconSize: Metrics.fontSize("hugeass") + } + + StyledText { + id: keyboardLayoutIcon + + text: SystemDetails.keyboardLayout + font.pixelSize: Metrics.fontSize(Appearance.font.size.huge - 4) + } + + } + + ColumnLayout { + + anchors { + horizontalCenter: parent.horizontalCenter + top: parent.top + topMargin: Metrics.margin(150) + } + + StyledText { + id: clock + + visible: !Config.runtime.appearance.background.clock.isAnalog + Layout.alignment: Qt.AlignBottom + animate: false + renderType: Text.NativeRendering + font.pixelSize: Metrics.fontSize(180) + text: Time.format("hh:mm") + } + + StyledText { + id: date + + visible: !Config.runtime.appearance.background.clock.isAnalog + Layout.alignment: Qt.AlignCenter + animate: false + renderType: Text.NativeRendering + font.pixelSize: Metrics.fontSize(50) + text: Time.format("dddd, dd/MM") + } + + Item { + id: analogClockContainer + + property int hours: parseInt(Time.format("hh")) + property int minutes: parseInt(Time.format("mm")) + property int seconds: parseInt(Time.format("ss")) + readonly property real cx: width / 2 + readonly property real cy: height / 2 + property var shapes: [MaterialShapes.getCookie7Sided, MaterialShapes.getCookie9Sided, MaterialShapes.getCookie12Sided, MaterialShapes.getPixelCircle, MaterialShapes.getCircle, MaterialShapes.getGhostish] + + visible: Config.runtime.appearance.background.clock.isAnalog + width: 350 + height: 350 + + // Polygon + MorphedPolygon { + id: shapeCanvas + + anchors.fill: parent + color: Appearance.m3colors.m3secondaryContainer + roundedPolygon: analogClockContainer.shapes[Config.runtime.appearance.background.clock.shape]() + } + + ClockDial { + anchors.fill: parent + anchors.margins: parent.width * 0.12 + color: Appearance.colors.colOnSecondaryContainer + z: 0 + } + + // Hour hand + StyledRect { + z: 2 + width: 10 + height: parent.height * 0.3 + radius: Metrics.radius("full") + color: Qt.darker(Appearance.m3colors.m3secondary, 0.8) + x: analogClockContainer.cx - width / 2 + y: analogClockContainer.cy - height + transformOrigin: Item.Bottom + rotation: (analogClockContainer.hours % 12 + analogClockContainer.minutes / 60) * 30 + } + + StyledRect { + anchors.centerIn: parent + width: 16 + height: 16 + radius: width / 2 + color: Appearance.m3colors.m3secondary + z: 99 // Ensures its on top of everthing + + // Inner dot + StyledRect { + width: parent.width / 2 + height: parent.height / 2 + radius: width / 2 + anchors.centerIn: parent + z: 100 + color: Appearance.m3colors.m3primaryContainer + } + + } + + // Minute hand + StyledRect { + width: 14 + height: parent.height * 0.35 + radius: Metrics.radius("full") + color: Appearance.m3colors.m3secondary + x: analogClockContainer.cx - width / 2 + y: analogClockContainer.cy - height + transformOrigin: Item.Bottom + rotation: analogClockContainer.minutes * 6 + z: 10 // On top of all hands + } + + // Second hand + StyledRect { + visible: true + width: 4 + height: parent.height * 0.28 + radius: Metrics.radius("full") + color: Appearance.m3colors.m3error + x: analogClockContainer.cx - width / 2 + y: analogClockContainer.cy - height + transformOrigin: Item.Bottom + rotation: analogClockContainer.seconds * 6 + z: 2 + } + + StyledText { + text: Time.format("hh") + anchors.top: parent.top + anchors.horizontalCenter: parent.horizontalCenter + anchors.topMargin: Metrics.margin(60) + font.pixelSize: Metrics.fontSize(100) + font.bold: true + opacity: 0.3 + animate: false + } + + StyledText { + text: Time.format("mm") + anchors.top: parent.top + anchors.horizontalCenter: parent.horizontalCenter + anchors.topMargin: Metrics.margin(150) + font.pixelSize: Metrics.fontSize(100) + font.bold: true + opacity: 0.3 + animate: false + } + + } + + } + + ColumnLayout { + // Commenting this will make the password entry visible on all monitors. + visible: Window.active + + anchors { + horizontalCenter: parent.horizontalCenter + bottom: parent.bottom + bottomMargin: Metrics.margin(20) + } + + RowLayout { + StyledTextField { + id: passwordBox + + implicitWidth: 300 + padding: Metrics.padding(10) + placeholder: root.context.showFailure ? "Incorrect Password" : "Enter Password" + focus: true + enabled: !root.context.unlockInProgress + echoMode: TextInput.Password + inputMethodHints: Qt.ImhSensitiveData + // Update the text in the context when the text in the box changes. + onTextChanged: root.context.currentText = this.text + // Try to unlock when enter is pressed. + onAccepted: root.context.tryUnlock() + + // Update the text in the box to match the text in the context. + // This makes sure multiple monitors have the same text. + Connections { + function onCurrentTextChanged() { + passwordBox.text = root.context.currentText; + } + + target: root.context + } + + } + + StyledButton { + icon: "chevron_right" + padding: Metrics.padding(10) + radius: Metrics.radius("unsharpenmore") + // don't steal focus from the text box + focusPolicy: Qt.NoFocus + enabled: !root.context.unlockInProgress && root.context.currentText !== "" + onClicked: root.context.tryUnlock() + } + + } + + } + +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/lockscreen/pam/password.conf b/.config/quickshell/nucleus-shell/modules/interface/lockscreen/pam/password.conf new file mode 100644 index 0000000..7b313c8 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/lockscreen/pam/password.conf @@ -0,0 +1 @@ +auth required pam_unix.so \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/modules/interface/notifications/NotificationChild.qml b/.config/quickshell/nucleus-shell/modules/interface/notifications/NotificationChild.qml new file mode 100644 index 0000000..f64603a --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/notifications/NotificationChild.qml @@ -0,0 +1,144 @@ +import qs.config +import qs.modules.components +import qs.services +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Quickshell.Wayland + +Rectangle { + id: root + + property bool startAnim: false + property string title: "No Title" + property string body: "No content" + property var rawNotif: null + property bool tracked: false + property string image: "" + property var buttons: [ + { label: "Okay!", onClick: () => console.log("Okay") } + ] + + opacity: tracked ? 1 : (startAnim ? 1 : 0) + Behavior on opacity { + enabled: Config.runtime.appearance.animations.enabled + NumberAnimation { + duration: Metrics.chronoDuration("small") + easing.type: Easing.InOutExpo + } + } + + Layout.fillWidth: true + radius: Metrics.radius("large") + + property bool hovered: mouseHandler.containsMouse + property bool clicked: mouseHandler.containsPress + color: hovered ? (clicked ? Appearance.m3colors.m3surfaceContainerHigh : Appearance.m3colors.m3surfaceContainerLow) : Appearance.m3colors.m3surface + Behavior on color { + enabled: Config.runtime.appearance.animations.enabled + ColorAnimation { + duration: Metrics.chronoDuration("small") + easing.type: Easing.InOutExpo + } + } + implicitHeight: Math.max(content.implicitHeight + 30, 80) + + RowLayout { + id: content + anchors.fill: parent + anchors.margins: Metrics.margin(10) + spacing: Metrics.spacing(10) + + ClippingRectangle { + width: 50 + height: 50 + radius: Metrics.radius("large") + clip: true + color: root.image === "" ? Appearance.m3colors.m3surfaceContainer : "transparent" + Image { + anchors.fill: parent + source: root.image + fillMode: Image.PreserveAspectCrop + smooth: true + } + MaterialSymbol { + icon: "chat" + color: Appearance.m3colors.m3onSurfaceVariant + anchors.centerIn: parent + visible: root.image === "" + iconSize: Metrics.iconSize(22) + } + } + + ColumnLayout { + StyledText { + text: root.title + font.bold: true + font.pixelSize: Metrics.fontSize(18) + wrapMode: Text.Wrap + color: Appearance.m3colors.m3onSurface + Layout.fillWidth: true + } + + StyledText { + text: root.body.length > 123 ? root.body.substr(0, 120) + "..." : root.body + visible: root.body.length > 0 + font.pixelSize: Metrics.fontSize(12) + color: Appearance.m3colors.m3onSurfaceVariant + wrapMode: Text.Wrap + Layout.fillWidth: true + } + + RowLayout { + visible: root.buttons.length > 1 + Layout.preferredHeight: 40 + Layout.fillWidth: true + spacing: Metrics.spacing(10) + + Repeater { + model: buttons + + StyledButton { + Layout.fillWidth: true + implicitHeight: 30 + implicitWidth: 0 + text: modelData.label + base_bg: index !== 0 + ? Appearance.m3colors.m3secondaryContainer + : Appearance.m3colors.m3primary + + base_fg: index !== 0 + ? Appearance.m3colors.m3onSecondaryContainer + : Appearance.m3colors.m3onPrimary + onClicked: modelData.onClick() + } + } + } + } + } + MouseArea { + id: mouseHandler + anchors.fill: parent + hoverEnabled: true + visible: root.buttons.length === 0 || root.buttons.length === 1 + cursorShape: Qt.PointingHandCursor + onClicked: { + if (root.buttons.length === 1 && root.buttons[0].onClick) { + root.buttons[0].onClick() + root.rawNotif?.notification.dismiss() + } else if (root.buttons.length === 0) { + console.log("[Notification] Dismissed a notification with no action.") + root.rawNotif.notification.tracked = false + root.rawNotif.popup = false + root.rawNotif?.notification.dismiss() + } else { + console.log("[Notification] Dismissed a notification with multiple actions.") + root.rawNotif?.notification.dismiss() + } + } + } + Component.onCompleted: { + startAnim = true + } +} \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/modules/interface/notifications/Notifications.qml b/.config/quickshell/nucleus-shell/modules/interface/notifications/Notifications.qml new file mode 100644 index 0000000..d39d3cb --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/notifications/Notifications.qml @@ -0,0 +1,154 @@ +import QtQuick +import QtQuick.Effects +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.Notifications +import Quickshell.Wayland +import Quickshell.Widgets +import qs.services +import qs.config +import qs.modules.components + +Scope { + id: root + + property int innerSpacing: Metrics.spacing(10) + + PanelWindow { + id: window + + implicitWidth: 520 + visible: true + color: "transparent" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.exclusionMode: ExclusionMode.Normal + WlrLayershell.namespace: "nucleus:notification" + + anchors { + top: true + left: Config.runtime.notifications.position.endsWith("left") + bottom: true + right: Config.runtime.notifications.position.endsWith("right") + } + + Item { + id: notificationList + + anchors.leftMargin: 0 + anchors.topMargin: Metrics.margin(10) + anchors.rightMargin: 0 + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + + Rectangle { + id: bgRectangle + + layer.enabled: true + anchors.top: parent.top + anchors.left: parent.left + anchors.leftMargin: Metrics.margin(20) + anchors.rightMargin: Metrics.margin(20) + anchors.right: parent.right + height: window.mask.height > 0 ? window.mask.height + 40 : 0 + color: Appearance.m3colors.m3background + radius: Metrics.radius("large") + + layer.effect: MultiEffect { + shadowEnabled: true + shadowOpacity: 1 + shadowColor: Appearance.m3colors.m3shadow + shadowBlur: 1 + shadowScale: 1 + } + + Behavior on height { + enabled: Config.runtime.appearance.animations.enabled + NumberAnimation { + duration: Metrics.chronoDuration("small") + easing.type: Easing.InOutExpo + } + + } + + } + + Item { + id: notificationColumn + + anchors.left: parent.left + anchors.right: parent.right + + Repeater { + id: rep + + model: (!Config.runtime.notifications.doNotDisturb && Config.runtime.notifications.enabled) ? NotifServer.popups : [] + + NotificationChild { + id: child + + width: notificationColumn.width - 80 + anchors.horizontalCenter: notificationColumn.horizontalCenter + y: { + var pos = 0; + for (let i = 0; i < index; i++) { + var prev = rep.itemAt(i); + if (prev) + pos += prev.height + root.innerSpacing; + + } + return pos + 20; + } + Component.onCompleted: { + if (!modelData.shown) + modelData.shown = true; + + } + title: modelData.summary + body: modelData.body + image: modelData.image || modelData.appIcon + rawNotif: modelData + tracked: modelData.shown + buttons: modelData.actions.map((action) => { + return ({ + "label": action.text, + "onClick": () => { + return action.invoke(); + } + }); + }) + + Behavior on y { + enabled: Config.runtime.appearance.animations.enabled + NumberAnimation { + duration: Metrics.chronoDuration("normal") + easing.type: Easing.InOutExpo + } + + } + + } + + } + + } + + } + + mask: Region { + width: window.width + height: { + var total = 0; + for (let i = 0; i < rep.count; i++) { + var child = rep.itemAt(i); + if (child) + total += child.height + (i < rep.count - 1 ? root.innerSpacing : 0); + + } + return total; + } + } + + } + +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/overlays/BrightnessOverlay.qml b/.config/quickshell/nucleus-shell/modules/interface/overlays/BrightnessOverlay.qml new file mode 100644 index 0000000..21c2092 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/overlays/BrightnessOverlay.qml @@ -0,0 +1,99 @@ +import qs.config +import qs.modules.components +import qs.services +import QtQuick +import QtQuick.Layouts +import Quickshell.Wayland +import Quickshell +import Quickshell.Widgets + +Scope { + id: root + + Connections { + target: Brightness + + function onBrightnessChanged() { + root.shouldShowOsd = true; + hideTimer.restart(); + } + } + + property var monitor: Brightness.monitors.length > 0 ? Brightness.monitors[0] : null + + property bool shouldShowOsd: false + + Timer { + id: hideTimer + interval: 3000 + onTriggered: root.shouldShowOsd = false + } + + LazyLoader { + active: root.shouldShowOsd + + PanelWindow { + visible: Config.runtime.overlays.brightnessOverlayEnabled && Config.runtime.overlays.enabled + WlrLayershell.namespace: "nucleus:brightnessOsd" + exclusiveZone: 0 + anchors.top: Config.runtime.overlays.brightnessOverlayPosition.startsWith("top") + anchors.bottom: Config.runtime.overlays.brightnessOverlayPosition.startsWith("bottom") + anchors.right: Config.runtime.overlays.brightnessOverlayPosition.endsWith("right") + anchors.left: Config.runtime.overlays.brightnessOverlayPosition.endsWith("left") + margins { + top: Metrics.margin(10) + bottom: Metrics.margin(10) + left: Metrics.margin(10) + right: Metrics.margin(10) + } + + implicitWidth: 460 + implicitHeight: 105 + color: "transparent" + mask: Region {} + + Rectangle { + anchors.fill: parent + radius: Appearance.rounding.childish + color: Appearance.m3colors.m3background + + RowLayout { + spacing: Metrics.spacing(10) + anchors { + fill: parent + leftMargin: Metrics.margin(15) + rightMargin: Metrics.margin(25) + } + + MaterialSymbol { + property real brightnessLevel: Math.floor(Brightness.getMonitorForScreen(Hyprland.focusedMonitor)?.multipliedBrightness*100) + icon: { + if (brightnessLevel > 66) return "brightness_high" + else if (brightnessLevel > 33) return "brightness_medium" + else return "brightness_low" + } + iconSize: Metrics.iconSize(30) + } + + ColumnLayout { + implicitHeight: 40 + spacing: Metrics.spacing(5) + + StyledText { + animate: false + text: "Brightness - " + Math.round(monitor.brightness * 100) + '%' + font.pixelSize: Metrics.fontSize(18) + } + + StyledSlider { + implicitHeight: 35 + from: 0 + to: 100 + value: Math.round(monitor.brightness * 100) + } + } + } + } + } + } +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/overlays/Overlays.qml b/.config/quickshell/nucleus-shell/modules/interface/overlays/Overlays.qml new file mode 100644 index 0000000..df4aa43 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/overlays/Overlays.qml @@ -0,0 +1,8 @@ +import QtQuick +import Quickshell + +Scope { + id: root + VolumeOverlay{} + BrightnessOverlay{} +} \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/modules/interface/overlays/VolumeOverlay.qml b/.config/quickshell/nucleus-shell/modules/interface/overlays/VolumeOverlay.qml new file mode 100644 index 0000000..32caef9 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/overlays/VolumeOverlay.qml @@ -0,0 +1,109 @@ +import qs.config +import qs.modules.components +import qs.services +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Wayland +import Quickshell.Services.Pipewire +import Quickshell.Widgets + +Scope { + id: root + + PwObjectTracker { + objects: [ Pipewire.defaultAudioSink ] + } + + Connections { + target: Pipewire.defaultAudioSink?.audio ?? null + + function onVolumeChanged() { + root.shouldShowOsd = true; + hideTimer.restart(); + } + + function onMutedChanged() { + root.shouldShowOsd = true; + hideTimer.restart(); + } + } + + + property bool shouldShowOsd: false + + Timer { + id: hideTimer + interval: 3000 + onTriggered: root.shouldShowOsd = false + } + + LazyLoader { + active: root.shouldShowOsd + + PanelWindow { + visible: Config.runtime.overlays.volumeOverlayEnabled && Config.runtime.overlays.enabled + WlrLayershell.namespace: "nucleus:brightnessOsd" + exclusiveZone: 0 + anchors.top: Config.runtime.overlays.volumeOverlayPosition.startsWith("top") + anchors.bottom: Config.runtime.overlays.volumeOverlayPosition.startsWith("bottom") + anchors.right: Config.runtime.overlays.volumeOverlayPosition.endsWith("right") + anchors.left: Config.runtime.overlays.volumeOverlayPosition.endsWith("left") + margins { + top: Metrics.margin(10) + bottom: Metrics.margin(10) + left: Metrics.margin(10) + right: Metrics.margin(10) + } + implicitWidth: 460 + implicitHeight: 105 + color: "transparent" + + mask: Region {} + + + Rectangle { + anchors.fill: parent + radius: Appearance.rounding.childish + color: Appearance.m3colors.m3background + + RowLayout { + spacing: Metrics.spacing(10) + anchors { + fill: parent + leftMargin: Metrics.margin(15) + rightMargin: Metrics.margin(25) + } + + MaterialSymbol { + property real volume: Pipewire.defaultAudioSink?.audio.muted ? 0 : Pipewire.defaultAudioSink?.audio.volume * 100 + icon: volume > 50 ? "volume_up" : volume > 0 ? "volume_down" : 'volume_off' + iconSize: Metrics.iconSize(34); + } + + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: Metrics.spacing(2) + + StyledText { + Layout.fillWidth: true + elide: Text.ElideRight + + animate: false + text: (Pipewire.defaultAudioSink?.description ?? "Unknown") + " - " + + (Pipewire.defaultAudioSink?.audio.muted ? 'Muted' : Math.floor(Pipewire.defaultAudioSink?.audio.volume * 100) + '%') + font.pixelSize: Metrics.fontSize(18) + } + + StyledSlider { + Layout.fillWidth: true + implicitHeight: 35 + value: (Pipewire.defaultAudioSink?.audio.muted ? 0 : Pipewire.defaultAudioSink?.audio.volume) * 100 + } + } + } + } + } + } +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/polkit/PolkitAgent.qml b/.config/quickshell/nucleus-shell/modules/interface/polkit/PolkitAgent.qml new file mode 100644 index 0000000..b9b8dd5 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/polkit/PolkitAgent.qml @@ -0,0 +1,207 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Effects + +import Quickshell +import Quickshell.Wayland + +import qs.config +import qs.services +import qs.modules.components + + +Scope { + id: root + property bool active: false + property var window: null + + Connections { + target: Polkit + function onIsActiveChanged() { + if (Polkit.isActive) { + root.active = true; + } else if (root.active && window) { + window.closeWithAnimation(); + } + } + } + + LazyLoader { + active: root.active + component: Prompt { + id: window + + Component.onCompleted: root.window = window + Component.onDestruction: root.window = null + + onFadeOutFinished: root.active = false + + Item { + id: promptContainer + property bool showPassword: false + property bool authenticating: false + + anchors.centerIn: parent + width: promptBg.width + height: promptBg.height + + Item { + Component.onCompleted: { + parent.layer.enabled = true; + parent.layer.effect = effectComponent; + } + + Component { + id: effectComponent + MultiEffect { + shadowEnabled: true + shadowOpacity: 1 + shadowColor: Appearance.colors.m3shadow + shadowBlur: 1 + shadowScale: 1 + } + } + } + + Rectangle { + id: promptBg + width: promptLayout.width + 40 + height: promptLayout.height + 40 + color: Appearance.m3colors.m3surface + radius: Metrics.radius(20) + + Behavior on height { + NumberAnimation { + duration: Metrics.chronoDuration("small") + easing.type: Appearance.animation.easing + } + } + } + + ColumnLayout { + id: promptLayout + spacing: Metrics.spacing(10) + anchors { + left: promptBg.left + leftMargin: Metrics.margin(20) + top: promptBg.top + topMargin: Metrics.margin(20) + } + + ColumnLayout { + spacing: Metrics.spacing(5) + MaterialSymbol { + icon: "security" + color: Appearance.m3colors.m3primary + font.pixelSize: Metrics.fontSize(22) + Layout.alignment: Qt.AlignHCenter + } + StyledText { + text: "Authentication required" + font.family: "Outfit SemiBold" + font.pixelSize: Metrics.fontSize(20) + Layout.alignment: Qt.AlignHCenter + } + StyledText { + text: Polkit.flow.message + Layout.alignment: Qt.AlignHCenter + } + } + + RowLayout { + spacing: Metrics.spacing(5) + StyledTextField { + id: textfield + Layout.fillWidth: true + leftPadding: undefined + padding: Metrics.padding(10) + filled: false + enabled: !promptContainer.authenticating + placeholder: Polkit.flow.inputPrompt.substring(0, Polkit.flow.inputPrompt.length - 2) + echoMode: promptContainer.showPassword ? TextInput.Normal : TextInput.Password + inputMethodHints: Qt.ImhSensitiveData + focus: true + Keys.onReturnPressed: okButton.clicked() + } + StyledButton { + Layout.fillHeight: true + width: height + radius: Metrics.radius(10) + topLeftRadius: Metrics.radius(5) + bottomLeftRadius: Metrics.radius(5) + enabled: !promptContainer.authenticating + checkable: true + checked: promptContainer.showPassword + icon: promptContainer.showPassword ? 'visibility' : 'visibility_off' + onToggled: promptContainer.showPassword = !promptContainer.showPassword + } + } + + + RowLayout { + RowLayout { + visible: Polkit.flow.failed && !Polkit.flow.isSuccessful && !promptContainer.authenticating + MaterialSymbol { + icon: "warning" + color: Appearance.m3colors.m3error + font.pixelSize: Metrics.fontSize(15) + } + StyledText { + text: "Failed to authenticate, incorrect password." + color: Appearance.m3colors.m3error + font.pixelSize: Metrics.fontSize(15) + } + } + LoadingIcon { + visible: promptContainer.authenticating + Layout.alignment: Qt.AlignLeft + } + Item { + Layout.fillWidth: true + } + StyledButton { + radius: Metrics.radius(10) + topRightRadius: Metrics.radius(5) + bottomRightRadius: Metrics.radius(5) + secondary: true + text: "Cancel" + // enabled: !promptContainer.authenticating (Allows to cancel if stuck in loop) + onClicked: Polkit.flow.cancelAuthenticationRequest() + } + StyledButton { + id: okButton + radius: Metrics.radius(10) + topLeftRadius: Metrics.radius(5) + bottomLeftRadius: Metrics.radius(5) + text: promptContainer.authenticating ? "Authenticating..." : "OK" + enabled: !promptContainer.authenticating + onClicked: { + promptContainer.authenticating = true; + Polkit.flow.submit(textfield.text); + } + } + } + } + + Connections { + target: Polkit.flow + function onIsCompletedChanged() { + if (Polkit.flow.isCompleted) { + promptContainer.authenticating = false; + } + } + function onFailedChanged() { + if (Polkit.flow.failed) { + promptContainer.authenticating = false; + } + } + function onIsCancelledChanged() { + if (Polkit.flow.isCancelled) { + promptContainer.authenticating = false; + } + } + } + } + } + } +} \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/modules/interface/polkit/Prompt.qml b/.config/quickshell/nucleus-shell/modules/interface/polkit/Prompt.qml new file mode 100644 index 0000000..da52a26 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/polkit/Prompt.qml @@ -0,0 +1,151 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Effects + +import Quickshell +import Quickshell.Wayland + +import qs.config +import qs.services +import qs.modules.components + +PanelWindow { + id: window + property bool isClosing: false + default property alias content: contentContainer.data + signal fadeOutFinished() + + anchors { + top: true + left: true + right: true + bottom: true + } + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.exclusionMode: ExclusionMode.Ignore + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + color: "transparent" + WlrLayershell.namespace: "nucleus:prompt" + + function closeWithAnimation() { + if (isClosing) return + isClosing = true + fadeOutAnim.start() + } + + Item { + anchors.fill: parent + + Keys.onPressed: { + if (event.key === Qt.Key_Escape) { + window.closeWithAnimation() + } + } + + ScreencopyView { + id: screencopy + visible: hasContent + captureSource: window.screen + anchors.fill: parent + opacity: 0 + scale: 1 + layer.enabled: true + layer.effect: MultiEffect { + blurEnabled: true + blur: 1 + blurMax: 32 + brightness: -0.05 + layer.enabled: true + layer.effect: MultiEffect { + autoPaddingEnabled: false + blurEnabled: true + blur: 1 + blurMax: 32 + } + } + } + + NumberAnimation { + id: fadeInAnim + target: screencopy + property: "opacity" + from: 0 + to: 1 + duration: Metrics.chronoDuration("normal") + easing.type: Appearance.animation.easing + running: screencopy.visible && !window.isClosing + } + + ParallelAnimation { + id: scaleInAnim + running: screencopy.visible && !window.isClosing + NumberAnimation { + target: contentContainer + property: "scale" + from: 0.9 + to: 1 + duration: Metrics.chronoDuration("normal") + easing.type: Appearance.animation.easing + } + ColorAnimation { + target: window + property: "color" + from: "transparent" + to: Appearance.m3colors.m3surface + duration: Metrics.chronoDuration("normal") + easing.type: Appearance.animation.easing + } + NumberAnimation { + target: contentContainer + property: "opacity" + from: 0 + to: 1 + duration: Metrics.chronoDuration("normal") + easing.type: Appearance.animation.easing + } + } + + ParallelAnimation { + id: fadeOutAnim + NumberAnimation { + target: screencopy + property: "opacity" + to: 0 + duration: Metrics.chronoDuration("normal") + easing.type: Appearance.animation.easing + } + ColorAnimation { + target: window + property: "color" + to: "transparent" + duration: Metrics.chronoDuration("normal") + easing.type: Appearance.animation.easing + } + NumberAnimation { + target: contentContainer + property: "opacity" + to: 0 + duration: Metrics.chronoDuration("normal") + easing.type: Appearance.animation.easing + } + NumberAnimation { + target: contentContainer + property: "scale" + to: 0.9 + duration: Metrics.chronoDuration("normal") + easing.type: Appearance.animation.easing + } + onFinished: { + window.visible = false + window.fadeOutFinished() + } + } + + Item { + id: contentContainer + anchors.fill: parent + opacity: 0 + scale: 0.9 + } + } +} \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/modules/interface/powermenu/Powermenu.qml b/.config/quickshell/nucleus-shell/modules/interface/powermenu/Powermenu.qml new file mode 100644 index 0000000..bd6a4dc --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/powermenu/Powermenu.qml @@ -0,0 +1,153 @@ +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Hyprland +import Quickshell.Io +import Quickshell.Services.Pipewire +import Quickshell.Wayland +import qs.config +import qs.modules.functions +import qs.services +import qs.modules.interface.lockscreen +import qs.modules.components + +PanelWindow { + id: powermenu + + WlrLayershell.keyboardFocus: Compositor.require("hyprland") && Globals.visiblility.powermenu + + function togglepowermenu() { + Globals.visiblility.powermenu = !Globals.visiblility.powermenu; // Simple toggle logic kept in a function as it might have more things to it later on. + } + + WlrLayershell.namespace: "nucleus:powermenu" + WlrLayershell.layer: WlrLayer.Top + visible: Config.initialized && Globals.visiblility.powermenu + color: "transparent" + exclusiveZone: 0 + implicitWidth: DisplayMetrics.scaledWidth(0.25) + implicitHeight: DisplayMetrics.scaledWidth(0.168) + + HyprlandFocusGrab { + id: grab + + active: Compositor.require("hyprland") + windows: [powermenu] + } + + StyledRect { + id: container + + color: Appearance.m3colors.m3background + radius: Metrics.radius("verylarge") + implicitWidth: powermenu.implicitWidth + anchors.fill: parent + + FocusScope { + focus: true + anchors.fill: parent + Keys.onPressed: { + if (event.key === Qt.Key_Escape) + Globals.visiblility.powermenu = false; + + } + + Item { + id: content + + anchors.margins: Metrics.radius(12) + anchors.topMargin: Metrics.radius(16) + anchors.leftMargin: Metrics.radius(18) + anchors.fill: parent + + Grid { + columns: 3 + rows: 3 + rowSpacing: Metrics.spacing(10) + columnSpacing: Metrics.spacing(10) + anchors.fill: parent + + PowerMenuButton { + buttonIcon: "power_settings_new" + onClicked: { + Quickshell.execDetached(["poweroff"]); + Globals.visiblility.powermenu = false; + } + } + + PowerMenuButton { + buttonIcon: "logout" + onClicked: { + Quickshell.execDetached(["hyprctl", "dispatch", "exit"]); + Globals.visiblility.powermenu = false; + } + } + + PowerMenuButton { + buttonIcon: "sleep" + onClicked: { + Quickshell.execDetached(["systemctl", "suspend"]); + Globals.visiblility.powermenu = false; + } + } + + PowerMenuButton { + buttonIcon: "lock" + onClicked: { + Quickshell.execDetached(["nucleus", "ipc", "call", "lockscreen", "lock"]); + Globals.visiblility.powermenu = false; + } + } + + PowerMenuButton { + buttonIcon: "restart_alt" + onClicked: { + Quickshell.execDetached(["reboot"]); + Globals.visiblility.powermenu = false; + } + } + + PowerMenuButton { + buttonIcon: "light_off" + onClicked: { + Quickshell.execDetached(["systemctl", "hibernate"]); + Globals.visiblility.powermenu = false; + } + } + + } + + component Anim: NumberAnimation { + running: Config.runtime.appearance.animations.enabled + duration: Metrics.chronoDuration(400) + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.animation.curves.standard + } + + } + + } + + } + + IpcHandler { + function toggle() { + togglepowermenu(); + } + + target: "powermenu" + } + + component PowerMenuButton: StyledButton { + property string buttonIcon + + icon: buttonIcon + iconSize: Metrics.iconSize(50) + width: powermenu.implicitWidth / 3.4 + height: powermenu.implicitHeight / 2.3 + radius: beingHovered ? Metrics.radius("verylarge") * 2 : Metrics.radius("large") + } + +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/screencapture/ScreenCapture.qml b/.config/quickshell/nucleus-shell/modules/interface/screencapture/ScreenCapture.qml new file mode 100644 index 0000000..4402b81 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/screencapture/ScreenCapture.qml @@ -0,0 +1,606 @@ +pragma ComponentBehavior: Bound +import Quickshell +import Quickshell.Hyprland +import Quickshell.Io +import QtQuick +import QtQuick.Layouts +import QtQuick.Effects +import Quickshell.Wayland +import qs.config +import qs.modules.components + +Scope { + id: root + property bool active: false + property rect selectedRegion: Qt.rect(0, 0, 0, 0) + property string tempScreenshot: "" + + IpcHandler { + target: "screen" + function capture() { + if (root.active) { + console.info("screencap", "already active"); + return; + } + console.info("screencap", "starting capture"); + root.active = true; + } + } + + LazyLoader { + active: root.active + component: PanelWindow { + id: win + property bool closing: false + property bool ready: false + property bool processing: false + property bool windowMode: false + property string savedPath: "" + property bool savedSuccess: false + + color: Appearance.m3colors.m3surface + anchors { top: true; left: true; right: true; bottom: true } + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.exclusionMode: ExclusionMode.Ignore + WlrLayershell.namespace: "nucleus:screencapture" + + Component.onCompleted: { + var ts = Qt.formatDateTime(new Date(), "yyyy-MM-dd_hh-mm-ss"); + root.tempScreenshot = "/tmp/screenshot_" + ts + ".png"; + } + + function close() { + if (closing) return; + closing = true; + closeAnim.start(); + } + + function saveFullscreen() { + console.info("screencap", "saveFullscreen started"); + win.processing = true; + screencopy.grabToImage(function(result) { + console.info("screencap", "fullscreen grabbed"); + var ts = Qt.formatDateTime(new Date(), "yyyy-MM-dd_hh-mm-ss"); + win.savedPath = Quickshell.env("HOME") + "/Pictures/Screenshots/screenshot_" + ts + ".png"; + + console.info("screencap", "saving to: " + win.savedPath); + if (result.saveToFile(win.savedPath)) { + console.info("screencap", "saved, copying"); + Quickshell.execDetached({ + command: ["sh", "-c", "cat '" + win.savedPath + "' | wl-copy --type image/png"] + }); + win.savedSuccess = true; + } else { + console.info("screencap", "save failed"); + win.savedSuccess = false; + } + win.processing = false; + console.info("screencap", "closing window"); + win.close(); + }); + } + + Component { + id: ffmpegProc + Process { + property string outputPath + property bool success: false + + onExited: (code) => { + console.info("screencap", "ffmpeg exited: " + code); + success = code === 0; + + if (success) { + console.info("screencap", "copying to clipboard"); + Quickshell.execDetached({ + command: ["sh", "-c", "cat '" + outputPath + "' | wl-copy --type image/png"] + }); + } + + Quickshell.execDetached({ command: ["rm", root.tempScreenshot] }); + + win.savedSuccess = success; + win.processing = false; + console.info("screencap", "done, closing"); + win.close(); + destroy(); + } + } + } + + function saveRegion(rect, suffix) { + console.info("screencap", "saveRegion started: " + rect.x + "," + rect.y + " " + rect.width + "x" + rect.height); + screencopy.grabToImage(function(result) { + console.info("screencap", "full screenshot grabbed for cropping"); + if (!result.saveToFile(root.tempScreenshot)) { + console.info("screencap", "ERROR: failed to save temp screenshot"); + win.savedSuccess = false; + win.processing = false; + win.close(); + return; + } + + console.info("screencap", "temp saved, cropping with ffmpeg"); + var ts = Qt.formatDateTime(new Date(), "yyyy-MM-dd_hh-mm-ss"); + win.savedPath = Quickshell.env("HOME") + "/Pictures/Screenshots/screenshot_" + ts + suffix + ".png"; + + ffmpegProc.createObject(win, { + command: ["ffmpeg", "-i", root.tempScreenshot, "-vf", "crop=" + Math.floor(rect.width) + ":" + Math.floor(rect.height) + ":" + Math.floor(rect.x) + ":" + Math.floor(rect.y), "-y", win.savedPath], + outputPath: win.savedPath, + running: true + }); + }); + } + + function captureFullscreen() { + win.processing = true; + saveFullscreen(); + } + + function captureWindow(rect) { + win.processing = true; + saveRegion(rect, "_window"); + } + + function captureRegion() { + if (!ready || !selection.hasSelection) return; + win.processing = true; + saveRegion(root.selectedRegion, "_region"); + } + + ScreencopyView { + id: screencopy + anchors.fill: parent + captureSource: win.screen + z: -999 + live: false + + onHasContentChanged: { + console.info("screencap", "hasContent: " + hasContent); + if (hasContent) { + console.info("screencap", "grabbing for preview"); + grabToImage(function(result) { + console.info("screencap", "preview grabbed: " + result.url); + frozen.source = result.url; + readyTimer.start(); + }); + } + } + } + + Timer { + id: readyTimer + interval: Metrics.chronoDuration("normal") + 50 + onTriggered: { + console.info("screencap", "UI ready"); + win.ready = true; + } + } + + Item { + anchors.fill: parent + focus: true + + Keys.onEscapePressed: win.close() + Keys.onPressed: event => { + if (event.key === Qt.Key_F) { + win.captureFullscreen(); + event.accepted = true; + } else if (event.key === Qt.Key_W) { + win.windowMode = !win.windowMode; + event.accepted = true; + } + } + + Image { + id: bg + anchors.fill: parent + source: Config.runtime.appearance.background.path + fillMode: Image.PreserveAspectCrop + opacity: 0 + scale: 1 + + layer.enabled: true + layer.effect: MultiEffect { + blurEnabled: true + blur: 1.0 + blurMax: 64 + brightness: -0.1 + } + + onStatusChanged: { + if (status === Image.Ready) fadeIn.start(); + } + + NumberAnimation on opacity { + id: fadeIn + to: 1 + duration: Metrics.chronoDuration("normal") + easing.type: Appearance.animation.easing + } + } + + Item { + id: container + anchors.centerIn: parent + width: win.width + height: win.height + + layer.enabled: true + layer.effect: MultiEffect { + shadowEnabled: true + shadowOpacity: 1 + shadowColor: Appearance.m3colors.m3shadow + } + + Image { + id: frozen + anchors.fill: parent + fillMode: Image.PreserveAspectFit + smooth: true + cache: false + } + + Item { + id: darkOverlay + anchors.fill: parent + visible: (selection.hasSelection || selection.selecting) && !win.windowMode + + Rectangle { + y: 0 + width: parent.width + height: selection.sy + color: "black" + opacity: 0.5 + } + Rectangle { + y: selection.sy + selection.h + width: parent.width + height: parent.height - (selection.sy + selection.h) + color: "black" + opacity: 0.5 + } + Rectangle { + x: 0 + y: selection.sy + width: selection.sx + height: selection.h + color: "black" + opacity: 0.5 + } + Rectangle { + x: selection.sx + selection.w + y: selection.sy + width: parent.width - (selection.sx + selection.w) + height: selection.h + color: "black" + opacity: 0.5 + } + + Rectangle { + x: selection.sx + y: selection.sy + width: selection.w + height: selection.h + color: "black" + opacity: win.processing ? 0.6 : 0 + + Behavior on opacity { + NumberAnimation { duration: 200 } + } + + LoadingIcon { + anchors.centerIn: parent + visible: win.processing + } + } + } + + Rectangle { + id: outline + x: selection.sx + y: selection.sy + width: selection.w + height: selection.h + color: "transparent" + border.color: Appearance.m3colors.m3primary + border.width: 2 + visible: (selection.selecting || selection.hasSelection) && !win.windowMode + } + + Rectangle { + visible: selection.selecting + anchors.top: outline.bottom + anchors.topMargin: Metrics.margin(10) + anchors.horizontalCenter: outline.horizontalCenter + width: coords.width + 10 + height: coords.height + 10 + color: Appearance.m3colors.m3surface + radius: Metrics.radius(20) + + StyledText { + id: coords + anchors.centerIn: parent + font.pixelSize: Metrics.fontSize(14) + animate: false + color: Appearance.m3colors.m3onSurface + property real scaleX: container.width / win.width + property real scaleY: container.height / win.height + text: Math.floor(selection.sx/scaleX) + "," + Math.floor(selection.sy/scaleY) + " " + Math.floor(selection.w/scaleX) + "x" + Math.floor(selection.h/scaleY) + } + } + + MouseArea { + id: selection + anchors.fill: parent + enabled: win.ready && !win.windowMode + + property real x1: 0 + property real y1: 0 + property real x2: 0 + property real y2: 0 + property bool selecting: false + property bool hasSelection: false + + property real xp: 0 + property real yp: 0 + property real wp: 0 + property real hp: 0 + + property real sx: xp * parent.width + property real sy: yp * parent.height + property real w: wp * parent.width + property real h: hp * parent.height + + onPressed: mouse => { + if (!win.ready) return; + x1 = Math.max(0, Math.min(mouse.x, width)); + y1 = Math.max(0, Math.min(mouse.y, height)); + x2 = x1; + y2 = y1; + selecting = true; + hasSelection = false; + } + + onPositionChanged: mouse => { + if (selecting) { + x2 = Math.max(0, Math.min(mouse.x, width)); + y2 = Math.max(0, Math.min(mouse.y, height)); + xp = Math.min(x1, x2) / width; + yp = Math.min(y1, y2) / height; + wp = Math.abs(x2 - x1) / width; + hp = Math.abs(y2 - y1) / height; + } + } + + onReleased: mouse => { + if (!selecting) return; + + x2 = Math.max(0, Math.min(mouse.x, width)); + y2 = Math.max(0, Math.min(mouse.y, height)); + selecting = false; + + hasSelection = Math.abs(x2 - x1) > 5 && Math.abs(y2 - y1) > 5; + + if (hasSelection) { + xp = Math.min(x1, x2) / width; + yp = Math.min(y1, y2) / height; + wp = Math.abs(x2 - x1) / width; + hp = Math.abs(y2 - y1) / height; + + root.selectedRegion = Qt.rect( + Math.min(x1, x2) * win.screen.width / width, + Math.min(y1, y2) * win.screen.height / height, + Math.abs(x2 - x1) * win.screen.width / width, + Math.abs(y2 - y1) * win.screen.height / height + ); + + win.captureRegion(); + } else { + win.close(); + } + } + } + + Repeater { + model: { + if (!win.windowMode || !win.ready) return []; + var ws = Hyprland.focusedMonitor?.activeWorkspace; + return ws?.toplevels ? ws.toplevels.values : []; + } + + delegate: Item { + required property var modelData + property var w: modelData?.lastIpcObject + visible: w?.at && w?.size + + property real barX: 0 + property real barY: 0 + property real sx: container.width / (win.screen.width - barX) + property real sy: container.height / (win.screen.height - barY) + + x: visible ? (w.at[0] - barX) * sx : 0 + y: visible ? (w.at[1] - barY) * sy : 0 + width: visible ? w.size[0] * sx : 0 + height: visible ? w.size[1] * sy : 0 + z: w?.floating ? (hover.containsMouse ? 1000 : 100) : (hover.containsMouse ? 50 : 0) + + Rectangle { + anchors.fill: parent + color: "transparent" + border.color: Appearance.m3colors.m3primary + border.width: hover.containsMouse ? 3 : 0 + radius: Metrics.radius(8) + Behavior on border.width { + NumberAnimation { duration: Metrics.chronoDuration(150) } + } + } + + Rectangle { + anchors.fill: parent + color: Appearance.m3colors.m3primary + opacity: hover.containsMouse ? 0.15 : 0 + radius: Metrics.radius(8) + Behavior on opacity { + NumberAnimation { duration: Metrics.chronoDuration(150) } + } + } + + MouseArea { + id: hover + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + win.captureWindow(Qt.rect(w.at[0], w.at[1], w.size[0], w.size[1])); + } + } + } + } + + ParallelAnimation { + running: win.visible && !win.closing && frozen.source != "" + + NumberAnimation { + target: bg + property: "scale" + to: bg.scale + 0.05 + duration: Metrics.chronoDuration("normal") + easing.type: Appearance.animation.easing + } + NumberAnimation { + target: container + property: "width" + to: win.width * 0.8 + duration: Metrics.chronoDuration("normal") + easing.type: Appearance.animation.easing + } + NumberAnimation { + target: container + property: "height" + to: win.height * 0.8 + duration: Metrics.chronoDuration("normal") + easing.type: Appearance.animation.easing + } + } + + ParallelAnimation { + id: closeAnim + + NumberAnimation { + target: bg + property: "scale" + to: bg.scale - 0.05 + duration: Metrics.chronoDuration("normal") + easing.type: Appearance.animation.easing + } + NumberAnimation { + target: container + property: "width" + to: win.width + duration: Metrics.chronoDuration("normal") + easing.type: Appearance.animation.easing + } + NumberAnimation { + target: container + property: "height" + to: win.height + duration: Metrics.chronoDuration("normal") + easing.type: Appearance.animation.easing + } + NumberAnimation { + target: darkOverlay + property: "opacity" + to: 0 + duration: Metrics.chronoDuration("normal") + easing.type: Appearance.animation.easing + } + NumberAnimation { + target: outline + property: "opacity" + to: 0 + duration: Metrics.chronoDuration("normal") + easing.type: Appearance.animation.easing + } + + onFinished: { + root.active = false; + if (win.savedSuccess) { + Quickshell.execDetached({ + command: ["notify-send", "Screenshot saved", win.savedPath.split("/").pop() + " (copied)"] + }); + } else if (win.savedPath !== "") { + Quickshell.execDetached({ + command: ["notify-send", "Screenshot failed", "Could not save"] + }); + } + } + } + } + + Item { + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: Metrics.margin(30) + width: row.width + 20 + height: row.height + 20 + visible: true + + Rectangle { + anchors.fill: parent + color: Appearance.m3colors.m3surface + radius: Metrics.radius("large") + } + + RowLayout { + id: row + anchors.centerIn: parent + + StyledButton { + icon: "fullscreen" + text: "Full screen" + tooltipText: "Capture the whole screen [F]" + onClicked: win.captureFullscreen() + } + Rectangle { + Layout.fillHeight: true + width: 2 + color: Appearance.m3colors.m3onSurfaceVariant + opacity: 0.2 + } + StyledButton { + icon: "window" + checkable: true + checked: win.windowMode + text: "Window" + tooltipText: "Hover and click a window [W]" + onClicked: win.windowMode = !win.windowMode + } + StyledButton { + secondary: true + icon: "close" + tooltipText: "Exit [Escape]" + onClicked: win.close() + } + } + } + } + + HyprlandFocusGrab { + id: grab + windows: [win] + } + + onVisibleChanged: { + if (visible) grab.active = true + } + + Connections { + target: grab + function onActiveChanged() { + if (!grab.active && !win.closing) win.close(); + } + } + } + } +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/settings/About.qml b/.config/quickshell/nucleus-shell/modules/interface/settings/About.qml new file mode 100644 index 0000000..9f10c18 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/settings/About.qml @@ -0,0 +1,115 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import qs.config +import qs.modules.components +import qs.config +import qs.services + +Item { + id: root + + Layout.fillWidth: true + Layout.fillHeight: true + property int logoOffset: -30 + + Column { + anchors.centerIn: parent + width: 460 + spacing: Metrics.spacing(12) + Item { + width: parent.width + height: Metrics.fontSize(200) + + StyledText { + text: SystemDetails.osIcon + anchors.centerIn: parent + x: root.logoOffset + font.pixelSize: Metrics.fontSize(200) + } + } + + StyledText { + text: "Nucleus Shell" + width: parent.width + horizontalAlignment: Text.AlignHCenter + font.family: "Outfit ExtraBold" + font.pixelSize: Metrics.fontSize(26) + } + + StyledText { + text: "A shell built to get things done." + width: parent.width + wrapMode: Text.Wrap + horizontalAlignment: Text.AlignHCenter + font.pixelSize: Metrics.fontSize(14) + } + + Row { + anchors.horizontalCenter: parent.horizontalCenter + spacing: Metrics.spacing(10) + + StyledButton { + text: "View on GitHub" + icon: "code" + secondary: true + onClicked: Qt.openUrlExternally("https://github.com/xZepyx/nucleus-shell") + } + + StyledButton { + text: "Report Issue" + icon: "bug_report" + secondary: true + onClicked: Qt.openUrlExternally("https://github.com/xZepyx/nucleus-shell/issues") + } + + } + + } + + StyledText { + text: "Nucleus-Shell v" + Config.runtime.shell.version + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: Metrics.margin(24) + font.pixelSize: Metrics.fontSize(12) + } + + StyledRect { + width: 52 + height: 52 + radius: Appearance.rounding.small + + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: Metrics.margin(24) + + StyledText { + text: "↻" + anchors.centerIn: parent + font.pixelSize: Metrics.fontSize(22) + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + + onClicked: { + Globals.states.settingsOpen = false + + Quickshell.execDetached(["notify-send", "Updating Nucleus Shell"]) + + Quickshell.execDetached([ + "kitty", + "--hold", + "bash", + "-c", + Directories.scriptsPath + "/system/update.sh" + ]) + } + } + + } +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/settings/AppearanceConfig.qml b/.config/quickshell/nucleus-shell/modules/interface/settings/AppearanceConfig.qml new file mode 100644 index 0000000..23a7e8e --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/settings/AppearanceConfig.qml @@ -0,0 +1,364 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Widgets +import qs.config +import qs.modules.components +import qs.services +import qs.plugins + +ContentMenu { + title: "Appearance" + description: "Adjust how the desktop looks like." + + ContentCard { + ColumnLayout { + Layout.fillWidth: true + spacing: Metrics.spacing(16) + + ColumnLayout { + spacing: Metrics.spacing(4) + + StyledText { + text: "Select Theme" + font.pixelSize: Metrics.fontSize(16) + } + + StyledText { + text: "Choose between dark or light mode." + font.pixelSize: Metrics.fontSize(12) + color: "#888888" + } + } + + RowLayout { + Layout.leftMargin: Metrics.margin(15) + Layout.fillWidth: true + Layout.alignment: Qt.AlignHCenter + spacing: Metrics.spacing(16) + + StyledButton { + Layout.preferredHeight: 300 + Layout.preferredWidth: 460 + Layout.maximumHeight: 400 + Layout.maximumWidth: 500 + icon: "dark_mode" + iconSize: Metrics.iconSize(64) + checked: Config.runtime.appearance.theme === "dark" + hoverEnabled: true + + onClicked: { + if (!Config.runtime.appearance.colors.autogenerated) { + const scheme = Config.runtime.appearance.colors.scheme + const file = Theme.map[scheme]?.dark + if (!file) { + Theme.notifyMissingVariant(scheme, "dark") + return + } + + Config.updateKey("appearance.theme", "dark") + Quickshell.execDetached([ + "nucleus", "theme", "switch", file + ]) + } else { + Config.updateKey("appearance.theme", "dark") + Quickshell.execDetached([ + "nucleus", "ipc", "call", "global", "regenColors" + ]) + } + } + } + + StyledButton { + Layout.preferredHeight: 300 + Layout.preferredWidth: 460 + Layout.maximumHeight: 400 + Layout.maximumWidth: 500 + icon: "light_mode" + iconSize: Metrics.iconSize(64) + checked: Config.runtime.appearance.theme === "light" + hoverEnabled: true + + onClicked: { + if (!Config.runtime.appearance.colors.autogenerated) { + const scheme = Config.runtime.appearance.colors.scheme + const file = Theme.map[scheme]?.light + if (!file) { + Theme.notifyMissingVariant(scheme, "light") + return + } + + Config.updateKey("appearance.theme", "light") + Quickshell.execDetached([ + "nucleus", "theme", "switch", file + ]) + } else { + Config.updateKey("appearance.theme", "light") + Quickshell.execDetached([ + "nucleus", "ipc", "call", "global", "regenColors" + ]) + } + } + } + } + + Item { + width: Metrics.spacing(30) + } + } + } + + ContentCard { + RowLayout { + opacity: autogeneratedColorsSelector.enabled ? 1 : 0.8 + + ColumnLayout { + StyledText { + text: "Color Generation Schemes:" + font.pixelSize: Metrics.fontSize(16) + } + + StyledText { + text: "Choose the scheme for autogenerated color generation." + font.pixelSize: Metrics.fontSize(12) + } + } + + Item { Layout.fillWidth: true } + + StyledDropDown { + id: autogeneratedColorsSelector + label: "Color Scheme" + model: [ + "scheme-content", + "scheme-expressive", + "scheme-fidelity", + "scheme-fruit-salad", + "scheme-monochrome", + "scheme-neutral", + "scheme-rainbow", + "scheme-tonal-spot" + ] + + currentIndex: model.indexOf(Config.runtime.appearance.colors.matugenScheme) + + onSelectedIndexChanged: (index) => { + if (!Config.runtime.appearance.colors.autogenerated) + return + const selectedScheme = model[index] + Config.updateKey("appearance.colors.matugenScheme", selectedScheme) + Quickshell.execDetached([ + "nucleus", "ipc", "call", "global", "regenColors" + ]) + } + + enabled: Config.runtime.appearance.colors.autogenerated + } + } + + RowLayout { + opacity: predefinedThemeSelector.enabled ? 1 : 0.8 + + ColumnLayout { + StyledText { + font.pixelSize: Metrics.fontSize(16) + text: "Predefined/Custom Themes:" + } + + StyledText { + font.pixelSize: Metrics.fontSize(12) + text: "Choose a pre-defined theme for your interface." + } + } + + Item { Layout.fillWidth: true } + + StyledDropDown { + id: predefinedThemeSelector + label: "Theme" + model: Object.keys(Theme.map) + currentIndex: model.indexOf(Config.runtime.appearance.colors.scheme) + + onSelectedIndexChanged: (index) => { + if (Config.runtime.appearance.colors.autogenerated) + return + const selectedTheme = model[index] + const variant = Config.runtime.appearance.theme + const file = Theme.map[selectedTheme][variant] + if (!file) return + + Config.updateKey("appearance.colors.scheme", selectedTheme) + Quickshell.execDetached([ + "nucleus", "theme", "switch", file + ]) + } + + enabled: !Config.runtime.appearance.colors.autogenerated + } + } + } + + ContentCard { + StyledSwitchOption { + title: "Tint Icons" + description: "Either tint icons across the shell or keep them colorized." + prefField: "appearance.tintIcons" + } + + StyledSwitchOption { + title: "Use Autogenerated Themes" + description: "Use autogenerated themes." + prefField: "appearance.colors.autogenerated" + } + + StyledSwitchOption { + title: "Use User Defined Themes" + description: "Enabling this will also run the default `config.toml` in `~/.config/matugen` dir." + prefField: "appearance.colors.runMatugenUserWide" + } + } + + ContentCard { + StyledText { + text: "Clock" + font.pixelSize: Metrics.fontSize(20) + font.bold: true + } + + StyledSwitchOption { + title: "Show Clock" + description: "Whether to show or disable the clock on the background." + prefField: "appearance.background.clock.enabled" + } + + StyledSwitchOption { + title: "Analog Variant" + description: "Whether to use analog clock or not." + prefField: "appearance.background.clock.isAnalog" + } + + StyledSwitchOption { + title: "Rotate Clock Polygon" + description: "Rotate the shape polygon of the analog clock." + prefField: "appearance.background.clock.rotatePolygonBg" + enabled: Config.runtime.appearance.background.clock.isAnalog + opacity: enabled ? 1 : 0.8 + } + + NumberStepper { + label: "Rotation Duration" + description: "Adjust the duration in which the clock rotates 360* (Seconds)." + prefField: "appearance.background.clock.rotationDuration" + minimum: 1 + maximum: 40 + step: 1 + } + + RowLayout { + id: shapeSelector + + ColumnLayout { + StyledText { + text: "Analog Clock Shape" + font.pixelSize: Metrics.fontSize(16) + } + + StyledText { + text: "Choose the analog clock's shape." + font.pixelSize: Metrics.fontSize(12) + } + } + + Item { Layout.fillWidth: true } + + StyledDropDown { + label: "Shape Type" + model: ["Cookie 7 Sided", "Cookie 9 Sided", "Cookie 12 Sided", "Pixelated Circle", "Circle"] + + currentIndex: Config.runtime.appearance.background.clock.shape + + onSelectedIndexChanged: (index) => { + Config.updateKey( + "appearance.background.clock.shape", + index + ) + } + } + } + } + + ContentCard { + StyledText { + text: "Rounding" + font.pixelSize: Metrics.fontSize(20) + font.bold: true + } + NumberStepper { + label: "Factor" + description: "Adjust the rounding factor." + prefField: "appearance.rounding.factor" + minimum: 0 + maximum: 1 + step: 0.1 + } + } + + ContentCard { + StyledText { + text: "Font" + font.pixelSize: Metrics.fontSize(20) + font.bold: true + } + NumberStepper { + label: "Scale" + description: "Adjust the font scale." + prefField: "appearance.font.scale" + minimum: 0.1 + maximum: 2 + step: 0.1 + } + } + + ContentCard { + StyledText { + text: "Transparency" + font.pixelSize: Metrics.fontSize(20) + font.bold: true + } + StyledSwitchOption { + title: "Enabled" + description: "Whether to enable or disable transparency." + prefField: "appearance.transparency.enabled" + } + NumberStepper { + label: "Factor" + description: "Adjust the alpha value for transparency." + prefField: "appearance.transparency.alpha" + minimum: 0.1 + maximum: 1 + step: 0.1 + } + } + + ContentCard { + StyledText { + text: "Animations" + font.pixelSize: Metrics.fontSize(20) + font.bold: true + } + StyledSwitchOption { + title: "Enabled" + description: "Whether to enable or disable animations (applies everywhere in the shell)." + prefField: "appearance.animations.enabled" + } + NumberStepper { + label: "Duration Scale" + description: "Adjust the duration scale of the animations." + prefField: "appearance.animations.durationScale" + minimum: 0.1 + maximum: 1 + step: 0.1 + } + } +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/settings/AudioConfig.qml b/.config/quickshell/nucleus-shell/modules/interface/settings/AudioConfig.qml new file mode 100644 index 0000000..0059f9b --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/settings/AudioConfig.qml @@ -0,0 +1,355 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell.Io +import qs.modules.functions +import qs.config +import qs.modules.components +import qs.services + +ContentMenu { + title: "Sound" + description: "Volume and audio devices" + + ContentCard { + ColumnLayout { + Layout.fillWidth: true + spacing: Metrics.spacing(20) + + RowLayout { + Layout.fillWidth: true + spacing: Metrics.spacing(16) + + Rectangle { + Layout.preferredWidth: 40 + Layout.preferredHeight: 40 + radius: Metrics.radius("large") + color: Appearance.m3colors.m3primaryContainer + + MaterialSymbol { + anchors.centerIn: parent + icon: "volume_up" + color: Appearance.m3colors.m3onPrimaryContainer + iconSize: Metrics.iconSize(24) + } + } + + ColumnLayout { + Layout.fillWidth: true + + StyledText { + text: "Output" + font.pixelSize: Metrics.fontSize(16) + font.family: Metrics.fontFamily("Outfit Medium") + color: Appearance.m3colors.m3onSurface + } + + StyledText { + text: Volume.defaultSink.description + font.pixelSize: Metrics.fontSize(13) + color: Appearance.m3colors.m3onSurfaceVariant + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: Appearance.m3colors.m3outlineVariant + opacity: 0.4 + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Metrics.spacing(12) + + RowLayout { + Layout.fillWidth: true + + StyledText { + text: "Volume" + font.pixelSize: Metrics.fontSize(14) + font.family: Metrics.fontFamily("Outfit Medium") + color: Appearance.m3colors.m3onSurface + } + + Item { Layout.fillWidth: true } + + StyledText { + animate: false + text: Math.round(Volume.defaultSink.audio.volume * 100) + "%" + font.pixelSize: Metrics.fontSize(14) + font.family: Metrics.fontFamily("Outfit SemiBold") + color: Appearance.m3colors.m3primary + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Metrics.spacing(16) + + MaterialSymbol { + icon: Volume.defaultSink.audio.muted ? "volume_off" + : Volume.defaultSink.audio.volume < 0.33 ? "volume_mute" + : Volume.defaultSink.audio.volume < 0.66 ? "volume_down" + : "volume_up" + color: Appearance.m3colors.m3onSurfaceVariant + iconSize: Metrics.iconSize(24) + } + + StyledSlider { + id: outputVolumeSlider + Layout.fillWidth: true + value: Volume.defaultSink.audio.volume * 100 + onValueChanged: Volume.setVolume(value / 100) + } + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Metrics.spacing(8) + + StyledText { + text: "Device" + font.pixelSize: Metrics.fontSize(14) + font.family: Metrics.fontFamily("Outfit Medium") + color: Appearance.m3colors.m3onSurface + } + + StyledDropDown { + Layout.fillWidth: true + label: "Output device" + model: Volume.sinks.map(d => d.description) + currentIndex: { + for (let i = 0; i < Volume.sinks.length; i++) + if (Volume.sinks[i].name === Volume.defaultSink.name) return i + return -1 + } + onSelectedIndexChanged: index => { + if (index >= 0 && index < Volume.sinks.length) + Volume.setDefaultSink(Volume.sinks[index]) + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 56 + radius: Metrics.radius("small") + color: Appearance.m3colors.m3surfaceContainerHigh + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Metrics.margin(16) + anchors.rightMargin: Metrics.margin(16) + spacing: Metrics.spacing(12) + + MaterialSymbol { + icon: Volume.defaultSink.audio.muted ? "volume_off" : "volume_up" + color: Volume.defaultSink.audio.muted ? Appearance.m3colors.m3error : Appearance.m3colors.m3onSurfaceVariant + iconSize: Metrics.iconSize(24) + } + + StyledText { + Layout.fillWidth: true + text: "Mute output" + font.pixelSize: Metrics.fontSize(14) + color: Appearance.m3colors.m3onSurface + } + + StyledSwitch { + checked: Volume.defaultSink.audio.muted + onToggled: Volume.toggleMuted(Volume.defaultSink) + } + } + } + } + } + + ContentCard { + ColumnLayout { + Layout.fillWidth: true + spacing: Metrics.spacing(20) + + RowLayout { + Layout.fillWidth: true + spacing: Metrics.spacing(16) + + Rectangle { + Layout.preferredWidth: 40 + Layout.preferredHeight: 40 + radius: Metrics.radius("large") + color: Appearance.m3colors.m3secondaryContainer + + MaterialSymbol { + anchors.centerIn: parent + icon: "mic" + color: Appearance.m3colors.m3onSecondaryContainer + iconSize: Metrics.iconSize(24) + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Metrics.spacing(2) + + StyledText { + text: "Input" + font.pixelSize: Metrics.fontSize(16) + font.family: Metrics.fontFamily("Outfit Medium") + color: Appearance.m3colors.m3onSurface + } + + StyledText { + visible: Volume.sources.length > 0 + text: Volume.defaultSource.description + font.pixelSize: Metrics.fontSize(13) + color: Appearance.m3colors.m3onSurfaceVariant + } + } + } + + Rectangle { + visible: Volume.sources.length === 0 + Layout.fillWidth: true + Layout.preferredHeight: 120 + radius: Metrics.radius("small") + color: Appearance.m3colors.m3surfaceContainerHigh + + ColumnLayout { + anchors.centerIn: parent + spacing: Metrics.spacing(8) + + MaterialSymbol { + icon: "mic_off" + iconSize: Metrics.iconSize(48) + color: ColorUtils.transparentize(Appearance.m3colors.m3onSurface, 0.3) + Layout.alignment: Qt.AlignHCenter + } + + StyledText { + text: "No input devices" + font.pixelSize: Metrics.fontSize(14) + font.family: Metrics.fontFamily("Outfit Medium") + color: Appearance.m3colors.m3onSurfaceVariant + Layout.alignment: Qt.AlignHCenter + } + } + } + + Rectangle { + visible: Volume.sources.length > 0 + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: Appearance.m3colors.m3outlineVariant + opacity: 0.4 + } + + ColumnLayout { + visible: Volume.sources.length > 0 + Layout.fillWidth: true + spacing: Metrics.spacing(12) + + RowLayout { + Layout.fillWidth: true + + StyledText { + text: "Volume" + font.pixelSize: Metrics.fontSize(14) + font.family: Metrics.fontFamily("Outfit Medium") + color: Appearance.m3colors.m3onSurface + } + + Item { Layout.fillWidth: true } + + StyledText { + animate: false + text: Math.round(Volume.defaultSource.audio.volume * 100) + "%" + font.pixelSize: Metrics.fontSize(14) + font.family: Metrics.fontFamily("Outfit SemiBold") + color: Appearance.m3colors.m3primary + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Metrics.spacing(16) + + MaterialSymbol { + icon: Volume.defaultSource.audio.muted ? "mic_off" : "mic" + color: Appearance.m3colors.m3onSurfaceVariant + iconSize: Metrics.iconSize(24) + } + + StyledSlider { + id: inputVolumeSlider + Layout.fillWidth: true + value: Volume.defaultSource.audio.volume * 100 + onValueChanged: Volume.setSourceVolume(value / 100) + } + } + } + + ColumnLayout { + visible: Volume.sources.length > 0 + Layout.fillWidth: true + spacing: Metrics.spacing(8) + + StyledText { + text: "Device" + font.pixelSize: Metrics.fontSize(14) + font.family: Metrics.fontFamily("Outfit Medium") + color: Appearance.m3colors.m3onSurface + } + + StyledDropDown { + Layout.fillWidth: true + label: "Input device" + model: Volume.sources.map(d => d.description) + currentIndex: { + for (let i = 0; i < Volume.sources.length; i++) + if (Volume.sources[i].name === Volume.defaultSource.name) return i + return -1 + } + onSelectedIndexChanged: index => { + if (index >= 0 && index < Volume.sources.length) + Volume.setDefaultSource(Volume.sources[index]) + } + } + } + + Rectangle { + visible: Volume.sources.length > 0 + Layout.fillWidth: true + Layout.preferredHeight: 56 + radius: Metrics.radius("small") + color: Appearance.m3colors.m3surfaceContainerHigh + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Metrics.margin(16) + anchors.rightMargin: Metrics.margin(16) + spacing: Metrics.spacing(12) + + MaterialSymbol { + icon: Volume.defaultSource.audio.muted ? "mic_off" : "mic" + color: Volume.defaultSource.audio.muted ? Appearance.m3colors.m3error : Appearance.m3colors.m3onSurfaceVariant + iconSize: Metrics.iconSize(24) + } + + StyledText { + Layout.fillWidth: true + text: "Mute input" + font.pixelSize: Metrics.fontSize(14) + color: Appearance.m3colors.m3onSurface + } + + StyledSwitch { + checked: Volume.defaultSource.audio.muted + onToggled: Volume.toggleMuted(Volume.defaultSource) + } + } + } + } + } +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/settings/BarConfig.qml b/.config/quickshell/nucleus-shell/modules/interface/settings/BarConfig.qml new file mode 100644 index 0000000..f6b75e4 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/settings/BarConfig.qml @@ -0,0 +1,300 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import QtQuick.Controls +import Quickshell.Widgets +import qs.config +import qs.modules.components +import qs.services + +ContentMenu { + property string barKey: "bar" + title: "Bar" + description: "Adjust the bar's look." + + ContentCard { + id: monitorSelectorCard + + StyledText { + text: "Monitor Bar Configuration" + font.pixelSize: Metrics.fontSize(20) + font.bold: true + } + + StyledText { + text: (Config.runtime.monitors?.[monitorSelector.model[monitorSelector.currentIndex]]?.bar) + ? "This monitor has its own bar configuration." + : "This monitor currently uses the global bar." + wrapMode: Text.WordWrap + } + + RowLayout { + spacing: Metrics.spacing("normal") + + StyledDropDown { + id: monitorSelector + Layout.preferredWidth: 220 + model: Xrandr.monitors.map(m => m.name) + currentIndex: 0 + onCurrentIndexChanged: monitorSelectorCard.updateMonitorProperties() + } + + Item { Layout.fillWidth: true } + + StyledButton { + id: createButton + icon: "add" + text: "Override Bar: (" + monitorSelector.model[monitorSelector.currentIndex] + ")" + Layout.preferredWidth: 280 + onClicked: { + const monitorName = monitorSelector.model[monitorSelector.currentIndex] + if (!monitorName) return + if (!Config.runtime.monitors) Config.runtime.monitors = {} + if (!Config.runtime.monitors[monitorName]) + Config.runtime.monitors[monitorName] = {} + + const defaultBar = { + density: 50, + enabled: true, + floating: false, + gothCorners: true, + margins: 16, + merged: false, + modules: { + height: 34, + paddingColor: "#1f1f1f", + radius: 17, + statusIcons: { + bluetoothStatusEnabled: true, + enabled: true, + networkStatusEnabled: true + }, + systemUsage: { + cpuStatsEnabled: true, + enabled: true, + memoryStatsEnabled: true, + tempStatsEnabled: true + }, + workspaces: { + enabled: true, + showAppIcons: true, + showJapaneseNumbers: false, + workspaceIndicators: 8 + } + }, + position: "top", + radius: 23 + } + + Config.updateKey("monitors." + monitorName + ".bar", defaultBar) + monitorSelectorCard.updateMonitorProperties() + } + } + + StyledButton { + id: deleteButton + icon: "delete" + text: "Use Global Bar: (" + monitorSelector.model[monitorSelector.currentIndex] + ")" + secondary: true + Layout.preferredWidth: 280 + onClicked: { + const monitorName = monitorSelector.model[monitorSelector.currentIndex] + if (!monitorName) return + Config.updateKey("monitors." + monitorName + ".bar", undefined) + monitorSelectorCard.updateMonitorProperties() + } + } + } + + function updateMonitorProperties() { + const monitorName = monitorSelector.model[monitorSelector.currentIndex] + const monitorBar = Config.runtime.monitors?.[monitorName]?.bar + barKey = monitorBar ? "monitors." + monitorName + ".bar" : "bar" + + createButton.enabled = !monitorBar + deleteButton.enabled = !!monitorBar + + monitorSelector.model = Xrandr.monitors.map(m => m.name) + monitorSelector.currentIndex = Xrandr.monitors.findIndex(m => m.name === monitorName) + } + } + + ContentCard { + StyledText { + text: "Bar" + font.pixelSize: Metrics.fontSize(20) + font.bold: true + } + + ColumnLayout { + StyledText { + text: "Position" + font.pixelSize: Metrics.fontSize(16) + } + + RowLayout { + spacing: Metrics.spacing(8) + Repeater { + model: ["Top", "Bottom", "Left", "Right"] + delegate: StyledButton { + property string pos: modelData.toLowerCase() + text: modelData + Layout.fillWidth: true + checked: ConfigResolver.bar(monitorSelector.model[monitorSelector.currentIndex]).position === pos + topLeftRadius: Metrics.radius("normal") + topRightRadius: Metrics.radius("normal") + bottomLeftRadius: Metrics.radius("normal") + bottomRightRadius: Metrics.radius("normal") + onClicked: Config.updateKey(barKey + ".position", pos) + } + } + } + } + + StyledSwitchOption { + title: "Enabled" + description: "Toggle the bar visibility on/off" + prefField: barKey + ".enabled" + } + StyledSwitchOption { + title: "Floating Bar" + description: "Make the bar float above other windows instead of being part of the desktop" + prefField: barKey + ".floating" + } + StyledSwitchOption { + title: "Goth Corners" + description: "Apply gothic-style corner cutouts to the bar" + prefField: barKey + ".gothCorners" + } + StyledSwitchOption { + title: "Merged Layout" + description: "Merge all modules into a single continuous layout" + prefField: barKey + ".merged" + } + } + + ContentCard { + StyledText { + text: "Bar Rounding & Size" + font.pixelSize: Metrics.fontSize(20) + font.bold: true + } + + NumberStepper { + label: "Bar Density" + prefField: barKey + ".density" + description: "Modify the bar's density" + minimum: 40 + maximum: 128 + } + NumberStepper { + label: "Bar Radius" + prefField: barKey + ".radius" + description: "Modify the bar's radius" + minimum: 10 + maximum: 128 + } + NumberStepper { + label: "Module Container Radius" + prefField: barKey + ".modules.radius" + description: "Modify the bar's module.radius" + minimum: 10 + maximum: 128 + } + NumberStepper { + label: "Module Height" + prefField: barKey + ".modules.height" + description: "Modify the bar's module.height" + minimum: 10 + maximum: 128 + } + NumberStepper { + label: "Workspace Indicators" + prefField: barKey + ".modules.workspaces.workspaceIndicators" + description: "Adjust how many workspace indicators to show." + minimum: 1 + maximum: 10 + } + } + + ContentCard { + StyledText { + text: "Bar Modules" + font.pixelSize: Metrics.fontSize(20) + font.bold: true + } + + StyledText { + text: "Workspaces" + font.pixelSize: Metrics.fontSize(18) + font.bold: true + } + StyledSwitchOption { + title: "Enabled" + description: "Show workspace indicator module" + prefField: barKey + ".modules.workspaces.enabled" + } + StyledSwitchOption { + title: "Show App Icons" + description: "Display application icons in workspace indicators" + prefField: barKey + ".modules.workspaces.showAppIcons" + enabled: !barKey.modules.workspaces.showJapaneseNumbers && Compositor.require("hyprland") + opacity: !barKey.modules.workspaces.showJapaneseNumbers && Compositor.require("hyprland") ? 1 : 0.8 + } + StyledSwitchOption { + title: "Show Japanese Numbers" + description: "Use Japanese-style numbers instead of standard numerals" + prefField: barKey + ".modules.workspaces.showJapaneseNumbers" + enabled: !barKey.modules.workspaces.showAppIcons + opacity: !barKey.modules.workspaces.showAppIcons ? 1 : 0.8 + } + + StyledText { + text: "Status Icons" + font.pixelSize: Metrics.fontSize(18) + font.bold: true + } + StyledSwitchOption { + title: "Enabled" + description: "Show status icons module (wifi, bluetooth)" + prefField: barKey + ".modules.statusIcons.enabled" + } + StyledSwitchOption { + title: "Show Wifi Status" + description: "Display wifi connection status and signal strength" + prefField: barKey + ".modules.statusIcons.networkStatusEnabled" + } + StyledSwitchOption { + title: "Show Bluetooth Status" + description: "Display bluetooth connection status" + prefField: barKey + ".modules.statusIcons.bluetoothStatusEnabled" + } + + StyledText { + text: "System Stats" + font.pixelSize: Metrics.fontSize(18) + font.bold: true + } + StyledSwitchOption { + title: "Enabled" + description: "Show system resource monitoring module" + prefField: barKey + ".modules.systemUsage.enabled" + } + StyledSwitchOption { + title: "Show Cpu Usage Stats" + description: "Display CPU usage percentage and load" + prefField: barKey + ".modules.systemUsage.cpuStatsEnabled" + } + StyledSwitchOption { + title: "Show Memory Usage Stats" + description: "Display RAM usage and available memory" + prefField: barKey + ".modules.systemUsage.memoryStatsEnabled" + } + StyledSwitchOption { + title: "Show Cpu Temperature Stats" + description: "Display CPU temperature readings" + prefField: barKey + ".modules.systemUsage.tempStatsEnabled" + } + } +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/settings/BluetoothConfig.qml b/.config/quickshell/nucleus-shell/modules/interface/settings/BluetoothConfig.qml new file mode 100644 index 0000000..85a109c --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/settings/BluetoothConfig.qml @@ -0,0 +1,187 @@ +import QtQuick +import QtQuick.Layouts +import qs.config +import qs.modules.components +import qs.modules.functions +import qs.services +import Quickshell.Bluetooth as QsBluetooth + +ContentMenu { + title: "Bluetooth" + description: "Manage Bluetooth devices and connections." + + ContentCard { + ContentRowCard { + cardSpacing: Metrics.spacing(0) + verticalPadding: Bluetooth.defaultAdapter.enabled ? Metrics.padding(10) : Metrics.padding(0) + cardMargin: Metrics.margin(0) + + StyledText { + text: powerSwitch.checked ? "Power: On" : "Power: Off" + font.pixelSize: Metrics.fontSize(16) + font.bold: true + } + + Item { Layout.fillWidth: true } + + StyledSwitch { + id: powerSwitch + checked: Bluetooth.defaultAdapter?.enabled + onToggled: Bluetooth.defaultAdapter.enabled = checked + } + } + + ContentRowCard { + visible: Bluetooth.defaultAdapter.enabled + cardSpacing: Metrics.spacing(0) + verticalPadding: Metrics.padding(10) + cardMargin: Metrics.margin(0) + + ColumnLayout { + spacing: Metrics.spacing(2) + + StyledText { + text: "Discoverable" + font.pixelSize: Metrics.fontSize(16) + } + + StyledText { + text: "Allow other devices to find this computer." + font.pixelSize: Metrics.fontSize(12) + color: ColorUtils.transparentize(Appearance.m3colors.m3onSurface, 0.6) + } + } + + Item { Layout.fillWidth: true } + + StyledSwitch { + checked: Bluetooth.defaultAdapter?.discoverable + onToggled: Bluetooth.defaultAdapter.discoverable = checked + } + } + + ContentRowCard { + visible: Bluetooth.defaultAdapter.enabled + cardSpacing: Metrics.spacing(0) + verticalPadding: Metrics.padding(0) + cardMargin: Metrics.margin(0) + + ColumnLayout { + spacing: Metrics.spacing(2) + + StyledText { + text: "Scanning" + font.pixelSize: Metrics.fontSize(16) + } + + StyledText { + text: "Search for nearby Bluetooth devices." + font.pixelSize: Metrics.fontSize(12) + color: ColorUtils.transparentize(Appearance.m3colors.m3onSurface, 0.6) + } + } + + Item { Layout.fillWidth: true } + + StyledSwitch { + checked: Bluetooth.defaultAdapter?.discovering + onToggled: Bluetooth.defaultAdapter.discovering = checked + } + } + } + + ContentCard { + visible: connectedDevices.count > 0 + + StyledText { + text: "Connected Devices" + font.pixelSize: Metrics.fontSize(18) + font.bold: true + } + + Repeater { + id: connectedDevices + model: Bluetooth.devices.filter(d => d.connected) + + delegate: BluetoothDeviceCard { + device: modelData + statusText: modelData.batteryAvailable + ? "Connected, " + Math.floor(modelData.battery * 100) + "% left" + : "Connected" + showDisconnect: true + showRemove: true + usePrimary: true + } + } + } + + ContentCard { + visible: Bluetooth.defaultAdapter?.enabled + + StyledText { + text: "Paired Devices" + font.pixelSize: Metrics.fontSize(18) + font.bold: true + } + + Item { + visible: pairedDevices.count === 0 + width: parent.width + height: Metrics.spacing(40) + + StyledText { + anchors.left: parent.left + text: "No paired devices" + font.pixelSize: Metrics.fontSize(14) + color: ColorUtils.transparentize(Appearance.m3colors.m3onSurface, 0.6) + } + } + + Repeater { + id: pairedDevices + model: Bluetooth.devices.filter(d => !d.connected && d.paired) + + delegate: BluetoothDeviceCard { + device: modelData + statusText: "Not connected" + showConnect: true + showRemove: true + } + } + } + + ContentCard { + visible: Bluetooth.defaultAdapter?.enabled + + StyledText { + text: "Available Devices" + font.pixelSize: Metrics.fontSize(18) + font.bold: true + } + + Item { + visible: discoveredDevices.count === 0 && !Bluetooth.defaultAdapter.discovering + width: parent.width + height: Metrics.spacing(40) + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: "No new devices found" + font.pixelSize: Metrics.fontSize(14) + color: ColorUtils.transparentize(Appearance.m3colors.m3onSurface, 0.6) + } + } + + Repeater { + id: discoveredDevices + model: Bluetooth.devices.filter(d => !d.paired && !d.connected) + + delegate: BluetoothDeviceCard { + device: modelData + statusText: "Discovered" + showConnect: true + showPair: true + } + } + } +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/settings/BluetoothDeviceCard.qml b/.config/quickshell/nucleus-shell/modules/interface/settings/BluetoothDeviceCard.qml new file mode 100644 index 0000000..cca4ac1 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/settings/BluetoothDeviceCard.qml @@ -0,0 +1,90 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.components +import qs.config +import qs.modules.functions +import Quickshell.Bluetooth as QsBluetooth + +ContentRowCard { + id: deviceRow + property var device + property string statusText: "" + property bool usePrimary: false + property bool showConnect: false + property bool showDisconnect: false + property bool showPair: false + property bool showRemove: false + + cardMargin: Metrics.margin(0) + cardSpacing: Metrics.spacing(10) + verticalPadding: Metrics.padding(0) + opacity: device.state === QsBluetooth.BluetoothDeviceState.Connecting || + device.state === QsBluetooth.BluetoothDeviceState.Disconnecting ? 0.6 : 1 + + function mapBluetoothIcon(dbusIcon, name) { + console.log(dbusIcon, " / ", name) + const iconMap = { + "audio-headset": "headset", + "audio-headphones": "headphones", + "input-keyboard": "keyboard", + "input-mouse": "mouse", + "input-gaming": "sports_esports", + "phone": "phone_android", + "computer": "computer", + "printer": "print", + "camera": "photo_camera", + "unknown": "bluetooth" + } + return iconMap[dbusIcon] || "bluetooth" + } + + MaterialSymbol { + icon: mapBluetoothIcon(device.icon, device.name) + font.pixelSize: Metrics.fontSize(32) + } + + ColumnLayout { + Layout.alignment: Qt.AlignVCenter + spacing: Metrics.spacing(0) + + StyledText { + text: device.name || device.address + font.pixelSize: Metrics.fontSize(16) + font.bold: true + } + + StyledText { + text: statusText + font.pixelSize: Metrics.fontSize(12) + color: usePrimary + ? Appearance.m3colors.m3primary + : ColorUtils.transparentize(Appearance.m3colors.m3onSurface, 0.6) + } + } + + Item { Layout.fillWidth: true } + + StyledButton { + visible: showConnect + icon: "link" + onClicked: device.connect() + } + + StyledButton { + visible: showDisconnect + icon: "link_off" + onClicked: device.disconnect() + } + + StyledButton { + visible: showPair + icon: "add" + onClicked: device.pair() + } + + StyledButton { + visible: showRemove + icon: "delete" + onClicked: Bluetooth.removeDevice(device) + } +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/settings/LauncherConfig.qml b/.config/quickshell/nucleus-shell/modules/interface/settings/LauncherConfig.qml new file mode 100644 index 0000000..43f7e92 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/settings/LauncherConfig.qml @@ -0,0 +1,79 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Widgets +import qs.config +import qs.modules.components +import qs.services + +ContentMenu { + title: "Launcher" + description: "Adjust launcher's settings." + + ContentCard { + StyledText { + text: "Filters & Search" + font.pixelSize: Metrics.fontSize(20) + font.bold: true + } + + StyledSwitchOption { + title: "Fuzzy Search" + description: "Enable or disable fuzzy search." + prefField: "launcher.fuzzySearchEnabled" + } + + RowLayout { + id: webEngineSelector + + property string title: "Web Search Engine" + property string description: "Choose the web search engine for web searches." + property string prefField: '' + + ColumnLayout { + StyledText { + text: webEngineSelector.title + font.pixelSize: Metrics.fontSize(16) + } + + StyledText { + text: webEngineSelector.description + font.pixelSize: Metrics.fontSize(12) + } + + } + + Item { + Layout.fillWidth: true + } + + StyledDropDown { + label: "Engine" + model: ["Google", "Brave", "DuckDuckGo", "Bing"] + // Set the initial index based on the lowercase value in Config + currentIndex: { + switch (Config.runtime.launcher.webSearchEngine.toLowerCase()) { + case "google": + return 0; + case "brave": + return 1; + case "duckduckgo": + return 2; + case "bing": + return 3; + default: + return 0; + } + } + onSelectedIndexChanged: (index) => { + // Update Config with lowercase version of selected model + Config.updateKey("launcher.webSearchEngine", model[index].toLowerCase()); + } + } + + } + + } + +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/settings/MiscConfig.qml b/.config/quickshell/nucleus-shell/modules/interface/settings/MiscConfig.qml new file mode 100644 index 0000000..0df8d69 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/settings/MiscConfig.qml @@ -0,0 +1,109 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Widgets +import qs.config +import qs.modules.components +import qs.services + +ContentMenu { + title: "Miscellaneous" + description: "Configure misc settings." + + ContentCard { + StyledText { + text: "Versions" + font.pixelSize: Metrics.fontSize(20) + font.bold: true + } + + RowLayout { + id: releaseChannelSelector + + property string title: "Release Channel" + property string description: "Choose the release channel for updates." + property string prefField: '' + + ColumnLayout { + StyledText { + text: releaseChannelSelector.title + font.pixelSize: Metrics.fontSize(16) + } + + StyledText { + text: releaseChannelSelector.description + font.pixelSize: Metrics.fontSize(12) + } + + } + + Item { + Layout.fillWidth: true + } + + StyledDropDown { + label: "Type" + model: ["Stable", "Edge (indev)"] + currentIndex: Config.runtime.shell.releaseChannel === "edge" ? 1 : 0 + onSelectedIndexChanged: (index) => { + Config.updateKey("shell.releaseChannel", index === 1 ? "edge" : "stable"); + UpdateNotifier.notified = false; + } + } + + } + + } + + ContentCard { + StyledText { + text: "Intelligence" + font.pixelSize: Metrics.fontSize(20) + font.bold: true + } + + StyledSwitchOption { + title: "Enabled" + description: "Enable or disable intelligence." + prefField: "misc.intelligence.enabled" + } + + } + + ContentCard { + StyledText { + text: "Intelligence Bearer/API" + font.pixelSize: Metrics.fontSize(20) + font.bold: true + } + + StyledTextField { + id: apiKeyTextField + + clip: true + horizontalAlignment: Text.AlignLeft + placeholderText: Config.runtime.misc.intelligence.apiKey !== "" ? Config.runtime.misc.intelligence.apiKey : "Bearer Key" + Layout.fillWidth: true + Keys.onPressed: (event) => { + if (event.key === Qt.Key_S && (event.modifiers & Qt.ControlModifier)) { + event.accepted = true; + Config.updateKey("misc.intelligence.apiKey", apiKeyTextField.text); + Quickshell.execDetached(["notify-send", "Saved Bearer/API Key"]) + } + } + font.pixelSize: Metrics.fontSize(16) + } + + Item { + width: 20 + } + + InfoCard { + title: "How to save the api key" + description: "In order to save the api key press Ctrl+S and it will save the api key to the config." + } + + } + +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/settings/NetworkCard.qml b/.config/quickshell/nucleus-shell/modules/interface/settings/NetworkCard.qml new file mode 100644 index 0000000..a7ef194 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/settings/NetworkCard.qml @@ -0,0 +1,136 @@ +import QtQuick +import QtQuick.Layouts +import qs.config +import qs.modules.components +import qs.services +import qs.modules.functions + +Item { + id: networkRow + property var connection + property bool isActive: false + property bool showConnect: false + property bool showDisconnect: false + property bool showPasswordField: false + property string password: "" + + width: parent.width + implicitHeight: mainLayout.implicitHeight + + function signalIcon(strength, secure) { + if (!connection) return "network_wifi"; + if (connection.type === "ethernet") return "settings_ethernet"; + if (strength >= 75) return "network_wifi"; + if (strength >= 50) return "network_wifi_3_bar"; + if (strength >= 25) return "network_wifi_2_bar"; + if (strength > 0) return "network_wifi_1_bar"; + return "network_wifi_1_bar"; + } + + ColumnLayout { + id: mainLayout + anchors.fill: parent + spacing: Metrics.spacing(10) + + RowLayout { + spacing: Metrics.spacing(10) + + // Signal icon with lock overlay + Item { + width: Metrics.spacing(32) + height: Metrics.spacing(32) + + MaterialSymbol { + anchors.fill: parent + icon: connection ? signalIcon(connection.strength, connection.isSecure) : "network_wifi" + font.pixelSize: Metrics.fontSize(32) + } + + // Lock overlay (anchors are safe because Item is not layout-managed) + MaterialSymbol { + icon: "lock" + visible: connection && connection.type === "wifi" && connection.isSecure + font.pixelSize: Metrics.fontSize(12) + anchors.right: parent.right + anchors.bottom: parent.bottom + } + } + + ColumnLayout { + Layout.alignment: Qt.AlignVCenter + spacing: Metrics.spacing(0) + + StyledText { + text: connection ? connection.name : "" + font.pixelSize: Metrics.fontSize(16) + font.bold: true + } + + StyledText { + text: connection ? ( + isActive ? "Connected" : + connection.type === "ethernet" ? connection.device || "Ethernet" : + connection.isSecure ? "Secured" : "Open" + ) : "" + font.pixelSize: Metrics.fontSize(12) + color: isActive + ? Appearance.m3colors.m3primary + : ColorUtils.transparentize(Appearance.m3colors.m3onSurface, 0.4) + } + } + + Item { Layout.fillWidth: true } + + StyledButton { + visible: showConnect && !showPasswordField + icon: "link" + onClicked: { + if (!connection) return; + if (connection.type === "ethernet") Network.connect(connection, "") + else if (connection.isSecure) showPasswordField = true + else Network.connect(connection, "") + } + } + + StyledButton { + visible: showDisconnect && !showPasswordField + icon: "link_off" + onClicked: Network.disconnect() + } + } + + // Password row + RowLayout { + visible: showPasswordField && connection && connection.type === "wifi" + spacing: Metrics.spacing(10) + + StyledTextField { + padding: Metrics.padding(10) + Layout.fillWidth: true + placeholderText: "Enter password" + echoMode: parent.showPassword ? TextInput.Normal : TextInput.Password + onTextChanged: networkRow.password = text + onAccepted: { + if (!connection) return; + Network.connect(connection, networkRow.password) + showPasswordField = false + } + } + + StyledButton { + property bool showPassword: false + icon: parent.showPassword ? "visibility" : "visibility_off" + onClicked: parent.showPassword = !parent.showPassword + } + + StyledButton { + icon: "link" + onClicked: { + if (!connection) return; + Network.connect(connection, networkRow.password) + showPasswordField = false + } + } + } + } +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/settings/NetworkConfig.qml b/.config/quickshell/nucleus-shell/modules/interface/settings/NetworkConfig.qml new file mode 100644 index 0000000..86c57c9 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/settings/NetworkConfig.qml @@ -0,0 +1,172 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.functions +import qs.config +import qs.modules.components +import qs.services + +ContentMenu { + title: "Network" + description: "Manage network connections." + + ContentCard { + ContentRowCard { + cardSpacing: Metrics.spacing(0) + verticalPadding: Network.wifiEnabled ? Metrics.padding(10) : Metrics.padding(0) + cardMargin: Metrics.margin(0) + + StyledText { + text: powerSwitch.checked ? "Wi-Fi: On" : "Wi-Fi: Off" + font.pixelSize: Metrics.fontSize(16) + font.bold: true + } + + Item { Layout.fillWidth: true } + + StyledSwitch { + id: powerSwitch + checked: Network.wifiEnabled + onToggled: Network.enableWifi(checked) + } + } + + ContentRowCard { + visible: Network.wifiEnabled + cardSpacing: Metrics.spacing(0) + verticalPadding: Metrics.padding(10) + cardMargin: Metrics.margin(0) + + ColumnLayout { + spacing: Metrics.spacing(2) + + StyledText { + text: "Scanning" + font.pixelSize: Metrics.fontSize(16) + } + + StyledText { + text: "Search for nearby Wi-Fi networks." + font.pixelSize: Metrics.fontSize(12) + color: ColorUtils.transparentize( + Appearance.m3colors.m3onSurface, 0.4 + ) + } + } + + Item { Layout.fillWidth: true } + + StyledSwitch { + checked: Network.scanning + onToggled: { + if (checked) + Network.rescan() + } + } + } + } + + InfoCard { + visible: Network.message !== "" && Network.message !== "ok" + icon: "error" + backgroundColor: Appearance.m3colors.m3error + contentColor: Appearance.m3colors.m3onError + title: "Failed to connect to " + Network.lastNetworkAttempt + description: Network.message + } + + ContentCard { + visible: Network.active !== null + + StyledText { + text: "Active Connection" + font.pixelSize: Metrics.fontSize(18) + font.bold: true + } + + NetworkCard { + connection: Network.active + isActive: true + showDisconnect: Network.active?.type === "wifi" + } + } + + ContentCard { + visible: Network.connections.filter(c => c.type === "ethernet").length > 0 + + StyledText { + text: "Ethernet" + font.pixelSize: Metrics.fontSize(18) + font.bold: true + } + + Repeater { + model: Network.connections.filter(c => c.type === "ethernet" && !c.active) + delegate: NetworkCard { + connection: modelData + showConnect: true + } + } + } + + ContentCard { + visible: Network.wifiEnabled + + StyledText { + text: "Available Wi-Fi Networks" + font.pixelSize: Metrics.fontSize(18) + font.bold: true + } + + Item { + visible: Network.connections.filter(c => c.type === "wifi").length === 0 && !Network.scanning + width: parent.width + height: Metrics.spacing(40) + + StyledText { + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter + text: "No networks found" + font.pixelSize: Metrics.fontSize(14) + color: ColorUtils.transparentize(Appearance.m3colors.m3onSurface, 0.4) + } + + } + + Repeater { + model: Network.connections.filter(c => c.type === "wifi" && !c.active) + delegate: NetworkCard { + connection: modelData + showConnect: true + } + } + } + + ContentCard { + visible: Network.savedNetworks.length > 0 + StyledText { + text: "Remembered Networks" + font.pixelSize: Metrics.fontSize(18) + font.bold: true + } + + Item { + visible: Network.savedNetworks.length === 0 + width: parent.width + height: Metrics.spacing(40) + StyledText { + anchors.left: parent.left + text: "No remembered networks" + font.pixelSize: Metrics.fontSize(14) + color: Appearance.colors.colSubtext + } + } + + Repeater { + model: Network.connections.filter(c => c.type === "wifi" && c.saved && !c.active) + delegate: NetworkCard { + connection: modelData + showConnect: false + showDisconnect: false + } + } + } +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/settings/NotificationConfig.qml b/.config/quickshell/nucleus-shell/modules/interface/settings/NotificationConfig.qml new file mode 100644 index 0000000..edd501f --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/settings/NotificationConfig.qml @@ -0,0 +1,215 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Widgets +import qs.config +import qs.modules.components +import qs.services +import qs.modules.functions + +ContentMenu { + title: "Notifications & Overlays" + description: "Adjust notification and overlay settings." + + function indexFromPosition(pos, model) { + pos = pos.toLowerCase() + for (let i = 0; i < model.length; i++) { + if (model[i].toLowerCase().replace(" ", "-") === pos) + return i + } + return 0 + } + + ContentCard { + + StyledText { + text: "Notifications" + font.pixelSize: Metrics.fontSize(20) + font.bold: true + } + + StyledSwitchOption { + title: "Enabled" + description: "Enable or disable built-in notification daemon." + prefField: "notifications.enabled" + } + + StyledSwitchOption { + title: "Do not disturb enabled" + description: "Enable or disable dnd." + prefField: "notifications.doNotDisturb" + } + + RowLayout { + + ColumnLayout { + StyledText { + text: "Notification Position" + font.pixelSize: Metrics.fontSize(16) + } + + StyledText { + text: "Select where notification will be shown." + font.pixelSize: Metrics.fontSize(12) + } + } + + Item { Layout.fillWidth: true } + + StyledDropDown { + id: notificationDropdown + label: "Position" + + property var positions: ["Top Left", "Top Right", "Top"] + + model: positions + + currentIndex: + indexFromPosition( + Config.runtime.notifications.position, + positions + ) + + onSelectedIndexChanged: function(index) { + Config.updateKey( + "notifications.position", + positions[index].toLowerCase().replace(" ", "-") + ) + } + } + } + + RowLayout { + + ColumnLayout { + StyledText { + text: "Test Notifications" + font.pixelSize: Metrics.fontSize(16) + } + + StyledText { + text: "Run a test notification." + font.pixelSize: Metrics.fontSize(12) + } + } + + Item { Layout.fillWidth: true } + + StyledButton { + text: "Test" + icon: "chat" + + onClicked: + Quickshell.execDetached([ + "notify-send", + "Quickshell", + "This is a test notification" + ]) + } + } + } + + ContentCard { + + StyledText { + text: "Overlays / OSDs" + font.pixelSize: Metrics.fontSize(20) + font.bold: true + } + + StyledSwitchOption { + title: "Enabled" + description: "Enable or disable built-in osd daemon." + prefField: "overlays.enabled" + } + + StyledSwitchOption { + title: "Volume OSD enabled" + description: "Enable or disable volume osd." + prefField: "overlays.volumeOverlayEnabled" + } + + StyledSwitchOption { + title: "Brightness OSD enabled" + description: "Enable or disable brightness osd." + prefField: "overlays.brightnessOverlayEnabled" + } + + RowLayout { + + ColumnLayout { + StyledText { + text: "Brightness OSD Position" + font.pixelSize: Metrics.fontSize(16) + } + + StyledText { + text: "Choose where brightness OSD is shown." + font.pixelSize: Metrics.fontSize(12) + } + } + + Item { Layout.fillWidth: true } + + StyledDropDown { + + property var positions: + ["Top Left","Top Right","Bottom Left","Bottom Right","Top","Bottom"] + + model: positions + + currentIndex: + indexFromPosition( + Config.runtime.overlays.brightnessOverlayPosition, + positions + ) + + onSelectedIndexChanged: function(index) { + Config.updateKey( + "overlays.brightnessOverlayPosition", + positions[index].toLowerCase().replace(" ", "-") + ) + } + } + } + + RowLayout { + + ColumnLayout { + StyledText { + text: "Volume OSD Position" + font.pixelSize: Metrics.fontSize(16) + } + + StyledText { + text: "Choose where volume OSD is shown." + font.pixelSize: Metrics.fontSize(12) + } + } + + Item { Layout.fillWidth: true } + + StyledDropDown { + + property var positions: + ["Top Left","Top Right","Bottom Left","Bottom Right","Top","Bottom"] + + model: positions + + currentIndex: + indexFromPosition( + Config.runtime.overlays.volumeOverlayPosition, + positions + ) + + onSelectedIndexChanged: function(index) { + Config.updateKey( + "overlays.volumeOverlayPosition", + positions[index].toLowerCase().replace(" ", "-") + ) + } + } + } + } +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/settings/Plugins.qml b/.config/quickshell/nucleus-shell/modules/interface/settings/Plugins.qml new file mode 100644 index 0000000..0ce7420 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/settings/Plugins.qml @@ -0,0 +1,51 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Widgets +import qs.config +import qs.modules.components +import qs.plugins + +ContentMenu { + title: "Plugins" + description: "Modify and Customize Installed Plugins." + + ContentCard { + Layout.fillWidth: true + Layout.preferredHeight: implicitHeight + color: "transparent" + + GridLayout { + id: grid + columns: 1 + Layout.fillWidth: true + columnSpacing: Metrics.spacing(16) + rowSpacing: Metrics.spacing(16) + + StyledText { + text: "Plugins not found!" + font.pixelSize: Metrics.fontSize(20) + font.bold: true + visible: PluginLoader.plugins.length === 0 + Layout.alignment: Qt.AlignHCenter + } + + Repeater { + model: PluginLoader.plugins + + delegate: ContentCard { + Layout.fillWidth: true + + Loader { + Layout.fillWidth: true + asynchronous: true + source: Qt.resolvedUrl( + Directories.shellConfig + "/plugins/" + modelData + "/Settings.qml" + ) + } + } + } + } + } +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/settings/Settings.qml b/.config/quickshell/nucleus-shell/modules/interface/settings/Settings.qml new file mode 100644 index 0000000..eefe720 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/settings/Settings.qml @@ -0,0 +1,201 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Window +import Quickshell +import Quickshell.Io +import Quickshell.Widgets +import qs.config +import qs.modules.functions +import qs.modules.components +import qs.services + +Scope { + property var settingsWindow: null + + IpcHandler { + function open(menu: string) { + Globals.states.settingsOpen = true; + + if (menu !== "" && settingsWindow !== null) { + for (var i = 0; i < settingsWindow.menuModel.length; i++) { + var item = settingsWindow.menuModel[i]; + if (!item.header && item.label.toLowerCase() === menu.toLowerCase()) { + settingsWindow.selectedIndex = item.page; + break; + } + } + } + } + target: "settings" + } + + LazyLoader { + active: Globals.states.settingsOpen + + Window { + id: root + width: 1280 + height: 720 + visible: true + title: "Nucleus - Settings" + color: Appearance.m3colors.m3background + onClosing: Globals.states.settingsOpen = false + Component.onCompleted: settingsWindow = root + + property int selectedIndex: 0 + property bool sidebarCollapsed: false + + property var menuModel: [ + { "header": true, "label": "System" }, + { "icon": "bluetooth", "label": "Bluetooth", "page": 0 }, + { "icon": "network_wifi", "label": "Network", "page": 1 }, + { "icon": "volume_up", "label": "Audio", "page": 2 }, + { "icon": "instant_mix", "label": "Appearance", "page": 3 }, + + { "header": true, "label": "Customization" }, + { "icon": "toolbar", "label": "Bar", "page": 4 }, + { "icon": "wallpaper", "label": "Wallpapers", "page": 5 }, + { "icon": "apps", "label": "Launcher", "page": 6 }, + { "icon": "chat", "label": "Notifications", "page": 7 }, + { "icon": "extension", "label": "Plugins", "page": 8 }, + { "icon": "apps", "label": "Store", "page": 9 }, + { "icon": "build", "label": "Miscellaneous", "page": 10 }, + + { "header": true, "label": "About" }, + { "icon": "info", "label": "About", "page": 11 } + ] + + Item { + anchors.fill: parent + Rectangle { + id: sidebarBG + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: root.sidebarCollapsed ? 80 : 350 + color: Appearance.m3colors.m3surfaceContainerLow + + ColumnLayout { + anchors.fill: parent + anchors.margins: Metrics.margin(40) + spacing: Metrics.spacing(5) + + RowLayout { + Layout.fillWidth: true + + StyledText { + Layout.fillWidth: true + text: "Settings" + font.family: "Outfit ExtraBold" + font.pixelSize: Metrics.fontSize(28) + visible: !root.sidebarCollapsed + } + + StyledButton { + Layout.preferredHeight: 40 + icon: root.sidebarCollapsed ? "chevron_right" : "chevron_left" + secondary: true + onClicked: root.sidebarCollapsed = !root.sidebarCollapsed + } + } + + ListView { + id: sidebarList + Layout.fillWidth: true + Layout.fillHeight: true + model: root.menuModel + spacing: Metrics.spacing(5) + clip: true + + delegate: Item { + width: sidebarList.width + height: modelData.header ? (root.sidebarCollapsed ? 0 : 30) : 42 + visible: !modelData.header || !root.sidebarCollapsed + + // header + Item { + width: parent.width + height: parent.height + + StyledText { + y: (parent.height - height) * 0.5 + x: 10 + text: modelData.label + font.pixelSize: Metrics.fontSize(14) + font.bold: true + opacity: modelData.header ? 1 : 0 + } + + } + + Rectangle { + anchors.fill: parent + visible: !modelData.header + radius: Appearance.rounding.large + color: root.selectedIndex === modelData.page + ? Appearance.m3colors.m3primary + : "transparent" + + RowLayout { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: 10 + spacing: 10 + + MaterialSymbol { + visible: !modelData.header + icon: modelData.icon ? modelData.icon : "" + iconSize: Metrics.iconSize(24) + } + + StyledText { + text: modelData.label + visible: !root.sidebarCollapsed + } + } + } + + MouseArea { + anchors.fill: parent + enabled: modelData.page !== undefined + onClicked: { + root.selectedIndex = modelData.page + settingsStack.currentIndex = modelData.page + } + } + } + } + } + + Behavior on width { + NumberAnimation { duration: 180; easing.type: Easing.InOutCubic } + } + } + + + StackLayout { + id: settingsStack + anchors.left: sidebarBG.right + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + currentIndex: root.selectedIndex + + BluetoothConfig { Layout.fillWidth: true; Layout.fillHeight: true } + NetworkConfig { Layout.fillWidth: true; Layout.fillHeight: true } + AudioConfig { Layout.fillWidth: true; Layout.fillHeight: true } + AppearanceConfig { Layout.fillWidth: true; Layout.fillHeight: true } + BarConfig { Layout.fillWidth: true; Layout.fillHeight: true } + WallpaperConfig { Layout.fillWidth: true; Layout.fillHeight: true } + LauncherConfig { Layout.fillWidth: true; Layout.fillHeight: true } + NotificationConfig { Layout.fillWidth: true; Layout.fillHeight: true } + Plugins { Layout.fillWidth: true; Layout.fillHeight: true } + Store { Layout.fillWidth: true; Layout.fillHeight: true } + MiscConfig { Layout.fillWidth: true; Layout.fillHeight: true } + About { Layout.fillWidth: true; Layout.fillHeight: true } + } + } + } + } +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/settings/Store.qml b/.config/quickshell/nucleus-shell/modules/interface/settings/Store.qml new file mode 100644 index 0000000..843f422 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/settings/Store.qml @@ -0,0 +1,104 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Widgets +import qs.config +import qs.modules.components +import qs.plugins + +ContentMenu { + title: "Store" + description: "Manage plugins and other stuff for the shell." + + ContentCard { + Layout.fillWidth: true + + GridLayout { + columns: 1 + Layout.fillWidth: true + columnSpacing: Metrics.spacing(16) + rowSpacing: Metrics.spacing(16) + + Repeater { + model: PluginParser.model + + delegate: StyledRect { + Layout.preferredHeight: 90 + Layout.fillWidth: true + radius: Metrics.radius("small") + color: Appearance.m3colors.m3surfaceContainer + + RowLayout { + anchors.fill: parent + anchors.margins: Metrics.margin("normal") + spacing: Metrics.spacing(12) + + Column { + Layout.fillWidth: true + spacing: Metrics.spacing(2) + + StyledText { + font.pixelSize: Metrics.fontSize("large") + text: name + } + + RowLayout { + spacing: Metrics.spacing(6) + + StyledText { + font.pixelSize: Metrics.fontSize("small") + text: author + color: Appearance.colors.colSubtext + } + + StyledText { + font.pixelSize: Metrics.fontSize("small") + text: "| Requires Nucleus " + requires_nucleus + color: Appearance.colors.colSubtext + } + } + + StyledText { + font.pixelSize: Metrics.fontSize("normal") + text: description + color: Appearance.colors.colSubtext + } + } + + RowLayout { + spacing: Metrics.spacing(8) + + StyledButton { + icon: "download" + text: "Install" + visible: !installed + secondary: true + Layout.preferredWidth: 140 + onClicked: PluginParser.install(id) + } + + StyledButton { + icon: "update" + text: "Update" + visible: installed + secondary: true + Layout.preferredWidth: 140 + onClicked: PluginParser.update(id) + } + + StyledButton { + icon: "delete" + text: "Remove" + visible: installed + secondary: true + Layout.preferredWidth: 140 + onClicked: PluginParser.uninstall(id) + } + } + } + } + } + } + } +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/settings/WallpaperConfig.qml b/.config/quickshell/nucleus-shell/modules/interface/settings/WallpaperConfig.qml new file mode 100644 index 0000000..68e7480 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/settings/WallpaperConfig.qml @@ -0,0 +1,294 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Widgets +import qs.config +import qs.modules.components +import qs.services + +ContentMenu { + property string displayName: root.screen?.name ?? "" + property var intervalOptions: [{ + "value": 5, + "label": "5 minutes" + }, { + "value": 15, + "label": "15 minutes" + }, { + "value": 30, + "label": "30 minutes" + }, { + "value": 60, + "label": "1 hour" + }, { + "value": 120, + "label": "2 hours" + }, { + "value": 360, + "label": "6 hours" + }] + + function getIntervalIndex(minutes) { + for (let i = 0; i < intervalOptions.length; i++) { + if (intervalOptions[i].value === minutes) + return i; + } + return 0; + } + + title: "Wallpaper" + description: "Manage your wallpapers" + + ContentCard { + ClippingRectangle { + id: wpContainer + + Layout.alignment: Qt.AlignHCenter + width: root.screen.width / 2 + height: width * root.screen.height / root.screen.width + radius: Metrics.radius("unsharpenmore") + color: Appearance.m3colors.m3surfaceContainer + + StyledText { + text: "Current Wallpaper:" + font.pixelSize: Metrics.fontSize("big") + font.bold: true + } + + ClippingRectangle { + id: wpPreview + + Layout.alignment: Qt.AlignHCenter | Qt.AlignCenter + anchors.fill: parent + radius: Metrics.radius("unsharpenmore") + color: Appearance.m3colors.m3paddingContainer + layer.enabled: true + + StyledText { + opacity: !Config.runtime.appearance.background.enabled ? 1 : 0 + font.pixelSize: Metrics.fontSize("title") + text: "Wallpaper Manager Disabled" + anchors.centerIn: parent + + Behavior on opacity { + enabled: Config.runtime.appearance.animations.enabled + Anim { } + } + } + + Image { + opacity: Config.runtime.appearance.background.enabled ? 1 : 0 + anchors.fill: parent + source: previewImg + "?t=" + Date.now() + property string previewImg: { + const displays = Config.runtime.monitors + const fallback = Config.runtime.appearance.background.defaultPath + + if (!displays) + return fallback + + const monitor = displays?.[displayName] + return monitor?.wallpaper ?? fallback + } + fillMode: Image.PreserveAspectCrop + cache: true + + Behavior on opacity { + enabled: Config.runtime.appearance.animations.enabled + Anim { } + } + } + } + } + + StyledButton { + icon: "wallpaper" + text: "Change Wallpaper" + Layout.fillWidth: true + onClicked: { + Quickshell.execDetached(["nucleus", "ipc", "call", "background", "change"]); + } + } + + StyledSwitchOption { + title: "Enabled" + description: "Enabled or disable built-in wallpaper daemon." + prefField: "appearance.background.enabled" + } + } + + ContentCard { + StyledText { + text: "Parallax Effect" + font.pixelSize: Metrics.fontSize("big") + font.bold: true + } + + StyledSwitchOption { + title: "Enabled" + description: "Enabled or disable wallpaper parallax effect." + prefField: "appearance.background.parallax.enabled" + } + + StyledSwitchOption { + title: "Enabled for Sidebar Left" + description: "Show parralax effect when sidebarLeft is opened." + prefField: "appearance.background.parallax.enableSidebarLeft" + } + + StyledSwitchOption { + title: "Enabled for Sidebar Right" + description: "Show parralax effect when sidebarRight is opened." + prefField: "appearance.background.parallax.enableSidebarRight" + } + + NumberStepper { + label: "Zoom Amount" + description: "Adjust the zoom of the parallax effect." + prefField: "appearance.background.parallax.zoom" + step: 0.1 + minimum: 1.10 + maximum: 2 + } + } + + ContentCard { + StyledText { + text: "Wallpaper Slideshow" + font.pixelSize: Metrics.fontSize("big") + font.bold: true + } + + StyledSwitchOption { + title: "Enable Slideshow" + description: "Automatically rotate wallpapers from a folder." + prefField: "appearance.background.slideshow.enabled" + } + + StyledSwitchOption { + title: "Include Subfolders" + description: "Also search for wallpapers in subfolders." + prefField: "appearance.background.slideshow.includeSubfolders" + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Metrics.spacing(8) + + RowLayout { + Layout.fillWidth: true + spacing: Metrics.spacing(12) + + ColumnLayout { + Layout.fillWidth: true + spacing: Metrics.spacing(4) + + StyledText { + text: "Wallpaper Folder" + font.pixelSize: Metrics.fontSize("normal") + } + + StyledText { + text: Config.runtime.appearance.background.slideshow.folder || "No folder selected" + font.pixelSize: Metrics.fontSize("small") + color: Appearance.m3colors.m3onSurfaceVariant + elide: Text.ElideMiddle + Layout.fillWidth: true + } + } + + StyledButton { + icon: "folder_open" + text: "Browse" + onClicked: folderPickerProc.running = true + } + } + } + + RowLayout { + id: skipWallpaper + + property string title: "Skip To Next Wallpaper" + property string description: "Skip to the next wallpaper in the wallpaper directory." + property string prefField: '' + + ColumnLayout { + StyledText { + text: skipWallpaper.title + font.pixelSize: Metrics.fontSize("normal") + } + + StyledText { + text: skipWallpaper.description + font.pixelSize: Metrics.fontSize("small") + } + } + + Item { Layout.fillWidth: true } + + StyledButton { + icon: "skip_next" + text: "Skip Next" + enabled: WallpaperSlideshow.wallpapers.length > 0 + onClicked: { + Quickshell.execDetached(["nucleus", "ipc", "call", "background", "next"]); + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Metrics.spacing(12) + + ColumnLayout { + Layout.fillWidth: true + spacing: Metrics.spacing(4) + + StyledText { + text: "Change Interval" + font.pixelSize: Metrics.fontSize("normal") + } + + StyledText { + text: "How often to change the wallpaper." + font.pixelSize: Metrics.fontSize("small") + color: Appearance.m3colors.m3onSurfaceVariant + } + } + + Item { Layout.fillWidth: true } + + StyledDropDown { + label: "Interval" + model: intervalOptions.map((opt) => { + return opt.label; + }) + currentIndex: getIntervalIndex(Config.runtime.appearance.background.slideshow.interval) + onSelectedIndexChanged: (index) => { + Config.updateKey("appearance.background.slideshow.interval", intervalOptions[index].value); + } + } + } + } + + Process { + id: folderPickerProc + + command: ["bash", Directories.scriptsPath + "/interface/selectfolder.sh", Config.runtime.appearance.background.slideshow.folder || Directories.pictures] + + stdout: StdioCollector { + onStreamFinished: { + const out = text.trim(); + if (out !== "null" && out.length > 0) + Config.updateKey("appearance.background.slideshow.folder", out); + } + } + } + + component Anim: NumberAnimation { + duration: Metrics.chronoDuration(400) + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.animation.curves.standard + } +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/sidebarLeft/IntelligencePanel.qml b/.config/quickshell/nucleus-shell/modules/interface/sidebarLeft/IntelligencePanel.qml new file mode 100644 index 0000000..f4a80d8 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/sidebarLeft/IntelligencePanel.qml @@ -0,0 +1,525 @@ +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import qs.config +import qs.modules.functions +import qs.modules.components +import qs.services + +Item { + id: root + + property bool initialChatSelected: false + + function appendMessage(sender, message) { + messageModel.append({ + "sender": sender, + "message": message + }); + scrollToBottom(); + } + + function updateChatsList(files) { + let existing = { + }; + for (let i = 0; i < chatListModel.count; i++) existing[chatListModel.get(i).name] = true + for (let file of files) { + let name = file.trim(); + if (!name.length) + continue; + + if (name.endsWith(".txt")) + name = name.slice(0, -4); + + if (!existing[name]) + chatListModel.append({ + "name": name + }); + + delete existing[name]; + } + // remove chats that no longer exist + for (let name in existing) { + for (let i = 0; i < chatListModel.count; i++) { + if (chatListModel.get(i).name === name) { + chatListModel.remove(i); + break; + } + } + } + // ensure default exists + let hasDefault = false; + for (let i = 0; i < chatListModel.count; i++) if (chatListModel.get(i).name === "default") { + hasDefault = true; + } + if (!hasDefault) { + chatListModel.insert(0, { + "name": "default" + }); + FileUtils.createFile(FileUtils.trimFileProtocol(Directories.config) + "/zenith/chats/default.txt"); + } + } + + function scrollToBottom() { + chatView.positionViewAtEnd(); + } + + function sendMessage() { + if (userInput.text === "" || Zenith.loading) + return ; + + Zenith.pendingInput = userInput.text; + appendMessage("You", userInput.text); + userInput.text = ""; + Zenith.loading = true; + Zenith.send(); + } + + function loadChatHistory(chatName) { + messageModel.clear(); + Zenith.loadChat(chatName); + } + + function selectDefaultChat() { + let defaultIndex = -1; + for (let i = 0; i < chatListModel.count; i++) { + if (chatListModel.get(i).name === "default") { + defaultIndex = i; + break; + } + } + if (defaultIndex !== -1) { + chatSelector.currentIndex = defaultIndex; + Zenith.currentChat = "default"; + loadChatHistory("default"); + } else if (chatListModel.count > 0) { + chatSelector.currentIndex = 0; + Zenith.currentChat = chatListModel.get(0).name; + loadChatHistory(Zenith.currentChat); + } + } + + ListModel { + // { sender: "You" | "AI", message: string } + + id: messageModel + } + + ListModel { + id: chatListModel + } + + ColumnLayout { + spacing: Metrics.spacing(8) + anchors.centerIn: parent + + StyledText { + visible: !Config.runtime.misc.intelligence.enabled + text: "Intelligence is disabled!" + Layout.leftMargin: Metrics.margin(24) + font.pixelSize: Appearance.font.size.huge + } + + StyledText { + visible: !Config.runtime.misc.intelligence.enabled + text: "Go to the settings to enable intelligence" + } + + } + + StyledRect { + anchors.topMargin: Metrics.margin(74) + radius: Metrics.radius("normal") + anchors.fill: parent + color: "transparent" + visible: Config.runtime.misc.intelligence.enabled + + ColumnLayout { + anchors.fill: parent + anchors.margins: Metrics.margin(16) + spacing: Metrics.spacing(10) + + RowLayout { + Layout.fillWidth: true + spacing: Metrics.spacing(10) + + StyledDropDown { + id: chatSelector + + Layout.fillWidth: true + model: chatListModel + textRole: "name" + Layout.preferredHeight: 40 + onCurrentIndexChanged: { + if (!initialChatSelected) + return ; + + if (currentIndex < 0 || currentIndex >= chatListModel.count) + return ; + + let chatName = chatListModel.get(currentIndex).name; + Zenith.currentChat = chatName; + loadChatHistory(chatName); + } + } + + StyledButton { + icon: "add" + Layout.preferredWidth: 40 + onClicked: { + let name = "new-chat-" + chatListModel.count; + let path = FileUtils.trimFileProtocol(Directories.config) + "/zenith/chats/" + name + ".txt"; + FileUtils.createFile(path, function(success) { + if (success) { + chatListModel.append({ + "name": name + }); + chatSelector.currentIndex = chatListModel.count - 1; + Zenith.currentChat = name; + messageModel.clear(); + } + }); + } + } + + StyledButton { + icon: "edit" + Layout.preferredWidth: 40 + enabled: chatSelector.currentIndex >= 0 + onClicked: renameDialog.open() + } + + StyledButton { + icon: "delete" + Layout.preferredWidth: 40 + enabled: chatSelector.currentIndex >= 0 && chatSelector.currentText !== "default" + onClicked: { + let name = chatSelector.currentText; + let path = FileUtils.trimFileProtocol(Directories.config) + "/zenith/chats/" + name + ".txt"; + FileUtils.removeFile(path, function(success) { + if (success) { + chatListModel.remove(chatSelector.currentIndex); + selectDefaultChat(); + } + }); + } + } + + } + + RowLayout { + Layout.fillWidth: true + spacing: Metrics.spacing(10) + + StyledDropDown { + id: modelSelector + + Layout.fillWidth: true + model: ["openai/gpt-4o","openai/gpt-4","openai/gpt-3.5-turbo","openai/gpt-4o-mini","anthropic/claude-3.5-sonnet","anthropic/claude-3-haiku","meta-llama/llama-3.3-70b-instruct:free","deepseek/deepseek-r1-0528:free","qwen/qwen3-coder:free"] + currentIndex: 0 + Layout.preferredHeight: 40 + onCurrentTextChanged: Zenith.currentModel = currentText + } + + StyledButton { + icon: "fullscreen" + Layout.preferredWidth: 40 + onClicked: { + Quickshell.execDetached(["nucleus", "ipc", "call", "intelligence", "openWindow"]); + Globals.visiblility.sidebarLeft = false; + } + } + + } + + StyledRect { + Layout.fillWidth: true + Layout.fillHeight: true + radius: Metrics.radius("normal") + color: Appearance.m3colors.m3surfaceContainerLow + + ScrollView { + anchors.fill: parent + clip: true + + ListView { + id: chatView + + model: messageModel + spacing: Metrics.spacing(8) + anchors.fill: parent + anchors.margins: Metrics.margin(12) + clip: true + + delegate: Item { + property bool isCodeBlock: message.split("\n").length > 2 && message.includes("import ") // simple heuristic + + width: chatView.width + height: bubble.implicitHeight + 6 + Component.onCompleted: { + chatView.forceLayout(); + } + + Row { + width: parent.width + spacing: Metrics.spacing(8) + + Item { + width: sender === "AI" ? 0 : parent.width * 0.2 + } + + StyledRect { + id: bubble + + radius: Metrics.radius("normal") + color: sender === "You" ? Appearance.m3colors.m3primaryContainer : Appearance.m3colors.m3surfaceContainerHigh + implicitWidth: Math.min(textItem.implicitWidth + 20, chatView.width * 0.8) + implicitHeight: textItem.implicitHeight + anchors.right: sender === "You" ? parent.right : undefined + anchors.left: sender === "AI" ? parent.left : undefined + anchors.topMargin: Metrics.margin(2) + + TextEdit { + id: textItem + + text: StringUtils.markdownToHtml(message) + wrapMode: TextEdit.Wrap + textFormat: TextEdit.RichText + readOnly: true // make it selectable but not editable + font.pixelSize: Metrics.fontSize(16) + anchors.leftMargin: Metrics.margin(12) + color: Appearance.syntaxHighlightingTheme + padding: Metrics.padding(8) + anchors.fill: parent + } + + MouseArea { + id: ma + + anchors.fill: parent + acceptedButtons: Qt.RightButton + onClicked: { + let p = Qt.createQmlObject('import Quickshell; import Quickshell.Io; Process { command: ["wl-copy", "' + message + '"] }', parent); + p.running = true; + } + } + + } + + Item { + width: sender === "You" ? 0 : parent.width * 0.2 + } + + } + + } + + } + + } + + } + + StyledRect { + Layout.fillWidth: true + height: 50 + radius: Metrics.radius("normal") + color: Appearance.m3colors.m3surfaceContainer + + RowLayout { + anchors.fill: parent + anchors.margins: 6 + spacing: 10 + + StyledTextField { + // Shift+Enter → insert newline + // Enter → send message + + id: userInput + + Layout.fillWidth: true + placeholderText: "Type your message..." + font.pixelSize: Metrics.fontSize(14) + padding: Metrics.padding(8) + Keys.onPressed: { + if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + if (event.modifiers & Qt.ShiftModifier) + insert("\n"); + else + sendMessage(); + event.accepted = true; + } + } + } + + StyledButton { + text: "Send" + enabled: userInput.text.trim().length > 0 && !Zenith.loading + opacity: enabled ? 1 : 0.5 + onClicked: sendMessage() + } + + } + + } + + } + + Dialog { + id: renameDialog + + title: "Rename Chat" + modal: true + visible: false + standardButtons: Dialog.NoButton + x: (root.width - 360) / 2 // center horizontally + y: (root.height - 160) / 2 // center vertically + width: 360 + height: 200 + + ColumnLayout { + anchors.fill: parent + anchors.margins: Metrics.margin(16) + spacing: Metrics.spacing(12) + + StyledText { + text: "Enter a new name for the chat" + font.pixelSize: Metrics.fontSize(18) + horizontalAlignment: Text.AlignHCenter + Layout.fillWidth: true + } + + StyledTextField { + id: renameInput + + Layout.fillWidth: true + placeholderText: "New name" + filled: false + highlight: false + text: chatSelector.currentText + font.pixelSize: Metrics.iconSize(16) + Layout.preferredHeight: 45 + padding: Metrics.padding(8) + } + + RowLayout { + Layout.fillWidth: true + spacing: Metrics.spacing(12) + Layout.alignment: Qt.AlignRight + + StyledButton { + text: "Cancel" + Layout.preferredWidth: 80 + onClicked: renameDialog.close() + } + + StyledButton { + text: "Rename" + Layout.preferredWidth: 100 + enabled: renameInput.text.trim().length > 0 && renameInput.text !== chatSelector.currentText + onClicked: { + let oldName = chatSelector.currentText; + let newName = renameInput.text.trim(); + let oldPath = FileUtils.trimFileProtocol(Directories.config) + "/zenith/chats/" + oldName + ".txt"; + let newPath = FileUtils.trimFileProtocol(Directories.config) + "/zenith/chats/" + newName + ".txt"; + FileUtils.renameFile(oldPath, newPath, function(success) { + if (success) { + chatListModel.set(chatSelector.currentIndex, { + "name": newName + }); + Zenith.currentChat = newName; + renameDialog.close(); + } + }); + } + } + + } + + } + + background: StyledRect { + color: Appearance.m3colors.m3surfaceContainer + radius: Metrics.radius("normal") + border.color: Appearance.colors.colOutline + border.width: 1 + } + + header: StyledRect { + color: Appearance.m3colors.m3surfaceContainer + radius: Metrics.radius("normal") + border.color: Appearance.colors.colOutline + border.width: 1 + } + + } + + StyledText { + text: "Thinking…" + visible: Zenith.loading + color: Appearance.colors.colSubtext + font.pixelSize: Metrics.fontSize(14) + + anchors { + left: parent.left + bottom: parent.bottom + leftMargin: Metrics.margin(22) + bottomMargin: Metrics.margin(76) + } + + } + + } + + Connections { + function onChatsListed(text) { + let lines = text.split(/\r?\n/); + updateChatsList(lines); + // only auto-select once + if (!initialChatSelected) { + selectDefaultChat(); + initialChatSelected = true; + } + } + + function onAiReply(text) { + appendMessage("AI", text.slice(5)); + Zenith.loading = false; + } + + function onChatLoaded(text) { + let lines = text.split(/\r?\n/); + let batch = []; + for (let l of lines) { + let line = l.trim(); + if (!line.length) + continue; + + let u = line.match(/^\[\d{4}-.*\] User: (.*)$/); + let a = line.match(/^\[\d{4}-.*\] AI: (.*)$/); + if (u) + batch.push({ + "sender": "You", + "message": u[1] + }); + else if (a) + batch.push({ + "sender": "AI", + "message": a[1] + }); + else if (batch.length) + batch[batch.length - 1].message += "\n" + line; + } + messageModel.clear(); + for (let m of batch) messageModel.append(m) + scrollToBottom(); + } + + target: Zenith + } + +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/sidebarLeft/SidebarLeft.qml b/.config/quickshell/nucleus-shell/modules/interface/sidebarLeft/SidebarLeft.qml new file mode 100644 index 0000000..d498aa6 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/sidebarLeft/SidebarLeft.qml @@ -0,0 +1,90 @@ +import qs.config +import qs.modules.components +import qs.modules.functions +import qs.services +import QtQuick +import Quickshell +import QtQuick.Layouts +import Quickshell.Wayland +import Quickshell.Io +import Quickshell.Hyprland +import QtQuick.Controls +import Qt5Compat.GraphicalEffects + +PanelWindow { + id: sidebarLeft + WlrLayershell.namespace: "nucleus:sidebarLeft" + WlrLayershell.layer: WlrLayer.Top + visible: Config.initialized && Globals.visiblility.sidebarLeft && !Globals.visiblility.sidebarRight + color: "transparent" + exclusiveZone: 0 + WlrLayershell.keyboardFocus: Compositor.require("hyprland") && Globals.visiblility.sidebarLeft + + property real sidebarLeftWidth: 500 + + implicitWidth: Compositor.screenW + + HyprlandFocusGrab { + id: grab + active: Compositor.require("hyprland") + windows: [sidebarLeft] + } + + anchors { + top: true + left: (Config.runtime.bar.position === "top" || Config.runtime.bar.position === "bottom" || Config.runtime.bar.position === "left") + bottom: true + right: (Config.runtime.bar.position === "right") + } + + margins { + top: Config.runtime.bar.margins + bottom: Config.runtime.bar.margins + left: Metrics.margin("small") + right: Metrics.margin("small") + } + + MouseArea { + anchors.fill: parent + z: 0 + onPressed: Globals.visiblility.sidebarLeft = false + } + + StyledRect { + id: container + z: 1 + color: Appearance.m3colors.m3background + radius: Metrics.radius("large") + width: sidebarLeft.sidebarLeftWidth + + anchors { + top: parent.top + bottom: parent.bottom + left: parent.left + } + + FocusScope { + focus: true + anchors.fill: parent + + Keys.onPressed: { + if (event.key === Qt.Key_Escape) { + Globals.visiblility.sidebarLeft = false; + } + } + + SidebarLeftContent {} + } + } + + function togglesidebarLeft() { + Globals.visiblility.sidebarLeft = !Globals.visiblility.sidebarLeft; + } + + IpcHandler { + target: "sidebarLeft" + function toggle() { + togglesidebarLeft(); + } + } +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/sidebarLeft/SidebarLeftContent.qml b/.config/quickshell/nucleus-shell/modules/interface/sidebarLeft/SidebarLeftContent.qml new file mode 100644 index 0000000..9726f98 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/sidebarLeft/SidebarLeftContent.qml @@ -0,0 +1,142 @@ +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import qs.config +import qs.modules.functions +import qs.modules.components +import qs.services + +Item { + anchors.fill: parent + + SwipeView { + id: view + + anchors.fill: parent + + // IntelligencePanel {} + SystemOverview {} + // WallpapersPage {} + + } + + Rectangle { + height: 2 + width: parent.width - Metrics.margin("verylarge") + color: Appearance.m3colors.m3outlineVariant + opacity: 0.6 + + anchors { + top: view.top + topMargin: segmentedIndicator.height + Metrics.margin("verysmall") + horizontalCenter: view.horizontalCenter + } + + } + + Rectangle { + id: activeTabIndicator + + height: 2 + width: 96 + radius: Metrics.radius(1) + color: Appearance.m3colors.m3primary + x: (segmentedIndicator.width / view.count) * view.currentIndex + (segmentedIndicator.width / view.count - width) / 2 + + anchors { + top: segmentedIndicator.bottom + topMargin: Metrics.margin(8) + } + + Behavior on x { + NumberAnimation { + duration: Metrics.chronoDuration(220) + easing.type: Easing.OutCubic + } + + } + + } + + Item { + id: segmentedIndicator + + height: 56 + width: parent.width + + anchors { + top: parent.top + horizontalCenter: parent.horizontalCenter + } + + Row { + anchors.fill: parent + spacing: 0 + + Repeater { + model: [ + // { + // "icon": "neurology", + // "text": "Intelligence" + // }, + { + "icon": "overview", + "text": "Overview" + }, + // { + // "icon": "wallpaper", + // "text": "Wallpapers" + // } + ] + + Item { + width: segmentedIndicator.width / view.count + height: parent.height + + MouseArea { + anchors.fill: parent + onClicked: view.currentIndex = index + } + + // Icon (true center) + MaterialSymbol { + icon: modelData.icon + iconSize: Metrics.iconSize("huge") + color: view.currentIndex === index ? Appearance.m3colors.m3primary : Appearance.m3colors.m3onSurfaceVariant + + anchors { + horizontalCenter: parent.horizontalCenter + top: parent.top + topMargin: Metrics.margin(12) + } + + } + + // Label (independent centering) + StyledText { + text: modelData.text + font.pixelSize: Metrics.fontSize("large") + font.weight: Font.Medium + color: view.currentIndex === index ? Appearance.m3colors.m3primary : Appearance.m3colors.m3onSurfaceVariant + + anchors { + horizontalCenter: parent.horizontalCenter + bottom: parent.bottom + bottomMargin: 0 + } + + } + + } + + } + + } + + } + +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/sidebarLeft/SystemOverview.qml b/.config/quickshell/nucleus-shell/modules/interface/sidebarLeft/SystemOverview.qml new file mode 100644 index 0000000..f2ecf9d --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/sidebarLeft/SystemOverview.qml @@ -0,0 +1,486 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import qs.modules.functions +import qs.services +import qs.config +import qs.modules.components + +Item { + id: root + + implicitWidth: 300 + implicitHeight: parent ? parent.height : 500 + + ColumnLayout { + anchors.topMargin: Metrics.margin(90) + anchors.fill: parent + anchors.margins: Metrics.margin("normal") + spacing: Metrics.margin("small") + + // Header + RowLayout { + Layout.fillWidth: true + + RowLayout { + spacing: Metrics.margin("normal") + + StyledText { + text: SystemDetails.osIcon + font.family: Metrics.fontFamily("nerdIcons") + font.pixelSize: Metrics.fontSize(48) + color: Appearance.colors.colPrimary + } + + ColumnLayout { + spacing: Metrics.spacing(2) + + StyledText { + text: SystemDetails.osName + font.pixelSize: Metrics.fontSize("large") + color: Appearance.m3colors.m3onSurface + } + + StyledText { + text: `${SystemDetails.username}@${SystemDetails.hostname}` + font.pixelSize: Metrics.fontSize("small") + color: Appearance.colors.colSubtext + } + + } + + } + + Item { + Layout.fillWidth: true + } + + ColumnLayout { + spacing: Metrics.spacing(2) + Layout.alignment: Qt.AlignRight + + StyledText { + text: `qs ${SystemDetails.qsVersion}` + font.pixelSize: Metrics.fontSize("small") + color: Appearance.colors.colSubtext + } + + StyledText { + text: `nucleus-shell v${Config.runtime.shell.version}` + font.pixelSize: Metrics.fontSize("smaller") + color: Appearance.colors.colSubtext + } + + } + + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: 56 + radius: Metrics.radius("normal") + color: Appearance.colors.colLayer2 + + RowLayout { + anchors.fill: parent + anchors.margins: Metrics.margin("small") + + StyledText { + text: "Uptime" + font.pixelSize: Metrics.fontSize("normal") + color: Appearance.colors.colPrimary + } + + Item { + Layout.fillWidth: true + } + + StyledText { + text: SystemDetails.uptime + font.pixelSize: Metrics.fontSize("small") + color: Appearance.m3colors.m3onSurface + } + + } + + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: 56 + radius: Metrics.radius("normal") + color: Appearance.colors.colLayer2 + + RowLayout { + anchors.fill: parent + anchors.margins: Metrics.margin("small") + + StyledText { + text: "Operating System" + font.pixelSize: Metrics.fontSize("normal") + color: Appearance.colors.colPrimary + } + + Item { + Layout.fillWidth: true + } + + StyledText { + text: SystemDetails.osName + font.pixelSize: Metrics.fontSize("small") + color: Appearance.m3colors.m3onSurface + elide: Text.ElideRight + } + + } + + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: 320 + radius: Metrics.radius("large") + color: Appearance.colors.colLayer2 + + ColumnLayout { + anchors.fill: parent + anchors.margins: Metrics.margin("large") + spacing: Metrics.margin("normal") + + ColumnLayout { + spacing: Metrics.spacing(6) + + RowLayout { + StyledText { + text: "CPU Usage" + color: Appearance.colors.colSubtext + } + + Item { + Layout.fillWidth: true + } + + StyledText { + animate: false + text: SystemDetails.cpuLoad + color: Appearance.colors.colSubtext + } + + } + + Rectangle { + Layout.fillWidth: true + height: 10 + radius: Metrics.radius(5) + color: Appearance.colors.colLayer1 + + Rectangle { + width: parent.width * SystemDetails.cpuPercent + height: parent.height + radius: Metrics.radius(5) + color: Appearance.colors.colPrimary + } + + } + + } + + ColumnLayout { + spacing: Metrics.spacing(6) + + RowLayout { + StyledText { + text: "Ram Usage" + color: Appearance.colors.colSubtext + } + + Item { + Layout.fillWidth: true + } + + StyledText { + animate: false + text: SystemDetails.ramUsage + color: Appearance.colors.colSubtext + } + + } + + Rectangle { + Layout.fillWidth: true + height: 10 + radius: Metrics.radius(5) + color: Appearance.colors.colLayer1 + + Rectangle { + width: parent.width * SystemDetails.ramPercent + height: parent.height + radius: Metrics.radius(5) + color: Appearance.colors.colPrimary + } + + } + + } + + ColumnLayout { + spacing: Metrics.spacing(6) + + RowLayout { + StyledText { + text: "Disk Usage" + color: Appearance.colors.colSubtext + } + + Item { + Layout.fillWidth: true + } + + StyledText { + animate: false + text: SystemDetails.diskUsage + color: Appearance.colors.colSubtext + } + + } + + Rectangle { + Layout.fillWidth: true + height: 10 + radius: Metrics.radius(5) + color: Appearance.colors.colLayer1 + + Rectangle { + width: parent.width * SystemDetails.diskPercent + height: parent.height + radius: Metrics.radius(5) + color: Appearance.colors.colPrimary + } + + } + + } + + ColumnLayout { + spacing: Metrics.spacing(6) + + RowLayout { + StyledText { + text: "Swap Usage" + color: Appearance.colors.colSubtext + } + + Item { + Layout.fillWidth: true + } + + StyledText { + text: SystemDetails.swapUsage + color: Appearance.colors.colSubtext + } + + } + + Rectangle { + Layout.fillWidth: true + height: 10 + radius: Metrics.radius(5) + color: Appearance.colors.colLayer1 + + Rectangle { + width: parent.width * SystemDetails.swapPercent + height: parent.height + radius: Metrics.radius(5) + color: Appearance.colors.colPrimary + } + + } + + } + + } + + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: 72 + radius: Metrics.radius("normal") + color: Appearance.colors.colLayer2 + + RowLayout { + anchors.fill: parent + anchors.margins: Metrics.margin("small") + spacing: Metrics.margin("large") + + ColumnLayout { + spacing: Metrics.spacing(2) + + StyledText { + text: "Kernel" + font.pixelSize: Metrics.fontSize("small") + color: Appearance.colors.colSubtext + } + + StyledText { + text: SystemDetails.kernelVersion + font.pixelSize: Metrics.fontSize("small") + color: Appearance.m3colors.m3onSurface + } + + } + + Item { + Layout.fillWidth: true + } + + ColumnLayout { + spacing: Metrics.spacing(2) + Layout.alignment: Qt.AlignRight + + StyledText { + text: "Architecture" + font.pixelSize: Metrics.fontSize("small") + color: Appearance.colors.colSubtext + horizontalAlignment: Text.AlignRight + } + + StyledText { + text: SystemDetails.architecture + font.pixelSize: Metrics.fontSize("small") + color: Appearance.m3colors.m3onSurface + horizontalAlignment: Text.AlignRight + } + + } + + } + + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: 72 + radius: Metrics.radius("normal") + color: Appearance.colors.colLayer2 + visible: UPower.batteryPresent + + RowLayout { + anchors.fill: parent + anchors.margins: Metrics.margin("small") + spacing: Metrics.margin("large") + + ColumnLayout { + spacing: Metrics.spacing(2) + + StyledText { + text: "Battery" + font.pixelSize: Metrics.fontSize("small") + color: Appearance.colors.colSubtext + } + + StyledText { + text: `${Math.round(UPower.percentage)}%` + font.pixelSize: Metrics.fontSize("small") + color: Appearance.m3colors.m3onSurface + } + + } + + Item { + Layout.fillWidth: true + } + + ColumnLayout { + spacing: Metrics.spacing(2) + Layout.alignment: Qt.AlignRight + + StyledText { + text: "AC" + font.pixelSize: Metrics.fontSize("small") + color: Appearance.colors.colSubtext + horizontalAlignment: Text.AlignRight + } + + StyledText { + text: UPower.acOnline ? "online" : "battery" + font.pixelSize: Metrics.fontSize("small") + color: Appearance.m3colors.m3onSurface + horizontalAlignment: Text.AlignRight + } + + } + + } + + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: 56 + radius: Metrics.radius("normal") + color: Appearance.colors.colLayer2 + + RowLayout { + anchors.fill: parent + anchors.margins: Metrics.margin("small") + + StyledText { + text: "Running Processes" + font.pixelSize: Metrics.fontSize("normal") + color: Appearance.colors.colPrimary + } + + Item { + Layout.fillWidth: true + } + + StyledText { + text: SystemDetails.runningProcesses + font.pixelSize: Metrics.fontSize("small") + color: Appearance.m3colors.m3onSurface + } + + } + + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: 56 + radius: Metrics.radius("normal") + color: Appearance.colors.colLayer2 + + RowLayout { + anchors.fill: parent + anchors.margins: Metrics.margin("small") + + StyledText { + text: "Logged-in Users" + font.pixelSize: Metrics.fontSize("normal") + color: Appearance.colors.colPrimary + } + + Item { + Layout.fillWidth: true + } + + StyledText { + text: SystemDetails.loggedInUsers + font.pixelSize: Metrics.fontSize("small") + color: Appearance.m3colors.m3onSurface + } + + } + + } + + Item { + Layout.fillHeight: true + } + + } + +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/sidebarLeft/WallpapersPage.qml b/.config/quickshell/nucleus-shell/modules/interface/sidebarLeft/WallpapersPage.qml new file mode 100644 index 0000000..ebab61c --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/sidebarLeft/WallpapersPage.qml @@ -0,0 +1,114 @@ +import Qt.labs.folderlistmodel +import Qt5Compat.GraphicalEffects +import QtCore +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import qs.modules.functions +import qs.services +import qs.config +import qs.modules.components + +Item { + id: wallpapersPage + property string displayName: screen?.name ?? "" + + FolderListModel { + id: wallpaperModel + + folder: StandardPaths.writableLocation(StandardPaths.PicturesLocation) + "/Wallpapers" + nameFilters: ["*.png", "*.jpg", "*.jpeg", "*.webp", "mp4", "mkv", "webm", "avi", "mov", "flv", "wmv", "m4v"] + showDirs: false + showDotAndDotDot: false + } + + // EMPTY STATE + StyledText { + visible: wallpaperModel.count === 0 + text: "Put some wallpapers in\n~/Pictures/Wallpapers" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + color: Appearance.m3colors.m3onSurfaceVariant + font.pixelSize: Appearance.font.size.large + anchors.centerIn: parent + } + + // WALLPAPER LIST + ListView { + anchors.topMargin: 90 + visible: wallpaperModel.count > 0 + anchors.fill: parent + model: wallpaperModel + spacing: Appearance.margin.normal + clip: true + + delegate: Item { + width: ListView.view.width + height: 240 + + StyledRect { + id: imgContainer + property bool activeWallpaper: + Config.runtime.monitors?.[wallpapersPage.displayName]?.wallpaper === fileUrl + + anchors.fill: parent + anchors.leftMargin: 20 + anchors.rightMargin: 20 + radius: Appearance.rounding.normal + color: activeWallpaper + ? Appearance.m3colors.m3secondaryContainer + : Appearance.m3colors.m3surfaceContainerLow + layer.enabled: true + + Image { + id: wallImg + + anchors.fill: parent + source: fileUrl + fillMode: Image.PreserveAspectCrop + asynchronous: true + cache: true + clip: true + } + + StyledText { + anchors.centerIn: parent + text: "Unsupported / Corrupted Image" + visible: wallImg.status === Image.Error + } + + MouseArea { + anchors.fill: parent + onClicked: { + Config.updateKey( + "monitors." + wallpapersPage.displayName + ".wallpaper", + fileUrl + ); + if (Config.runtime.appearance.colors.autogenerated) { + Quickshell.execDetached([ + "nucleus", "ipc", "call", "global", "regenColors" + ]); + } + } + cursorShape: Qt.PointingHandCursor + } + + layer.effect: OpacityMask { + + maskSource: Rectangle { + width: imgContainer.width + height: imgContainer.height + radius: imgContainer.radius + } + + } + + } + + } + + } + +} \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/modules/interface/sidebarRight/SidebarRight.qml b/.config/quickshell/nucleus-shell/modules/interface/sidebarRight/SidebarRight.qml new file mode 100644 index 0000000..e813ba8 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/sidebarRight/SidebarRight.qml @@ -0,0 +1,97 @@ +import qs.config +import qs.modules.components +import qs.modules.functions +import qs.services +import QtQuick +import Quickshell +import QtQuick.Layouts +import Quickshell.Wayland +import Quickshell.Io +import Quickshell.Hyprland +import QtQuick.Controls +import Quickshell.Services.Pipewire +import Qt5Compat.GraphicalEffects + +PanelWindow { + id: sidebarRight + WlrLayershell.namespace: "nucleus:sidebarRight" + WlrLayershell.layer: WlrLayer.Top + visible: Config.initialized && Globals.visiblility.sidebarRight && !Globals.visiblility.sidebarLeft + color: "transparent" + exclusiveZone: 0 + WlrLayershell.keyboardFocus: Compositor.require("hyprland") && Globals.visiblility.sidebarRight + + property real sidebarRightWidth: 500 + + implicitWidth: Compositor.screenW + + HyprlandFocusGrab { + id: grab + active: Compositor.require("hyprland") + windows: [sidebarRight] + } + + anchors { + top: true + right: (Config.runtime.bar.position === "top" || Config.runtime.bar.position === "bottom" || Config.runtime.bar.position === "right") + bottom: true + left: (Config.runtime.bar.position === "left") + } + + margins { + top: Config.runtime.bar.margins + bottom: Config.runtime.bar.margins + left: Metrics.margin("small") + right: Metrics.margin("small") + } + + PwObjectTracker { + objects: [Pipewire.defaultAudioSink] + } + + property var sink: Pipewire.defaultAudioSink?.audio + + MouseArea { + anchors.fill: parent + z: 0 + onPressed: Globals.visiblility.sidebarRight = false + } + + StyledRect { + id: container + z: 1 + color: Appearance.m3colors.m3background + radius: Metrics.radius("large") + width: sidebarRight.sidebarRightWidth + + anchors { + top: parent.top + bottom: parent.bottom + right: parent.right + } + + FocusScope { + focus: true + anchors.fill: parent + + Keys.onPressed: { + if (event.key === Qt.Key_Escape) { + Globals.visiblility.sidebarRight = false; + } + } + + SidebarRightContent {} + } + } + + function togglesidebarRight() { + Globals.visiblility.sidebarRight = !Globals.visiblility.sidebarRight; + } + + IpcHandler { + target: "sidebarRight" + function toggle() { + togglesidebarRight(); + } + } +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/sidebarRight/SidebarRightContent.qml b/.config/quickshell/nucleus-shell/modules/interface/sidebarRight/SidebarRightContent.qml new file mode 100644 index 0000000..8780c94 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/sidebarRight/SidebarRightContent.qml @@ -0,0 +1,247 @@ +import qs.config +import qs.modules.components +import qs.services +import qs.modules.functions +import QtQuick +import Quickshell +import QtQuick.Layouts +import Quickshell.Wayland +import Quickshell.Io +import QtQuick.Controls +import Quickshell.Services.Pipewire +import Qt5Compat.GraphicalEffects +import "content/" + +Item { + anchors.fill: parent + anchors.leftMargin: Metrics.margin("normal") + anchors.rightMargin: Metrics.margin("normal") + anchors.topMargin: Metrics.margin("large") + anchors.bottomMargin: Metrics.margin("large") + + ColumnLayout { + id: mainLayout + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: Metrics.margin("tiny") + anchors.rightMargin: Metrics.margin("tiny") + anchors.margins: Metrics.margin("large") + spacing: Metrics.margin("large") + + RowLayout { + id: topSection + Layout.fillWidth: true + + ColumnLayout { + Layout.fillWidth: true + Layout.leftMargin: Metrics.margin(10) + Layout.alignment: Qt.AlignVCenter + spacing: Metrics.spacing(2) + + RowLayout { + spacing: Metrics.spacing(8) + + StyledText { + text: SystemDetails.osIcon + font.pixelSize: Metrics.fontSize("hugeass") + 6 + } + + StyledText { + text: SystemDetails.uptime + font.pixelSize: Metrics.fontSize("large") + Layout.alignment: Qt.AlignBottom + Layout.bottomMargin: Metrics.margin(5) + } + } + } + + Item { Layout.fillWidth: true } + + Row { + spacing: Metrics.spacing(6) + Layout.leftMargin: Metrics.margin(25) + Layout.alignment: Qt.AlignVCenter + + StyledRect { + id: screenshotbtncontainer + color: "transparent" + radius: Metrics.radius("large") + implicitHeight: screenshotButton.height + Metrics.margin("tiny") + implicitWidth: screenshotButton.width + Metrics.margin("small") + Layout.alignment: Qt.AlignRight | Qt.AlignTop + Layout.topMargin: Metrics.margin(10) + Layout.leftMargin: Metrics.margin(15) + + MaterialSymbolButton { + id: screenshotButton + icon: "edit" + anchors.centerIn: parent + iconSize: Metrics.iconSize("hugeass") + 2 + tooltipText: "Take a screenshot" + + onButtonClicked: { + Quickshell.execDetached(["nucleus", "ipc", "call", "screen", "capture"]) + Globals.visiblility.sidebarRight = false; + } + } + } + + StyledRect { + id: reloadbtncontainer + color: "transparent" + radius: Metrics.radius("large") + implicitHeight: reloadButton.height + Metrics.margin("tiny") + implicitWidth: reloadButton.width + Metrics.margin("small") + Layout.alignment: Qt.AlignRight | Qt.AlignTop + Layout.topMargin: Metrics.margin(10) + Layout.leftMargin: Metrics.margin(15) + + MaterialSymbolButton { + id: reloadButton + icon: "refresh" + anchors.centerIn: parent + iconSize: Metrics.iconSize("hugeass") + 4 + tooltipText: "Reload Nucleus Shell" + + onButtonClicked: { + Quickshell.execDetached(["nucleus", "run", "--reload"]) + } + } + } + + StyledRect { + id: settingsbtncontainer + color: "transparent" + radius: Metrics.radius("large") + implicitHeight: settingsButton.height + Metrics.margin("tiny") + implicitWidth: settingsButton.width + Metrics.margin("small") + Layout.alignment: Qt.AlignRight | Qt.AlignTop + Layout.topMargin: Metrics.margin(10) + Layout.leftMargin: Metrics.margin(15) + + MaterialSymbolButton { + id: settingsButton + icon: "settings" + anchors.centerIn: parent + iconSize: Metrics.iconSize("hugeass") + 2 + tooltipText: "Open Settings" + onButtonClicked: { + Globals.visiblility.sidebarRight = false + Globals.states.settingsOpen = true + } + } + } + + StyledRect { + id: powerbtncontainer + color: "transparent" + radius: Metrics.radius("large") + implicitHeight: settingsButton.height + Metrics.margin("tiny") + implicitWidth: settingsButton.width + Metrics.margin("small") + Layout.alignment: Qt.AlignRight | Qt.AlignTop + Layout.topMargin: Metrics.margin(10) + Layout.leftMargin: Metrics.margin(15) + + MaterialSymbolButton { + id: powerButton + icon: "power_settings_new" + anchors.centerIn: parent + iconSize: Metrics.iconSize("hugeass") + 2 + tooltipText: "Open PowerMenu" + + onButtonClicked: { + Globals.visiblility.sidebarRight = false + Globals.visiblility.powermenu = true + } + } + } + } + } + + Rectangle { + Layout.fillWidth: true + height: 1 + color: Appearance.m3colors.m3outlineVariant + radius: Metrics.radius(1) + } + + ColumnLayout { + id: sliderColumn + Layout.fillWidth: true + + VolumeSlider { + Layout.fillWidth: true + Layout.preferredHeight: 50 + icon: "volume_up" + iconSize: Metrics.iconSize("large") + 3 + } + + BrightnessSlider { + Layout.fillWidth: true + Layout.preferredHeight: 50 + icon: "brightness_high" + } + } + + Rectangle { + Layout.fillWidth: true + height: 1 + color: Appearance.m3colors.m3outlineVariant + radius: Metrics.radius(1) + } + + GridLayout { + id: middleGrid + Layout.fillWidth: true + columns: 1 + columnSpacing: Metrics.spacing(8) + rowSpacing: Metrics.spacing(8) + Layout.preferredWidth: parent.width + + RowLayout { + NetworkToggle { + Layout.fillWidth: true + Layout.preferredHeight: 80 + } + FlightModeToggle { + Layout.fillWidth: true + Layout.preferredHeight: 80 + } + } + + RowLayout { + BluetoothToggle { + Layout.preferredWidth: 220 + Layout.preferredHeight: 80 + } + ThemeToggle { + Layout.preferredHeight: 80 + Layout.fillWidth: true + } + NightModeToggle { + Layout.preferredHeight: 80 + Layout.fillWidth: true + } + } + } + + ColumnLayout { + spacing: Metrics.margin("small") + Layout.fillWidth: true + + Rectangle { + Layout.fillWidth: true + height: 1 + color: Appearance.m3colors.m3outlineVariant + radius: Metrics.radius(1) + Layout.topMargin: Metrics.margin(5) + Layout.bottomMargin: Metrics.margin(5) + } + + NotifModal { + Layout.preferredHeight: (Config.runtime.bar.position === "left" || Config.runtime.bar.position === "right") ? 480 : 470 + } + } + } +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/sidebarRight/content/BluetoothToggle.qml b/.config/quickshell/nucleus-shell/modules/interface/sidebarRight/content/BluetoothToggle.qml new file mode 100644 index 0000000..4f3ce1f --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/sidebarRight/content/BluetoothToggle.qml @@ -0,0 +1,91 @@ +import qs.config +import qs.modules.components +import qs.services +import QtQuick +import Quickshell +import QtQuick.Layouts + +StyledRect { + id: root + width: 200 + height: 80 + radius: Metrics.radius("verylarge") + color: Appearance.m3colors.m3surfaceContainerHigh + + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter + + readonly property bool adapterPresent: Bluetooth.defaultAdapter !== null + readonly property bool enabled: Bluetooth.defaultAdapter?.enabled ?? false + readonly property var activeDevice: Bluetooth.activeDevice + + readonly property string iconName: Bluetooth.icon + + readonly property string statusText: { + if (!adapterPresent) + return "No adapter"; + if (!enabled) + return "Disabled"; + if (activeDevice) + return activeDevice.name; + return Bluetooth.defaultAdapter.discovering + ? "Scanning…" + : "Enabled"; + } + + StyledRect { + id: iconBg + width: 50 + height: 50 + radius: Metrics.radius("verylarge") + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: Metrics.margin("small") + + color: { + if (!enabled) + return Appearance.m3colors.m3surfaceContainerHigh; + if (activeDevice) + return Appearance.m3colors.m3primaryContainer; + return Appearance.m3colors.m3secondaryContainer; + } + + MaterialSymbol { + anchors.centerIn: parent + iconSize: Metrics.iconSize(35) + icon: iconName + } + } + + Column { + anchors.verticalCenter: parent.verticalCenter + anchors.left: iconBg.right + anchors.leftMargin: Metrics.margin("small") + spacing: Metrics.spacing(2) + + StyledText { + text: "Bluetooth" + font.pixelSize: Metrics.fontSize("large") + elide: Text.ElideRight + width: root.width - iconBg.width - 30 + } + + StyledText { + text: statusText + font.pixelSize: Metrics.fontSize("small") + color: Appearance.m3colors.m3onSurfaceVariant + elide: Text.ElideRight + width: root.width - iconBg.width - 30 + } + } + + MouseArea { + anchors.fill: parent + onClicked: { + if (!adapterPresent) + return; + + Bluetooth.defaultAdapter.enabled = + !Bluetooth.defaultAdapter.enabled; + } + } +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/sidebarRight/content/BrightnessSlider.qml b/.config/quickshell/nucleus-shell/modules/interface/sidebarRight/content/BrightnessSlider.qml new file mode 100644 index 0000000..f790b12 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/sidebarRight/content/BrightnessSlider.qml @@ -0,0 +1,28 @@ +import qs.config +import qs.modules.components +import qs.services +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.Pipewire +import Quickshell.Widgets +import QtQuick.Controls + + +StyledSlider { + id: brightnessSlider + Layout.fillWidth: true + from: 0 + to: 1 + stepSize: 0.01 + property var monitor: Brightness.monitors.length > 0 ? Brightness.monitors[0] : null + value: monitor ? monitor.brightness : 0.5 + + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter + + property real level: brightnessSlider.value * 100 + + onMoved: if (monitor) { + monitor.setBrightness(value); + } +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/sidebarRight/content/FlightModeToggle.qml b/.config/quickshell/nucleus-shell/modules/interface/sidebarRight/content/FlightModeToggle.qml new file mode 100644 index 0000000..956cea3 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/sidebarRight/content/FlightModeToggle.qml @@ -0,0 +1,73 @@ +import qs.config +import qs.modules.components +import qs.modules.functions +import qs.services +import QtQuick +import Quickshell +import Quickshell.Io +import QtQuick.Layouts + +StyledRect { + id: root + width: 150 + height: 50 + radius: Metrics.radius("verylarge") + color: Appearance.m3colors.m3surfaceContainerHigh + + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter + + property bool flightMode + property string flightModeText: flightMode ? "Enabled" : "Disabled" + + Process { + id: toggleflightModeProc + running: false + command: [] + + function toggle() { + flightMode = !flightMode; + const cmd = flightMode ? "off" : "on"; + toggleflightModeProc.command = ["bash", "-c", `nmcli radio all ${cmd}`]; + toggleflightModeProc.running = true; + } + } + + StyledRect { + id: iconBg + width: 50 + height: 50 + radius: Metrics.radius("large") + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: Metrics.margin(10) + color: !flightMode ? Appearance.m3colors.m3surfaceContainerHigh : Appearance.m3colors.m3primaryContainer + + MaterialSymbol { + anchors.centerIn: parent + iconSize: Metrics.iconSize(35) + icon: "flight" + } + } + + Column { + anchors.verticalCenter: parent.verticalCenter + anchors.left: iconBg.right + anchors.leftMargin: Metrics.margin(10) + + StyledText { + text: "Flight Mode" + font.pixelSize: Metrics.fontSize(20) + } + + StyledText { + text: flightModeText + font.pixelSize: Metrics.fontSize("small") + } + } + + MouseArea { + anchors.fill: parent + propagateComposedEvents: true + onClicked: toggleflightModeProc.toggle() + } +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/sidebarRight/content/Media.qml b/.config/quickshell/nucleus-shell/modules/interface/sidebarRight/content/Media.qml new file mode 100644 index 0000000..fbce4a5 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/sidebarRight/content/Media.qml @@ -0,0 +1,196 @@ +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Quickshell.Io +import qs.config +import qs.modules.functions +import qs.modules.interface.notifications +import qs.modules.components +import qs.services + +StyledRect { + id: root + + Layout.fillWidth: true + radius: Metrics.radius("normal") + color: Appearance.m3colors.m3surfaceContainer + + ClippingRectangle { + color: Appearance.colors.colLayer1 + radius: Metrics.radius("normal") + implicitHeight: 90 + anchors.fill: parent + + RowLayout { + anchors.fill: parent + spacing: Metrics.margin("small") + + ClippingRectangle { + implicitWidth: 140 + implicitHeight: 140 + Layout.leftMargin: Metrics.margin("large") + radius: Metrics.radius("normal") + clip: true + color: Appearance.colors.colLayer2 + + Image { + anchors.fill: parent + source: Mpris.artUrl + fillMode: Image.PreserveAspectCrop + cache: true + } + + } + + ColumnLayout { + Layout.fillWidth: true + Layout.rightMargin: Metrics.margin("small") + spacing: Metrics.spacing(2) + + Text { + text: Mpris.albumTitle + elide: Text.ElideRight + Layout.maximumWidth: 190 + font.family: Metrics.fontFamily("title") + font.pixelSize: Metrics.fontSize("hugeass") + font.bold: true + color: Appearance.colors.colOnLayer2 + } + + Text { + text: Mpris.albumArtist + elide: Text.ElideRight + Layout.maximumWidth: 160 + font.family: Metrics.fontFamily("main") + font.pixelSize: Metrics.fontSize("normal") + color: Appearance.colors.colSubtext + } + + RowLayout { + + Layout.fillWidth: true + spacing: Metrics.spacing(12) + + Process { + id: control + } + + Button { + Layout.preferredWidth: 36 + Layout.preferredHeight: 36 + onClicked: Quickshell.execDetached(["playerctl", "previous"]) + + background: Rectangle { + radius: Metrics.radius("large") + color: Appearance.colors.colLayer2 + } + + contentItem: MaterialSymbol { + anchors.centerIn: parent + icon: "skip_previous" + font.pixelSize: Metrics.fontSize(24) + color: Appearance.colors.colOnLayer2 + fill: 1 + } + + } + + Button { + Layout.preferredWidth: 42 + Layout.preferredHeight: 42 + onClicked: Quickshell.execDetached(["playerctl", "play-pause"]) + + background: Rectangle { + radius: Metrics.radius("full") + color: Appearance.colors.colPrimary + } + + contentItem: MaterialSymbol { + anchors.bottom: parent.bottom + anchors.top: parent.top + icon: "play_arrow" + font.pixelSize: Metrics.fontSize(36) + color: Appearance.colors.colOnPrimary + fill: 1 + + } + + } + + Button { + Layout.preferredWidth: 36 + Layout.preferredHeight: 36 + onClicked: Quickshell.execDetached(["playerctl", "next"]) + + background: Rectangle { + radius: Metrics.radius("large") + color: Appearance.colors.colLayer2 + } + + contentItem: MaterialSymbol { + anchors.centerIn: parent + icon: "skip_next" + font.pixelSize: Metrics.fontSize(24) + color: Appearance.colors.colOnLayer2 + fill: 1 + + } + + } + + } + + RowLayout { + Layout.topMargin: Metrics.margin(15) + Layout.fillWidth: true + spacing: Metrics.spacing(12) + + Text { + text: Mpris.formatTime(Mpris.positionSec) + font.pixelSize: Metrics.fontSize("smallest") + color: Appearance.colors.colSubtext + } + + Item { + Layout.fillWidth: true + implicitHeight: 20 + + Rectangle { + anchors.fill: parent + radius: Metrics.radius("full") + color: Appearance.colors.colLayer2 + } + + Rectangle { + width: parent.width * (Mpris.lengthSec > 0 ? Mpris.positionSec / Mpris.lengthSec : 0) + radius: Metrics.radius("full") + color: Appearance.colors.colPrimary + + anchors { + left: parent.left + top: parent.top + bottom: parent.bottom + } + + } + + } + + Text { + text: Mpris.formatTime(Mpris.lengthSec) + font.pixelSize: Metrics.fontSize("smallest") + color: Appearance.colors.colSubtext + } + + } + + } + + } + + } + +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/sidebarRight/content/NetworkToggle.qml b/.config/quickshell/nucleus-shell/modules/interface/sidebarRight/content/NetworkToggle.qml new file mode 100644 index 0000000..428edb1 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/sidebarRight/content/NetworkToggle.qml @@ -0,0 +1,78 @@ +import qs.config +import qs.modules.components +import qs.modules.functions +import qs.services +import QtQuick +import Quickshell +import QtQuick.Layouts + +StyledRect { + id: root + width: 150 + height: 50 + radius: Metrics.radius("verylarge") + color: Appearance.m3colors.m3surfaceContainerHigh + + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter + + // Service bindings + readonly property bool wifiEnabled: Network.wifiEnabled + readonly property bool hasActive: Network.active !== null + readonly property string iconName: Network.icon + readonly property string titleText: Network.label + readonly property string statusText: Network.status + + // Icon background + StyledRect { + id: iconBg + width: 50 + height: 50 + radius: Metrics.radius("large") + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: Metrics.margin(10) + + color: { + if (!wifiEnabled) + return Appearance.m3colors.m3surfaceContainerHigh; + if (hasActive) + return Appearance.m3colors.m3primaryContainer; + return Appearance.m3colors.m3secondaryContainer; + } + + MaterialSymbol { + anchors.centerIn: parent + icon: iconName + iconSize: Metrics.iconSize(35) + } + } + + // Labels + Column { + anchors.verticalCenter: parent.verticalCenter + anchors.left: iconBg.right + anchors.leftMargin: Metrics.margin(10) + spacing: Metrics.spacing(2) + + StyledText { + text: titleText + font.pixelSize: Metrics.fontSize(20) + elide: Text.ElideRight + width: root.width - iconBg.width - 30 + } + + StyledText { + text: statusText + font.pixelSize: Metrics.fontSize("small") + color: Appearance.m3colors.m3onSurfaceVariant + elide: Text.ElideRight + width: root.width - iconBg.width - 30 + } + } + + // Interaction + MouseArea { + anchors.fill: parent + onClicked: Network.toggleWifi() + } +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/sidebarRight/content/NightModeToggle.qml b/.config/quickshell/nucleus-shell/modules/interface/sidebarRight/content/NightModeToggle.qml new file mode 100644 index 0000000..9c6b08b --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/sidebarRight/content/NightModeToggle.qml @@ -0,0 +1,33 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import qs.services +import qs.config +import qs.modules.components + +Rectangle { + id: root + + property bool nightTime + width: 200 + height: 80 + radius: Metrics.radius("childish") + color: !nightTime ? Appearance.m3colors.m3surfaceContainer : Appearance.m3colors.m3paddingContainer + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter + Layout.margins: 0 + + MaterialSymbol { + anchors.centerIn: parent + iconSize: Metrics.iconSize(35) + icon: "coffee" + } + + MouseArea { + anchors.fill: parent + onClicked: { + nightTime = !nightTime; + nightTime ? Quickshell.execDetached(["gammastep", "-O", "4000"]) : Quickshell.execDetached(["killall", "gammastep"]); + } + } + +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/sidebarRight/content/NotifModal.qml b/.config/quickshell/nucleus-shell/modules/interface/sidebarRight/content/NotifModal.qml new file mode 100644 index 0000000..7ea455a --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/sidebarRight/content/NotifModal.qml @@ -0,0 +1,110 @@ +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import qs.modules.functions +import qs.modules.interface.notifications +import qs.services +import qs.config +import qs.modules.components + +StyledRect { + id: root + + Layout.fillWidth: true + radius: Metrics.radius("normal") + color: Appearance.colors.colLayer1 + property bool dndActive: Config.runtime.notifications.doNotDisturb + + function toggleDnd() { + Config.updateKey("notifications.doNotDisturb", !dndActive); + } + + StyledButton { + id: clearButton + anchors.bottom: parent.bottom + anchors.right: parent.right + anchors.bottomMargin: Metrics.margin(10) + anchors.rightMargin: Metrics.margin(10) + icon: "clear_all" + text: "Clear" + implicitHeight: 40 + implicitWidth: 100 + secondary: true + + onClicked: { + for (let i = 0; i < NotifServer.history.length; i++) { + let n = NotifServer.history[i]; + if (n?.notification) n.notification.dismiss(); + } + } + } + + StyledButton { + id: silentButton + anchors.bottom: parent.bottom + anchors.right: parent.right + anchors.bottomMargin: Metrics.margin(10) + anchors.rightMargin: clearButton.implicitWidth + Metrics.margin(15) + text: "Silent" + icon: "do_not_disturb_on" + implicitHeight: 40 + implicitWidth: 100 + secondary: true + checkable: true + checked: Config.runtime.notifications.doNotDisturb + + onClicked: { + toggleDnd() + } + } + + StyledText { + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.bottomMargin: Metrics.margin(15) + anchors.leftMargin: Metrics.margin(15) + text: NotifServer.history.length + " Notifications" + + } + + StyledText { + anchors.centerIn: parent + text: "No notifications" + visible: NotifServer.history.length < 1 + font.pixelSize: Metrics.fontSize("huge") + } + + ListView { + id: notifList + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: clearButton.top + anchors.margins: Metrics.margin(10) + + clip: true + spacing: Metrics.spacing(8) + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ScrollBar { } + + model: Config.runtime.notifications.enabled + ? NotifServer.history + : [] + + delegate: NotificationChild { + width: notifList.width + title: model.summary + body: model.body + image: model.image || model.appIcon + rawNotif: model + buttons: model.actions.map((action) => ({ + "label": action.text, + "onClick": () => action.invoke() + })) + } + } + +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/sidebarRight/content/ThemeToggle.qml b/.config/quickshell/nucleus-shell/modules/interface/sidebarRight/content/ThemeToggle.qml new file mode 100644 index 0000000..dfc08b1 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/sidebarRight/content/ThemeToggle.qml @@ -0,0 +1,34 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import qs.services +import qs.config +import qs.modules.components + +Rectangle { + id: root + + readonly property bool isDark: Config.runtime.appearance.theme === "dark" + property string themestatusicon: isDark ? "dark_mode" : "light_mode" + + width: 200 + height: 80 + radius: Metrics.radius("childish") + color: Appearance.m3colors.m3paddingContainer + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter + Layout.margins: 0 + + MaterialSymbol { + anchors.centerIn: parent + iconSize: Metrics.iconSize(35) + icon: themestatusicon + } + + MouseArea { + anchors.fill: parent + onClicked: { + Quickshell.execDetached(["nucleus", "ipc", "call", "global", "toggleTheme"]); + } + } + +} diff --git a/.config/quickshell/nucleus-shell/modules/interface/sidebarRight/content/VolumeSlider.qml b/.config/quickshell/nucleus-shell/modules/interface/sidebarRight/content/VolumeSlider.qml new file mode 100644 index 0000000..8631b43 --- /dev/null +++ b/.config/quickshell/nucleus-shell/modules/interface/sidebarRight/content/VolumeSlider.qml @@ -0,0 +1,34 @@ +import qs.config +import qs.modules.components +import qs.services +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.Pipewire +import Quickshell.Widgets +import QtQuick.Controls + + +StyledSlider { + id: volSlider + Layout.fillWidth: true + from: 0 + to: 1 + stepSize: 0.01 + value: sink ? sink.volume : 0 + + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter + + property real level: volSlider.value * 100 + + PwObjectTracker { + objects: [Pipewire.defaultAudioSink] + } + + property var sink: Pipewire.defaultAudioSink?.audio + + + onMoved: { + if (sink) sink.volume = value + } +} diff --git a/.config/quickshell/nucleus-shell/plugins/PluginHost.qml b/.config/quickshell/nucleus-shell/plugins/PluginHost.qml new file mode 100644 index 0000000..cb297de --- /dev/null +++ b/.config/quickshell/nucleus-shell/plugins/PluginHost.qml @@ -0,0 +1,27 @@ +import QtQuick +import Quickshell +import qs.config + +Item { + Repeater { + model: PluginLoader.plugins + + delegate: Item { + width: 0 + height: 0 + + LazyLoader { + id: pluginLoader + active: Config.initialized + && Config.runtime + && Config.runtime.plugins + && Config.runtime.plugins[modelData] + && Config.runtime.plugins[modelData].enabled === true // Long ass binding to guard object existence + source: Qt.resolvedUrl( + Directories.shellConfig + "/plugins/" + modelData + "/Main.qml" + ) + loading: true // optional, keeps plugin loaded + } + } + } +} diff --git a/.config/quickshell/nucleus-shell/plugins/PluginLoader.qml b/.config/quickshell/nucleus-shell/plugins/PluginLoader.qml new file mode 100644 index 0000000..33361dd --- /dev/null +++ b/.config/quickshell/nucleus-shell/plugins/PluginLoader.qml @@ -0,0 +1,32 @@ +pragma Singleton +import QtQuick +import Quickshell +import Quickshell.Io +import qs.config + +Item { + id: root + + property var plugins: [] + + function reload() { + listPluginsProc.running = true + } + + Component.onCompleted: reload() + + Process { + id: listPluginsProc + // List directories under ~/.config/nucleus-shell/plugins + command: ["sh", "-c", "ls -1 ~/.config/nucleus-shell/plugins"] + running: true + + stdout: StdioCollector { + onStreamFinished: { + const names = text.split("\n").filter(s => s.trim() !== "") + root.plugins = names + } + } + + } +} diff --git a/.config/quickshell/nucleus-shell/plugins/PluginParser.qml b/.config/quickshell/nucleus-shell/plugins/PluginParser.qml new file mode 100644 index 0000000..bed085c --- /dev/null +++ b/.config/quickshell/nucleus-shell/plugins/PluginParser.qml @@ -0,0 +1,137 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import qs.config +pragma Singleton + +Item { + id: root + + property alias model: pluginModel + property string pendingPid: "" + property bool waitingForState: false + + Timer { + id: statePoller + interval: 100 + repeat: true + running: false + onTriggered: { + if (!waitingForState) + return + + const idx = indexOf(pendingPid) + if (idx === -1) + return + + const realInstalled = isInstalled(pendingPid) + if (pluginModel.get(idx).installed !== realInstalled) { + pluginModel.setProperty(idx, "installed", realInstalled) + pluginModel.setProperty(idx, "busy", false) + waitingForState = false + pendingPid = "" + statePoller.stop() + } + } + } + + function isInstalled(pid) { + return PluginLoader.plugins.indexOf(pid) !== -1 + } + + function indexOf(pid) { + for (let i = 0; i < pluginModel.count; ++i) { + if (pluginModel.get(i).id === pid) + return i + } + return -1 + } + + function refresh() { + pluginModel.clear() + fetchProc.running = true + } + + function install(pid) { + runAction("install", pid) + } + + function uninstall(pid) { + runAction("uninstall", pid) + } + + function update(pid) { + runAction("update", pid) + } + + function runAction(action, pid) { + const idx = indexOf(pid) + if (idx === -1) + return + + pendingPid = pid + waitingForState = true + + pluginModel.setProperty(idx, "busy", true) + + actionProc.command = [ + "bash", + "-c", + Directories.scriptsPath + "/plugins/plugins.sh " + action + " " + pid + ] + actionProc.running = true + } + + ListModel { + id: pluginModel + } + + Process { + id: fetchProc + running: true + command: [ + "bash", + "-c", + Directories.scriptsPath + "/plugins/plugins.sh fetch all-machine" + ] + + stdout: SplitParser { + onRead: (data) => { + const lines = data.split("\n") + for (let i = 0; i < lines.length; ++i) { + const line = lines[i].trim() + if (!line) + continue + + const parts = line.split("\t") + if (parts.length < 7) + continue + + const pid = parts[0] + pluginModel.append({ + id: pid, + name: parts[1], + version: parts[2], + author: parts[3], + description: parts[4], + requires_nucleus: parts[5], + repo: parts[6], + installed: isInstalled(pid), + busy: false + }) + } + } + } + } + + Process { + id: actionProc + + stdout: StdioCollector { + onStreamFinished: { + PluginLoader.reload() + statePoller.start() + } + } + } +} diff --git a/.config/quickshell/nucleus-shell/plugins/PluginSettingsLoader.qml b/.config/quickshell/nucleus-shell/plugins/PluginSettingsLoader.qml new file mode 100644 index 0000000..3c4c959 --- /dev/null +++ b/.config/quickshell/nucleus-shell/plugins/PluginSettingsLoader.qml @@ -0,0 +1,34 @@ +import QtQuick +import QtQuick.Layouts +import qs.config +import qs.plugins + +ColumnLayout { + id: pluginColumn + Layout.fillWidth: true + spacing: 8 + implicitHeight: childrenRect.height + + Repeater { + model: PluginLoader.plugins + + delegate: ContentCard { + Layout.fillWidth: true + + Loader { + Layout.fillWidth: true + asynchronous: true + source: Qt.resolvedUrl( + Directories.shellConfig + "/plugins/" + modelData + "/Settings.qml" + ) + + onStatusChanged: { + if (status === Loader.Ready) { + // recompute height when loader finishes loading + pluginColumn.implicitHeight = pluginColumn.childrenRect.height + } + } + } + } + } +} diff --git a/.config/quickshell/nucleus-shell/scripts/finders/find-apps.sh b/.config/quickshell/nucleus-shell/scripts/finders/find-apps.sh new file mode 100755 index 0000000..c0862a3 --- /dev/null +++ b/.config/quickshell/nucleus-shell/scripts/finders/find-apps.sh @@ -0,0 +1,118 @@ +#!/bin/bash +# List desktop applications for the launcher +# from github.com/bgibson72/yahr-quickshell with modifications +# Blacklist - apps to hide (add desktop file basenames here) +BLACKLIST=( + "xfce4-about.desktop" + "avahi-discover.desktop" + "bssh.desktop" + "bvnc.desktop" + "qv4l2.desktop" + "qvidcap.desktop" + "lstopo.desktop" + "uuctl.desktop" + "codium.desktop" # Hide regular VSCodium (keep Wayland version) + "xgps.desktop" # Hide Xgps + "xgpsspeed.desktop" # Hide Xgpsspeed +) + +# Whitelist - always include these desktop files +WHITELIST=( + "wallpaper.desktop" + "theme.desktop" + "powermenu.desktop" +) + +# Search paths for .desktop files (local first so overrides work) +SEARCH_PATHS=( + "$HOME/.local/share/applications" + "$HOME/.local/share/flatpak/exports/share/applications" + "/var/lib/flatpak/exports/share/applications" + "/usr/local/share/applications" + "/usr/share/applications" +) + +# Function to find icon path +find_icon() { + local icon_name="$1" + local theme="Papirus" + icon_name="${icon_name#theme://}" + [[ "$icon_name" == /* ]] && { echo "$icon_name"; return; } + + local exts=(png svg xpm) + local sizes=(16 22 24 32 48 64 128 256 512 scalable) + local icon_bases=( + "$HOME/.local/share/icons/$theme" + "/usr/share/icons/$theme" + "/usr/share/icons/hicolor" + "/usr/share/pixmaps" + ) + + for base in "${icon_bases[@]}"; do + for size in "${sizes[@]}"; do + for ext in "${exts[@]}"; do + for subdir in apps actions status devices places panel mimetypes; do + local candidate="$base/${size}x${size}/$subdir/$icon_name.$ext" + [[ -f "$candidate" ]] && { echo "$candidate"; return; } + done + local scalable="$base/scalable/apps/$icon_name.$ext" + [[ -f "$scalable" ]] && { echo "$scalable"; return; } + done + done + done + + for ext in "${exts[@]}"; do + [[ -f "/usr/share/pixmaps/$icon_name.$ext" ]] && { echo "/usr/share/pixmaps/$icon_name.$ext"; return; } + done + + echo "$icon_name" +} + +# Collect all desktop files and process +declare -A seen_apps + +for dir in "${SEARCH_PATHS[@]}"; do + [ ! -d "$dir" ] && continue + + while IFS= read -r desktop_file; do + basename_file=$(basename "$desktop_file") + + # Skip duplicates (local overrides system) + [[ -n "${seen_apps[$basename_file]}" ]] && continue + seen_apps[$basename_file]=1 + + # Skip blacklisted unless whitelisted + skip=0 + for blacklisted in "${BLACKLIST[@]}"; do + [[ "$basename_file" == "$blacklisted" ]] && skip=1 && break + done + # Check whitelist override + for whitelisted in "${WHITELIST[@]}"; do + [[ "$basename_file" == "$whitelisted" ]] && skip=0 && break + done + [[ $skip -eq 1 ]] && continue + + # Skip if NoDisplay=true + grep -q "^NoDisplay=true" "$desktop_file" 2>/dev/null && continue + + # Extract fields + name=$(grep "^Name=" "$desktop_file" | head -1 | cut -d= -f2-) + comment=$(grep "^Comment=" "$desktop_file" | head -1 | cut -d= -f2-) + icon=$(grep "^Icon=" "$desktop_file" | head -1 | cut -d= -f2-) + exec=$(grep "^Exec=" "$desktop_file" | head -1 | cut -d= -f2- | sed 's/%[uUfF]//g' | sed 's/%[cdnNvmki]//g') + + # Skip if no name or exec + [ -z "$name" ] && continue + [ -z "$exec" ] && continue + + # Default comment + [ -z "$comment" ] && comment="Application" + + # Find icon + icon_path=$(find_icon "$icon") + + # Output + echo "$name|$comment|$icon_path|$exec" + + done < <(find -L "$dir" -name "*.desktop" -type f 2>/dev/null) +done | sort -u diff --git a/.config/quickshell/nucleus-shell/scripts/interface/changebg.sh b/.config/quickshell/nucleus-shell/scripts/interface/changebg.sh new file mode 100755 index 0000000..ef9adae --- /dev/null +++ b/.config/quickshell/nucleus-shell/scripts/interface/changebg.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +START_DIR="$HOME/Pictures/Wallpapers" + +# Get monitor list (Wayland/Hyprland/Qtile etc. usually expose via xrandr or hyprctl) +MONITORS=$(xrandr --query | grep " connected" | cut -d" " -f1) + +# Convert monitors into Zenity list arguments +LIST_ARGS=() +for m in $MONITORS; do + LIST_ARGS+=("$m") +done + +DISPLAY=$(zenity --list \ + --title="Select Display" \ + --column="Monitor" \ + "${LIST_ARGS[@]}" \ + --height=300 \ + --width=300 2>/dev/null) + +# User cancelled +[ -z "$DISPLAY" ] && echo "null" && exit + +FILE=$(zenity --file-selection \ + --title="Select Wallpaper for $DISPLAY" \ + --filename="$START_DIR/" \ + --file-filter="Images/Videos | *.png *.jpg *.jpeg *.webp *.bmp *.svg *.mp4 *.mkv *.webm *.mov *.avi *.m4v" \ + 2>/dev/null) + +[ -z "$FILE" ] && echo "null" && exit + +# Output format: monitor|wallpaper +echo "$DISPLAY|file://$FILE" \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/scripts/interface/gencolors.sh b/.config/quickshell/nucleus-shell/scripts/interface/gencolors.sh new file mode 100755 index 0000000..68294a9 --- /dev/null +++ b/.config/quickshell/nucleus-shell/scripts/interface/gencolors.sh @@ -0,0 +1,66 @@ +#!/bin/bash +# ~/config/quickshell/scripts/background/gencolors.sh +# Generate Matugen color scheme for a given wallpaper + +USER_WIDE=false + +# Parse flags +while [[ "$1" == --* ]]; do + case "$1" in + --user-wide) + USER_WIDE=true + shift + ;; + *) + echo "Unknown flag: $1" >&2 + exit 1 + ;; + esac +done + +WALLPAPER_PATH="$1" +SCHEME_TYPE="$2" +SCHEME_MODE="$3" +CONFIG_PATH="$4" + +# Validate required arguments +if [[ -z "$WALLPAPER_PATH" || "$WALLPAPER_PATH" == "null" ]]; then + echo "Error: no wallpaper provided" >&2 + exit 1 +fi + +: "${SCHEME_TYPE:?Error: scheme type not provided}" +: "${SCHEME_MODE:?Error: scheme mode not provided}" + +# Strip file:// prefix if present +if [[ "$WALLPAPER_PATH" == file://* ]]; then + WALLPAPER_PATH="${WALLPAPER_PATH#file://}" +fi + +if ! $USER_WIDE; then + if [[ -z "$CONFIG_PATH" || ! -f "$CONFIG_PATH" ]]; then + echo "Error: config file not found: $CONFIG_PATH" >&2 + exit 1 + fi +fi + + +run_with_config() { + matugen --config "$CONFIG_PATH" \ + image "$WALLPAPER_PATH" \ + --type "$SCHEME_TYPE" \ + --mode "$SCHEME_MODE" +} + +run_without_config() { + matugen image "$WALLPAPER_PATH" \ + --type "$SCHEME_TYPE" \ + --mode "$SCHEME_MODE" +} + +if $USER_WIDE; then + run_with_config + run_without_config +else + run_with_config +fi diff --git a/.config/quickshell/nucleus-shell/scripts/interface/selectfolder.sh b/.config/quickshell/nucleus-shell/scripts/interface/selectfolder.sh new file mode 100755 index 0000000..c363f51 --- /dev/null +++ b/.config/quickshell/nucleus-shell/scripts/interface/selectfolder.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +# Folder selector for wallpaper slideshow + +START_DIR="${1:-$HOME/Pictures/Wallpapers}" + +# Ensure start dir exists, fallback to Pictures or Home +if [ ! -d "$START_DIR" ]; then + START_DIR="$HOME/Pictures" +fi +if [ ! -d "$START_DIR" ]; then + START_DIR="$HOME" +fi + +FOLDER=$(zenity --file-selection \ + --directory \ + --title="Select Wallpaper Folder" \ + --filename="$START_DIR/" 2>/dev/null) + +if [ $? -eq 0 ] && [ -n "$FOLDER" ]; then + echo "$FOLDER" +else + echo "null" +fi diff --git a/.config/quickshell/nucleus-shell/scripts/interface/switchTheme.sh b/.config/quickshell/nucleus-shell/scripts/interface/switchTheme.sh new file mode 100755 index 0000000..8a1d4ba --- /dev/null +++ b/.config/quickshell/nucleus-shell/scripts/interface/switchTheme.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +THEME_NAME="${1:-}" + +THEME_DIR="$HOME/.config/nucleus-shell/colorschemes" +TARGET="$HOME/.config/nucleus-shell/config/colors.json" + +if [[ -z "$THEME_NAME" ]]; then + echo "Usage: switch-theme.sh " + exit 1 +fi + +# === AUTOGENERATED THEME === +if [[ "$THEME_NAME" == "auto" || "$THEME_NAME" == "autogen" ]]; then + echo "Generating theme via Quickshell IPC…" + + qs -c nucleus-shell ipc call global regenColors + + echo "Autogenerated theme applied" + exit 0 +fi + +# === STATIC THEME === +SOURCE="$THEME_DIR/$THEME_NAME.json" + +if [[ ! -f "$SOURCE" ]]; then + echo "Theme not found: $THEME_NAME" + exit 1 +fi + +# Atomic write +tmp="$(mktemp)" +cat "$SOURCE" > "$tmp" +mv "$tmp" "$TARGET" + +echo "Theme switched to: $THEME_NAME" diff --git a/.config/quickshell/nucleus-shell/scripts/plugins/plugins.sh b/.config/quickshell/nucleus-shell/scripts/plugins/plugins.sh new file mode 100755 index 0000000..d38fe95 --- /dev/null +++ b/.config/quickshell/nucleus-shell/scripts/plugins/plugins.sh @@ -0,0 +1,242 @@ +#!/usr/bin/env bash +set -euo pipefail + +# This script the now depreciated as it is succeeded by the nucleus-cli (https://github.com/unf6/nucleus-cli) + +# Config + +INSTALL_DIR="$HOME/.config/nucleus-shell/plugins" +CACHE_BASE="/tmp/nucleus-plugins" + +declare -A PLUGIN_REPOS=( + [official]="https://github.com/xZepyx/nucleus-plugins.git" +) + +# Dependencies + +require() { + command -v "$1" >/dev/null 2>&1 || { + echo "Missing dependency: $1" + exit 1 + } +} + +require git +require jq + +mkdir -p "$INSTALL_DIR" "$CACHE_BASE" + +# Repo handling + +update_repo() { + local name="$1" + local url="$2" + local dir="$CACHE_BASE/$name" + + if [[ -d "$dir/.git" ]]; then + git -C "$dir" pull --quiet + else + rm -rf "$dir" + git clone --quiet "$url" "$dir" + fi +} + +update_all_repos() { + for name in "${!PLUGIN_REPOS[@]}"; do + update_repo "$name" "${PLUGIN_REPOS[$name]}" + done +} + +# Plugin Lookup + +find_plugin() { + local plugin="$1" + + for repo in "${!PLUGIN_REPOS[@]}"; do + local path="$CACHE_BASE/$repo/$plugin" + if [[ -d "$path" && -f "$path/manifest.json" ]]; then + echo "$repo:$path" + return 0 + fi + done + + return 1 +} + +# Fetch + +fetch_all() { + update_all_repos + + for repo in "${!PLUGIN_REPOS[@]}"; do + local base="$CACHE_BASE/$repo" + + for dir in "$base"/*/; do + [[ -f "$dir/manifest.json" ]] || continue + + jq -r ' + "id: \(.id) +name: \(.name) +version: \(.version) +author: \(.author) +description: \(.description) +requires_nucleus: \(.requires_nucleus // "none") +repo: '"$repo"' +---" + ' "$dir/manifest.json" + done + done +} + +fetch_one() { + local plugin="$1" + update_all_repos + + local result + result=$(find_plugin "$plugin") || { + echo "Plugin '$plugin' not found in any repo" + exit 1 + } + + local repo="${result%%:*}" + local path="${result#*:}" + + jq -r ' + "id: \(.id) +name: \(.name) +version: \(.version) +author: \(.author) +description: \(.description) +requires_nucleus: \(.requires_nucleus // "none") +repo: '"$repo"' +---" + ' "$path/manifest.json" +} + +# Install / Update / Uninstall + +install_plugin() { + local plugin="$1" + update_all_repos + + local dst="$INSTALL_DIR/$plugin" + [[ -d "$dst" ]] && { + echo "Plugin '$plugin' already installed" + exit 0 + } + + local result + result=$(find_plugin "$plugin") || { + echo "Plugin '$plugin' not found" + exit 1 + } + + local path="${result#*:}" + cp -r "$path" "$dst" + + echo "Installed plugin '$plugin'" +} + +uninstall_plugin() { + local plugin="$1" + local dst="$INSTALL_DIR/$plugin" + + [[ -d "$dst" ]] || { + echo "Plugin '$plugin' is not installed" + exit 0 + } + + rm -rf "$dst" + echo "Uninstalled plugin '$plugin'" +} + +fetch_all_machine() { # For quickshell + update_all_repos + + for repo in "${!PLUGIN_REPOS[@]}"; do + local base="$CACHE_BASE/$repo" + + for dir in "$base"/*/; do + [[ -f "$dir/manifest.json" ]] || continue + + jq -r ' + [ + .id, + .name, + .version, + .author, + .description, + (.requires_nucleus // "none"), + "'"$repo"'" + ] | @tsv + ' "$dir/manifest.json" + done + done +} + + +update_plugin() { + local plugin="$1" + update_all_repos + + local dst="$INSTALL_DIR/$plugin" + [[ -d "$dst" ]] || { + echo "Plugin '$plugin' not installed" + exit 1 + } + + local result + result=$(find_plugin "$plugin") || { + echo "Plugin '$plugin' not found in repos" + exit 1 + } + + local src="${result#*:}" + + local local_version repo_version + local_version=$(jq -r '.version' "$dst/manifest.json") + repo_version=$(jq -r '.version' "$src/manifest.json") + + if [[ "$local_version" == "$repo_version" ]]; then + echo "Plugin '$plugin' already up to date ($local_version)" + exit 0 + fi + + rm -rf "$dst" + cp -r "$src" "$dst" + + echo "Updated '$plugin' $local_version → $repo_version" +} + +# CLI + +usage() { + cat < + plugins install + plugins uninstall + plugins update +EOF +} + +case "${1:-}" in + fetch) + [[ "${2:-}" == "all-machine" ]] && fetch_all_machine \ + || [[ "${2:-}" == "all" ]] && fetch_all \ + || fetch_one "${2:-}" + ;; + install) + install_plugin "${2:-}" + ;; + uninstall) + uninstall_plugin "${2:-}" + ;; + update) + update_plugin "${2:-}" + ;; + *) + usage + ;; +esac diff --git a/.config/quickshell/nucleus-shell/scripts/system/reload.sh b/.config/quickshell/nucleus-shell/scripts/system/reload.sh new file mode 100755 index 0000000..c049a7f --- /dev/null +++ b/.config/quickshell/nucleus-shell/scripts/system/reload.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +killall quickshell +nucleus run \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/scripts/system/update.sh b/.config/quickshell/nucleus-shell/scripts/system/update.sh new file mode 100755 index 0000000..8903d59 --- /dev/null +++ b/.config/quickshell/nucleus-shell/scripts/system/update.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +# Paths / repo +CONFIG="$HOME/.config/nucleus-shell/config/configuration.json" +QS_DIR="$HOME/.config/quickshell/nucleus-shell" +REPO="xZepyx/nucleus-shell" +API="https://api.github.com/repos/$REPO/releases" + +# Spinner +spinner() { + local pid=$1 + local spin='|/-\' + local i=0 + + while kill -0 "$pid" 2>/dev/null; do + printf "\r[*] %s %c" "$SPINNER_MSG" "${spin:i++%4:1}" + sleep 0.1 + done +} + +run() { + SPINNER_MSG="$1" + shift + "$@" &>/dev/null & + spinner $! + wait $! || fail "$SPINNER_MSG failed" + printf "\r[✓] %s\n" "$SPINNER_MSG" +} + +fail() { + printf "[✗] %s\n" "$1" >&2 + exit 1 +} + +info() { + printf "[*] %s\n" "$1" +} + +# Selection +echo "Select the version to install:" +echo "1. Latest" +echo "2. Edge" +echo "3. Git" + +read -rp "[?] Choice: " choice + +case "$choice" in + 1) mode="stable" ;; + 2) mode="indev" ;; + 3) + read -rp "[?] Enter git tag or version: " input + [[ -z "$input" ]] && fail "No version provided" + latest="${input#v}" + latest_tag="v$latest" + ;; + *) fail "Invalid choice" ;; +esac + +# Validate config +[[ -f "$CONFIG" ]] || fail "configuration.json not found" + +current="$(jq -r '.shell.version // empty' "$CONFIG")" +[[ -n "$current" ]] || fail "Current version not set" + +# Resolve release +if [[ "${mode:-}" ]]; then + info "Resolving release" + latest_tag="$( + curl -fsSL "$API" | + jq -r " + map(select(.draft == false)) | + $( [[ "$mode" == "stable" ]] && echo 'map(select(.prerelease == false)) |' ) + sort_by(.published_at) | + last | + .tag_name + " + )" + [[ -n "$latest_tag" && "$latest_tag" != "null" ]] || fail "Release resolution failed" + latest="${latest_tag#v}" +fi + +# No-op +if [[ "$current" == "$latest" ]]; then + info "Already up to date ($current)" + exit 0 +fi + +# Temp workspace +tmp="$(mktemp -d)" +zip="$tmp/source.zip" +root_dir="$tmp/nucleus-shell-$latest" +SRC_DIR="$root_dir/quickshell/nucleus-shell" + +# Download +run "Downloading nucleus-shell $latest" \ + curl -fsSL \ + "https://github.com/$REPO/archive/refs/tags/$latest_tag.zip" \ + -o "$zip" + +# Extract +run "Extracting archive" unzip -q "$zip" -d "$tmp" + +[[ -d "$SRC_DIR" ]] || fail "nucleus-shell directory missing in archive" + +# Install +run "Installing files" bash -c " + rm -rf '$QS_DIR' && + mkdir -p '$QS_DIR' && + cp -r '$SRC_DIR/'* '$QS_DIR/' +" + +# Update config +run "Updating configuration" bash -c " + tmp_cfg=\$(mktemp) && + jq --arg v '$latest' '.shell.version = \$v' '$CONFIG' > \"\$tmp_cfg\" && + mv \"\$tmp_cfg\" '$CONFIG' +" + +# Reload shell +run "Reloading shell" bash -c " + killall qs &>/dev/null || true + nohup qs -c nucleus-shell &>/dev/null & disown +" + +printf "[✓] Updated nucleus-shell: %s -> %s\n" "$current" "$latest" diff --git a/.config/quickshell/nucleus-shell/services/AppRegistry.qml b/.config/quickshell/nucleus-shell/services/AppRegistry.qml new file mode 100644 index 0000000..bc052af --- /dev/null +++ b/.config/quickshell/nucleus-shell/services/AppRegistry.qml @@ -0,0 +1,168 @@ +pragma Singleton +import QtQuick +import Quickshell +import qs.modules.functions +import qs.config + +/* + This registry is only used to get app details for wm classes. +*/ + +Singleton { + id: registry + + property var apps: [] + property var classToIcon: ({}) + property var desktopIdToIcon: ({}) + property var nameToIcon: ({}) + + signal ready() + + function iconForDesktopIcon(icon) { + if (!icon) return "" + + // If it's already a URL, keep it + if (icon.startsWith("file://") || icon.startsWith("qrc:/")) + return icon + + // Absolute filesystem path → convert to file URL + if (icon.startsWith("/")) + return "file://" + icon + + // Otherwise treat as theme icon name + return Quickshell.iconPath(icon) + } + + // Try very aggressive matching so the running app always gets the same icon as launcher + function iconForClass(id) { + if (!id) return "" + + const lower = id.toLowerCase() + + // direct hits first + if (classToIcon[lower]) + return iconForDesktopIcon(classToIcon[lower]) + + if (desktopIdToIcon[lower]) + return iconForDesktopIcon(desktopIdToIcon[lower]) + + if (nameToIcon[lower]) + return iconForDesktopIcon(nameToIcon[lower]) + + // fuzzy contains match against wmClass map + for (let key in classToIcon) { + if (lower.includes(key) || key.includes(lower)) + return iconForDesktopIcon(classToIcon[key]) + } + + // fuzzy against desktop ids + for (let key in desktopIdToIcon) { + if (lower.includes(key) || key.includes(lower)) + return iconForDesktopIcon(desktopIdToIcon[key]) + } + + // fuzzy against names + for (let key in nameToIcon) { + if (lower.includes(key) || key.includes(lower)) + return iconForDesktopIcon(nameToIcon[key]) + } + + // final fallback to theme resolution + const resolved = FileUtils.resolveIcon(id) + return iconForDesktopIcon(resolved) + } + + // Extra helper: resolve icon using any metadata we might have (Hyprland, Niri, etc.) + function iconForAppMeta(meta) { + if (!meta) return Quickshell.iconPath("application-x-executable") + + const candidates = [ + meta.appId, + meta.class, + meta.initialClass, + meta.desktopId, + meta.title, + meta.name + ] + + for (let c of candidates) { + const icon = iconForClass(c) + if (icon !== "") + return icon + } + + // fallback: try compositor provided icon name + if (meta.icon) + return iconForDesktopIcon(meta.icon) + + // hard fallback icons (guaranteed to exist in most themes) + const fallbacks = [ + "application-x-executable", + "application-default-icon", + "window" + ] + + for (let f of fallbacks) { + const resolved = Quickshell.iconPath(f) + if (resolved) + return resolved + } + + return "" + } + + function registerApp(displayName, comment, icon, exec, wmClass, desktopId) { + const entry = { + name: displayName, + comment: comment, + icon: icon, + exec: exec, + wmClass: wmClass, + desktopId: desktopId + } + + apps.push(entry) + + if (wmClass) + classToIcon[wmClass.toLowerCase()] = icon + + if (desktopId) + desktopIdToIcon[desktopId.toLowerCase()] = icon + + if (displayName) + nameToIcon[displayName.toLowerCase()] = icon + + // Hard aliases for apps with messy WM_CLASS values + if (displayName.toLowerCase().includes("visual studio code") || + icon.toLowerCase().includes("code")) { + + classToIcon["code"] = icon + classToIcon["code-oss"] = icon + classToIcon["code-url-handler"] = icon + desktopIdToIcon["code.desktop"] = icon + desktopIdToIcon["code-oss.desktop"] = icon + } + } + + function buildRegistry() { + const entries = DesktopEntries.applications.values + + for (let entry of entries) { + if (entry.noDisplay) + continue + + registry.registerApp( + entry.name || "", + entry.comment || "", + entry.icon || "", + entry.execString || "", + entry.startupWMClass || "", + entry.id || "" + ) + } + + registry.ready() + } + + Component.onCompleted: buildRegistry() +} \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/services/Apps.qml b/.config/quickshell/nucleus-shell/services/Apps.qml new file mode 100644 index 0000000..410ba49 --- /dev/null +++ b/.config/quickshell/nucleus-shell/services/Apps.qml @@ -0,0 +1,30 @@ +pragma Singleton + +import "../modules/functions/fuzzy/fuzzysort.js" as Fuzzy +import Quickshell + +Singleton { + id: root + + readonly property list list: DesktopEntries.applications.values.filter(a => !a.noDisplay).sort((a, b) => a.name.localeCompare(b.name)) + readonly property list preppedApps: list.map(a => ({ + name: Fuzzy.prepare(a.name), + comment: Fuzzy.prepare(a.comment), + entry: a + })) + + function fuzzyQuery(search: string): var { // idk why list doesn't work + return Fuzzy.go(search, preppedApps, { + all: true, + keys: ["name", "comment"], + scoreFn: r => r[0].score > 0 ? r[0].score * 0.9 + r[1].score * 0.1 : 0 + }).map(r => r.obj.entry); + } + + function launch(entry: DesktopEntry): void { + if (entry.execString.startsWith("sh -c")) + Quickshell.execDetached(["sh", "-c", `app2unit -- ${entry.execString}`]); + else + Quickshell.execDetached(["sh", "-c", `app2unit -- '${entry.id}.desktop' || app2unit -- ${entry.execString}`]); + } +} \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/services/Bluetooth.qml b/.config/quickshell/nucleus-shell/services/Bluetooth.qml new file mode 100644 index 0000000..c906a57 --- /dev/null +++ b/.config/quickshell/nucleus-shell/services/Bluetooth.qml @@ -0,0 +1,24 @@ +pragma Singleton +import QtQuick +import Quickshell +import Quickshell.Bluetooth + + +Singleton { + id: root + readonly property BluetoothAdapter defaultAdapter: Bluetooth.defaultAdapter + readonly property list devices: defaultAdapter?.devices?.values ?? [] + readonly property BluetoothDevice activeDevice: devices.find(d => d.connected) ?? null + readonly property string icon: { + if (!defaultAdapter?.enabled) + return "bluetooth_disabled" + + if (activeDevice) + return "bluetooth_connected" + + return defaultAdapter.discovering + ? "bluetooth_searching" + : "bluetooth" + } + +} \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/services/Brightness.qml b/.config/quickshell/nucleus-shell/services/Brightness.qml new file mode 100755 index 0000000..2b6f9e8 --- /dev/null +++ b/.config/quickshell/nucleus-shell/services/Brightness.qml @@ -0,0 +1,142 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland +import QtQuick + +// from github.com/end-4/dots-hyprland + +Singleton { + id: root + signal brightnessChanged() + + property var ddcMonitors: [] + readonly property list monitors: Quickshell.screens.map(screen => monitorComp.createObject(root, { screen })) + + function getMonitorForScreen(screen: ShellScreen): var { + return monitors.find(m => m.screen === screen) + } + + function increaseBrightness(): void { + const focusedName = Hyprland.focusedMonitor.name + const monitor = monitors.find(m => focusedName === m.screen.name) + if (monitor) + monitor.setBrightness(monitor.brightness + 0.05) + } + + function decreaseBrightness(): void { + const focusedName = Hyprland.focusedMonitor.name + const monitor = monitors.find(m => focusedName === m.screen.name) + if (monitor) + monitor.setBrightness(monitor.brightness - 0.05) + } + + reloadableId: "brightness" + + onMonitorsChanged: { + ddcMonitors = [] + ddcProc.running = true + } + + Process { + id: ddcProc + command: ["ddcutil", "detect", "--brief"] + stdout: SplitParser { + splitMarker: "\n\n" + onRead: data => { + if (data.startsWith("Display ")) { + const lines = data.split("\n").map(l => l.trim()) + root.ddcMonitors.push({ + model: lines.find(l => l.startsWith("Monitor:")).split(":")[2], + busNum: lines.find(l => l.startsWith("I2C bus:")).split("/dev/i2c-")[1] + }) + } + } + } + onExited: root.ddcMonitorsChanged() + } + + Process { id: setProc } + + component BrightnessMonitor: QtObject { + id: monitor + + required property ShellScreen screen + + readonly property bool isDdc: { + const match = root.ddcMonitors.find(m => m.model === screen.model && + !root.monitors.slice(0, root.monitors.indexOf(this)) + .some(mon => mon.busNum === m.busNum)) + return !!match + } + + readonly property string busNum: { + const match = root.ddcMonitors.find(m => m.model === screen.model && + !root.monitors.slice(0, root.monitors.indexOf(this)) + .some(mon => mon.busNum === m.busNum)) + return match?.busNum ?? "" + } + + property int rawMaxBrightness: 100 + property real brightness + property real brightnessMultiplier: 1.0 + property real multipliedBrightness: Math.max(0, Math.min(1, brightness * brightnessMultiplier)) + property bool ready: false + property bool animateChanges: !monitor.isDdc + + onBrightnessChanged: { + if (!monitor.ready) return + root.brightnessChanged() + if (monitor.animateChanges) + syncBrightness() + else + setTimer.restart() + } + + property var setTimer: Timer { + id: setTimer + interval: monitor.isDdc ? 300 : 0 + onTriggered: syncBrightness() + } + + function initialize() { + monitor.ready = false + initProc.command = isDdc + ? ["ddcutil", "-b", busNum, "getvcp", "10", "--brief"] + : ["sh", "-c", `echo "a b c $(brightnessctl g) $(brightnessctl m)"`] + initProc.running = true + } + + readonly property Process initProc: Process { + stdout: SplitParser { + onRead: data => { + const [, , , current, max] = data.split(" ") + monitor.rawMaxBrightness = parseInt(max) + monitor.brightness = parseInt(current) / monitor.rawMaxBrightness + monitor.ready = true + } + } + } + + function syncBrightness() { + const brightnessValue = Math.max(Math.min(monitor.multipliedBrightness, 1), 0) + const rawValueRounded = Math.max(Math.floor(brightnessValue * monitor.rawMaxBrightness), 1) + setProc.command = isDdc + ? ["ddcutil", "-b", busNum, "setvcp", "10", rawValueRounded] + : ["brightnessctl", "set", rawValueRounded.toString()] + setProc.startDetached() + } + + function setBrightness(value: real): void { + value = Math.max(0, Math.min(1, value)) + monitor.brightness = value + } + + Component.onCompleted: initialize() + onBusNumChanged: initialize() + } + + Component { id: monitorComp; BrightnessMonitor {} } +} diff --git a/.config/quickshell/nucleus-shell/services/Compositor.qml b/.config/quickshell/nucleus-shell/services/Compositor.qml new file mode 100644 index 0000000..578b810 --- /dev/null +++ b/.config/quickshell/nucleus-shell/services/Compositor.qml @@ -0,0 +1,89 @@ +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Wayland +import Quickshell.Io + +Singleton { + id: root + + // compositor stuff + property string detectedCompositor: "" + + readonly property var backend: { + if (detectedCompositor === "niri") + return Niri + if (detectedCompositor === "hyprland") + return Hyprland + return null + } + + function require(compositors) { // This function can be effectively used to detect check requirements for a feature (also supports multiple compositors) + if (Array.isArray(compositors)) { + return compositors.includes(detectedCompositor); + } + return compositors === detectedCompositor; + } + + // Unified api + property string title: backend?.title ?? "" + property bool isFullscreen: backend?.isFullscreen ?? false + property string layout: backend?.layout ?? "Tiled" + property int focusedWorkspaceId: backend?.focusedWorkspaceId ?? 1 + property var workspaces: backend?.workspaces ?? [] + property var windowList: backend?.windowList ?? [] + property bool initialized: backend?.initialized ?? true + property int workspaceCount: backend?.workspaceCount ?? 0 + property real screenW: backend?.screenW ?? 0 + property real screenH: backend?.screenH ?? 0 + property real screenScale: backend?.screenScale ?? 1 + readonly property Toplevel activeToplevel: ToplevelManager.activeToplevel + + function changeWorkspace(id) { + backend?.changeWorkspace?.(id) + } + + function changeWorkspaceRelative(delta) { + backend?.changeWorkspaceRelative?.(delta) + } + + function isWorkspaceOccupied(id) { + return backend?.isWorkspaceOccupied?.(id) ?? false + } + + function focusedWindowForWorkspace(id) { + return backend?.focusedWindowForWorkspace?.(id) ?? null + } + + // process to detect compositor + Process { + command: ["sh", "-c", "echo \"$XDG_CURRENT_DESKTOP $XDG_SESSION_DESKTOP\""] + running: true + + stdout: SplitParser { + onRead: data => { + if (!data) + return + + const val = data.trim().toLowerCase() + + if (val.includes("hyprland")) { + root.detectedCompositor = "hyprland" + } else if (val.includes("niri")) { + root.detectedCompositor = "niri" + } + } + } + } + + signal stateChanged() + + Connections { + target: backend + function onStateChanged() { + root.stateChanged() + } + } + +} diff --git a/.config/quickshell/nucleus-shell/services/ConfigResolver.qml b/.config/quickshell/nucleus-shell/services/ConfigResolver.qml new file mode 100644 index 0000000..75383c9 --- /dev/null +++ b/.config/quickshell/nucleus-shell/services/ConfigResolver.qml @@ -0,0 +1,34 @@ +import QtQuick +import Quickshell +import qs.config +pragma Singleton + + +/* + + This service primarily resolves configs for widgets that are customizable per monitor. + +*/ + + +Singleton { + + function bar(displayName) { + const displays = Config.runtime.monitors; + const fallback = Config.runtime.bar; + if (!displays || !displays[displayName] || !displays[displayName].bar || displayName === "") + return fallback; + + return displays[displayName].bar; + } + + function getBarConfigurableHandle(displayName) { // returns prefField string + const displays = Config.runtime.monitors; + + if (!displays || !displays[displayName] || !displays[displayName].bar || displayName === "") + return "bar"; + + return "monitors." + displayName + ".bar"; + } + +} diff --git a/.config/quickshell/nucleus-shell/services/Contracts.qml b/.config/quickshell/nucleus-shell/services/Contracts.qml new file mode 100644 index 0000000..c4f9a6e --- /dev/null +++ b/.config/quickshell/nucleus-shell/services/Contracts.qml @@ -0,0 +1,69 @@ +pragma Singleton +import QtQuick +import Quickshell.Io +import qs.config + +QtObject { + // Power menu + property url powerMenu: Qt.resolvedUrl("../modules/interface/powermenu/Powermenu.qml") + property bool overriddenPowerMenu: false + function overridePowerMenu() { + overriddenPowerMenu = true + } + + // Bar + property url bar: Qt.resolvedUrl("../modules/interface/bar/Bar.qml") + property bool overriddenBar: false + function overrideBar() { + overriddenBar = true + } + + // App launcher + property url launcher: Qt.resolvedUrl("../modules/interface/launcher/Launcher.qml") + property bool overriddenLauncher: false + function overrideLauncher() { + overriddenLauncher = true + } + + // Lock screen + property url lockScreen: Qt.resolvedUrl("../modules/interface/lockscreen/LockScreen.qml") + property bool overriddenLockScreen: false + function overrideLockScreen() { + overriddenLockScreen = true + } + + // Desktop background / wallpaper handler + property url background: Qt.resolvedUrl("../modules/interface/background/Background.qml") + property bool overriddenBackground: false + function overrideBackground() { + overriddenBackground = true + } + + // Notifications UI + property url notifications: Qt.resolvedUrl("../modules/interface/notifications/Notifications.qml") + property bool overriddenNotifications: false + function overrideNotifications() { + overriddenNotifications = true + } + + // Global overlays (OSD, volume, brightness, etc.) + property url overlays: Qt.resolvedUrl("../modules/interface/overlays/Overlays.qml") + property bool overriddenOverlays: false + function overrideOverlays() { + overriddenOverlays = true + } + + // Right sidebar + property url sidebarRight: !overriddenSidebarRight ? Qt.resolvedUrl("../modules/interface/sidebarRight/SidebarRight.qml") : "" // Force override + property bool overriddenSidebarRight: false + function overrideSidebarRight() { + overriddenSidebarRight = true + } + + // Left sidebar + property url sidebarLeft: !overriddenSidebarLeft ? Qt.resolvedUrl("../modules/interface/sidebarLeft/SidebarLeft.qml") : "" // Force override + property bool overriddenSidebarLeft: false + function overrideSidebarLeft() { + overriddenSidebarLeft = true + } +} diff --git a/.config/quickshell/nucleus-shell/services/Hyprland.qml b/.config/quickshell/nucleus-shell/services/Hyprland.qml new file mode 100755 index 0000000..ba17fa0 --- /dev/null +++ b/.config/quickshell/nucleus-shell/services/Hyprland.qml @@ -0,0 +1,216 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland +import Quickshell.Wayland + +Singleton { + id: root + + // true if Hyprland is running, false otherwise + readonly property bool isHyprland: Compositor.require("hyprland") + + // reactive Hyprland data, only valid if Hyprland is running + signal stateChanged() + readonly property var toplevels: isHyprland ? Hyprland.toplevels : [] + readonly property var workspaces: isHyprland ? Hyprland.workspaces : [] + readonly property var monitors: isHyprland ? Hyprland.monitors : [] + readonly property Toplevel activeToplevel: isHyprland ? ToplevelManager.activeToplevel : null + readonly property HyprlandWorkspace focusedWorkspace: isHyprland ? Hyprland.focusedWorkspace : null + readonly property HyprlandMonitor focusedMonitor: isHyprland ? Hyprland.focusedMonitor : null + readonly property int focusedWorkspaceId: focusedWorkspace?.id ?? 1 + property real screenW: focusedMonitor ? focusedMonitor.width : 0 + property real screenH: focusedMonitor ? focusedMonitor.height : 0 + property real screenScale: focusedMonitor ? focusedMonitor.scale : 1 + + // parsed hyprctl data, defaults are empty + property var windowList: [] + property var windowByAddress: ({}) + property var addresses: [] + property var layers: ({}) + property var monitorsInfo: [] + property var workspacesInfo: [] + property var workspaceById: ({}) + property var workspaceIds: [] + property var activeWorkspaceInfo: null + property string keyboardLayout: "?" + + // dispatch a command to Hyprland, no-op if not running + function dispatch(request: string): void { + if (!isHyprland) return + Hyprland.dispatch(request) + } + + // switch workspace safely + function changeWorkspace(targetWorkspaceId) { + if (!isHyprland || !targetWorkspaceId) return + root.dispatch("workspace " + targetWorkspaceId) + } + + // find most recently focused window in a workspace + function focusedWindowForWorkspace(workspaceId) { + if (!isHyprland) return null + const wsWindows = root.windowList.filter(w => w.workspace.id === workspaceId) + if (wsWindows.length === 0) return null + return wsWindows.reduce((best, win) => { + const bestFocus = best?.focusHistoryID ?? Infinity + const winFocus = win?.focusHistoryID ?? Infinity + return winFocus < bestFocus ? win : best + }, null) + } + + // check if a workspace has any windows + function isWorkspaceOccupied(id: int): bool { + if (!isHyprland) return false + return Hyprland.workspaces.values.find(w => w?.id === id)?.lastIpcObject.windows > 0 || false + } + + // update all hyprctl processes + function updateAll() { + if (!isHyprland) return + getClients.running = true + getLayers.running = true + getMonitors.running = true + getWorkspaces.running = true + getActiveWorkspace.running = true + } + + // largest window in a workspace + function biggestWindowForWorkspace(workspaceId) { + if (!isHyprland) return null + const windowsInThisWorkspace = root.windowList.filter(w => w.workspace.id === workspaceId) + return windowsInThisWorkspace.reduce((maxWin, win) => { + const maxArea = (maxWin?.size?.[0] ?? 0) * (maxWin?.size?.[1] ?? 0) + const winArea = (win?.size?.[0] ?? 0) * (win?.size?.[1] ?? 0) + return winArea > maxArea ? win : maxWin + }, null) + } + + // refresh keyboard layout + function refreshKeyboardLayout() { + if (!isHyprland) return + hyprctlDevices.running = true + } + + // only create hyprctl processes if Hyprland is running + Component.onCompleted: { + if (isHyprland) { + updateAll() + refreshKeyboardLayout() + } + } + + // process to get keyboard layout + Process { + id: hyprctlDevices + running: false + command: ["hyprctl", "devices", "-j"] + stdout: StdioCollector { + onStreamFinished: { + try { + const devices = JSON.parse(this.text) + const keyboard = devices.keyboards.find(k => k.main) || devices.keyboards[0] + root.keyboardLayout = keyboard?.active_keymap?.toUpperCase()?.slice(0, 2) ?? "?" + } catch (err) { + console.error("Failed to parse keyboard layout:", err) + root.keyboardLayout = "?" + } + } + } + } + + Process { + id: getClients + running: false + command: ["hyprctl", "clients", "-j"] + stdout: StdioCollector { + onStreamFinished: { + try { + root.windowList = JSON.parse(this.text) + let tempWinByAddress = {} + for (let win of root.windowList) tempWinByAddress[win.address] = win + root.windowByAddress = tempWinByAddress + root.addresses = root.windowList.map(w => w.address) + } catch (e) { + console.error("Failed to parse clients:", e) + } + } + } + } + + Process { + id: getMonitors + running: false + command: ["hyprctl", "monitors", "-j"] + stdout: StdioCollector { + onStreamFinished: { + try { root.monitorsInfo = JSON.parse(this.text) } + catch (e) { console.error("Failed to parse monitors:", e) } + } + } + } + + Process { + id: getLayers + running: false + command: ["hyprctl", "layers", "-j"] + stdout: StdioCollector { + onStreamFinished: { + try { root.layers = JSON.parse(this.text) } + catch (e) { console.error("Failed to parse layers:", e) } + } + } + } + + Process { + id: getWorkspaces + running: false + command: ["hyprctl", "workspaces", "-j"] + stdout: StdioCollector { + onStreamFinished: { + try { + root.workspacesInfo = JSON.parse(this.text) + let map = {} + for (let ws of root.workspacesInfo) map[ws.id] = ws + root.workspaceById = map + root.workspaceIds = root.workspacesInfo.map(ws => ws.id) + } catch (e) { console.error("Failed to parse workspaces:", e) } + } + } + } + + Process { + id: getActiveWorkspace + running: false + command: ["hyprctl", "activeworkspace", "-j"] + stdout: StdioCollector { + onStreamFinished: { + try { root.activeWorkspaceInfo = JSON.parse(this.text) } + catch (e) { console.error("Failed to parse active workspace:", e) } + } + } + } + + // only connect to Hyprland events if running + Connections { + target: isHyprland ? Hyprland : null + function onRawEvent(event) { + if (!isHyprland || event.name.endsWith("v2")) return + + if (event.name.includes("activelayout")) + refreshKeyboardLayout() + else if (event.name.includes("mon")) + Hyprland.refreshMonitors() + else if (event.name.includes("workspace") || event.name.includes("window")) + Hyprland.refreshWorkspaces() + else + Hyprland.refreshToplevels() + + updateAll() + root.stateChanged() + } + } +} diff --git a/.config/quickshell/nucleus-shell/services/Mpris.qml b/.config/quickshell/nucleus-shell/services/Mpris.qml new file mode 100755 index 0000000..567a3c5 --- /dev/null +++ b/.config/quickshell/nucleus-shell/services/Mpris.qml @@ -0,0 +1,136 @@ +import QtQuick +import Quickshell +import Quickshell.Services.Mpris +pragma Singleton + +Singleton { + id: root + + property alias activePlayer: instance.activePlayer + property bool isPlaying: activePlayer ? activePlayer.playbackState === MprisPlaybackState.Playing : false + property string title: activePlayer ? activePlayer.trackTitle : "No Media" + property string artist: activePlayer ? activePlayer.trackArtist : "" + property string album: activePlayer ? activePlayer.trackAlbum : "" + property string artUrl: activePlayer ? activePlayer.trackArtUrl : "" + property double position: 0 + property double length: activePlayer ? activePlayer.length : 0 + property var _players: Mpris.players.values + property int playerCount: _players.length + property var playerList: { + let list = []; + for (let p of _players) { + list.push({ + "identity": p.identity || p.desktopEntry || "Unknown", + "desktopEntry": p.desktopEntry || "", + "player": p + }); + } + return list; + } + property string currentPlayerName: activePlayer ? (activePlayer.identity || activePlayer.desktopEntry || "Unknown") : "" + property bool manualSelection: false + + function setPosition(pos) { + if (activePlayer) + activePlayer.position = pos; + + } + + function selectPlayer(player) { + if (player) { + instance.activePlayer = player; + manualSelection = true; + } + } + + function selectNextPlayer() { + const players = Mpris.players.values; + if (players.length <= 1) + return ; + + const currentIndex = players.indexOf(activePlayer); + const nextIndex = (currentIndex + 1) % players.length; + selectPlayer(players[nextIndex]); + } + + function selectPreviousPlayer() { + const players = Mpris.players.values; + if (players.length <= 1) + return ; + + const currentIndex = players.indexOf(activePlayer); + const prevIndex = (currentIndex - 1 + players.length) % players.length; + selectPlayer(players[prevIndex]); + } + + function updateActivePlayer() { + const players = Mpris.players.values; + if (manualSelection && instance.activePlayer && players.includes(instance.activePlayer)) + return ; + + if (manualSelection && instance.activePlayer && !players.includes(instance.activePlayer)) + manualSelection = false; + + const playing = players.find((p) => { + return p.playbackState === MprisPlaybackState.Playing; + }); + if (playing) { + instance.activePlayer = playing; + } else if (players.length > 0) { + if (!instance.activePlayer || !players.includes(instance.activePlayer)) + instance.activePlayer = players[0]; + + } else { + instance.activePlayer = null; + } + } + + function playPause() { + if (activePlayer && activePlayer.canTogglePlaying) + activePlayer.togglePlaying(); + + } + + function next() { + if (activePlayer && activePlayer.canGoNext) + activePlayer.next(); + + } + + function previous() { + if (activePlayer && activePlayer.canGoPrevious) + activePlayer.previous(); + + } + + Component.onCompleted: updateActivePlayer() + + QtObject { + id: instance + + property var players: Mpris.players.values + property var activePlayer: null + } + + Timer { + interval: 1000 + running: true + repeat: true + onTriggered: { + updateActivePlayer(); + if (activePlayer) + root.position = activePlayer.position; + + } + } + + Connections { + function onValuesChanged() { + root._players = Mpris.players.values; + updateActivePlayer(); + } + + target: Mpris.players + } + +} \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/services/Network.qml b/.config/quickshell/nucleus-shell/services/Network.qml new file mode 100755 index 0000000..58f9369 --- /dev/null +++ b/.config/quickshell/nucleus-shell/services/Network.qml @@ -0,0 +1,309 @@ +pragma Singleton +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + readonly property list connections: [] + readonly property list savedNetworks: [] + readonly property Connection active: connections.find(c => c.active) ?? null + + property bool wifiEnabled: true + readonly property bool scanning: rescanProc.running + + property string lastNetworkAttempt: "" + property string lastErrorMessage: "" + property string message: "" + + readonly property string icon: { + if (!active) return "signal_wifi_off"; + if (active.type === "ethernet") return "settings_ethernet"; + + if (active.strength >= 75) return "network_wifi"; + else if (active.strength >= 50) return "network_wifi_3_bar"; + else if (active.strength >= 25) return "network_wifi_2_bar"; + else return "network_wifi_1_bar"; + } + + readonly property string wifiLabel: { + const activeWifi = connections.find(c => c.active && c.type === "wifi"); + if (activeWifi) return activeWifi.name; + return "Wi-Fi"; + } + + readonly property string wifiStatus: { + const activeWifi = connections.find(c => c.active && c.type === "wifi"); + if (activeWifi) return "Connected"; + if (wifiEnabled) return "On"; + return "Off"; + } + + readonly property string label: { + if (active) return active.name; + if (wifiEnabled) return "Wi-Fi"; + return "Wi-Fi"; + } + + readonly property string status: { + if (active) return "Connected"; + if (wifiEnabled) return "On"; + return "Off"; + } + + function enableWifi(enabled: bool): void { + enableWifiProc.exec(["nmcli", "radio", "wifi", enabled ? "on" : "off"]); + } + + function toggleWifi(): void { + enableWifi(!wifiEnabled); + } + + function rescan(): void { + rescanProc.running = true; + } + + function connect(connection: Connection, password: string): void { + if (connection.type === "wifi") { + root.lastNetworkAttempt = connection.name; + root.lastErrorMessage = ""; + root.message = ""; + + if (password && password.length > 0) { + connectProc.exec(["nmcli", "dev", "wifi", "connect", connection.name, "password", password]); + } else { + connectProc.exec(["nmcli", "dev", "wifi", "connect", connection.name]); + } + } else if (connection.type === "ethernet") { + ethConnectProc.exec(["nmcli", "connection", "up", connection.uuid]); + } + } + + function disconnect(): void { + if (!active) return; + + if (active.type === "wifi") { + disconnectProc.exec(["nmcli", "connection", "down", active.name]); + } else if (active.type === "ethernet") { + ethDisconnectProc.exec(["nmcli", "connection", "down", active.uuid]); + } + } + + Process { + running: true + command: ["nmcli", "m"] + stdout: SplitParser { + onRead: { + getWifiStatus(); + updateConnections(); + } + } + } + + function getWifiStatus(): void { + wifiStatusProc.running = true; + } + + function updateConnections(): void { + getWifiNetworks.running = true; + getEthConnections.running = true; + getSavedNetworks.running = true; + } + + Process { + id: wifiStatusProc + running: true + command: ["nmcli", "radio", "wifi"] + environment: ({ LANG: "C.UTF-8", LC_ALL: "C.UTF-8" }) + stdout: StdioCollector { + onStreamFinished: { + root.wifiEnabled = text.trim() === "enabled"; + if (!root.wifiEnabled) { + root.lastErrorMessage = ""; + root.message = ""; + root.lastNetworkAttempt = ""; + } + } + } + } + + Process { + id: enableWifiProc + onExited: updateConnections() + } + + Process { + id: rescanProc + command: ["nmcli", "dev", "wifi", "list", "--rescan", "yes"] + onExited: updateConnections() + } + + Process { + id: connectProc + stdout: StdioCollector { } + stderr: StdioCollector { + onStreamFinished: { + if (text.includes("Error") || text.includes("incorrect")) { + root.lastErrorMessage = "Incorrect password"; + } + } + } + onExited: { + if (exitCode === 0) { + root.message = "ok"; + root.lastErrorMessage = ""; + } else { + root.message = root.lastErrorMessage !== "" ? root.lastErrorMessage : "Connection failed"; + } + updateConnections(); + } + } + + Process { + id: disconnectProc + onExited: updateConnections() + } + + Process { + id: ethConnectProc + onExited: updateConnections() + } + + Process { + id: ethDisconnectProc + onExited: updateConnections() + } + + Process { + id: getSavedNetworks + running: true + command: ["nmcli", "-g", "NAME,TYPE", "connection", "show"] + environment: ({ LANG: "C.UTF-8", LC_ALL: "C.UTF-8" }) + stdout: StdioCollector { + onStreamFinished: { + const lines = text.trim().split("\n"); + const wifiConnections = lines + .map(line => line.split(":")) + .filter(parts => parts[1] === "802-11-wireless") + .map(parts => parts[0]); + root.savedNetworks = wifiConnections; + } + } + } + + Process { + id: getWifiNetworks + running: true + command: ["nmcli", "-g", "ACTIVE,SIGNAL,FREQ,SSID,BSSID,SECURITY", "d", "w"] + environment: ({ LANG: "C.UTF-8", LC_ALL: "C.UTF-8" }) + stdout: StdioCollector { + onStreamFinished: { + const PLACEHOLDER = "STRINGWHICHHOPEFULLYWONTBEUSED"; + const rep = new RegExp("\\\\:", "g"); + const rep2 = new RegExp(PLACEHOLDER, "g"); + + const allNetworks = text.trim().split("\n").map(n => { + const net = n.replace(rep, PLACEHOLDER).split(":"); + return { + type: "wifi", + active: net[0] === "yes", + strength: parseInt(net[1]), + frequency: parseInt(net[2]), + name: net[3]?.replace(rep2, ":") ?? "", + bssid: net[4]?.replace(rep2, ":") ?? "", + security: net[5] ?? "", + saved: root.savedNetworks.includes(net[3] ?? ""), + uuid: "", + device: "" + }; + }).filter(n => n.name && n.name.length > 0); + + const networkMap = new Map(); + for (const network of allNetworks) { + const existing = networkMap.get(network.name); + if (!existing) { + networkMap.set(network.name, network); + } else { + if (network.active && !existing.active) { + networkMap.set(network.name, network); + } else if (!network.active && !existing.active && network.strength > existing.strength) { + networkMap.set(network.name, network); + } + } + } + + mergeConnections(Array.from(networkMap.values()), "wifi"); + } + } + } + + Process { + id: getEthConnections + running: true + command: ["nmcli", "-g", "NAME,UUID,TYPE,DEVICE,STATE", "connection", "show"] + environment: ({ LANG: "C.UTF-8", LC_ALL: "C.UTF-8" }) + stdout: StdioCollector { + onStreamFinished: { + const lines = text.trim().split("\n"); + const ethConns = lines + .map(line => line.split(":")) + .filter(parts => parts[2] === "802-3-ethernet" || parts[2] === "gsm" || parts[2] === "bluetooth") + .map(parts => ({ + type: "ethernet", + name: parts[0], + uuid: parts[1], + device: parts[3], + active: parts[4] === "activated", + strength: 100, + frequency: 0, + bssid: "", + security: "", + saved: true + })); + + mergeConnections(ethConns, "ethernet"); + } + } + } + + function mergeConnections(newConns: var, connType: string): void { + const rConns = root.connections; + const destroyed = rConns.filter(rc => rc.type === connType && !newConns.find(nc => + connType === "wifi" ? (nc.frequency === rc.frequency && nc.name === rc.name && nc.bssid === rc.bssid) + : nc.uuid === rc.uuid + )); + + for (const conn of destroyed) + rConns.splice(rConns.indexOf(conn), 1).forEach(c => c.destroy()); + + for (const conn of newConns) { + const match = rConns.find(c => + connType === "wifi" ? (c.frequency === conn.frequency && c.name === conn.name && c.bssid === conn.bssid) + : c.uuid === conn.uuid + ); + if (match) { + match.lastIpcObject = conn; + } else { + rConns.push(connComp.createObject(root, { lastIpcObject: conn })); + } + } + } + + component Connection: QtObject { + required property var lastIpcObject + readonly property string type: lastIpcObject.type + readonly property string name: lastIpcObject.name + readonly property string uuid: lastIpcObject.uuid + readonly property string device: lastIpcObject.device + readonly property bool active: lastIpcObject.active + readonly property int strength: lastIpcObject.strength + readonly property int frequency: lastIpcObject.frequency + readonly property string bssid: lastIpcObject.bssid + readonly property string security: lastIpcObject.security + readonly property bool isSecure: security.length > 0 + readonly property bool saved: lastIpcObject.saved + } + + Component { id: connComp; Connection { } } +} \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/services/Niri.qml b/.config/quickshell/nucleus-shell/services/Niri.qml new file mode 100644 index 0000000..bf5cff5 --- /dev/null +++ b/.config/quickshell/nucleus-shell/services/Niri.qml @@ -0,0 +1,242 @@ +import QtQuick +import Quickshell +import Quickshell.Wayland +import Quickshell.Io +pragma Singleton + +Singleton { + id: niriItem + + signal stateChanged() + + property string title: "" + property bool isFullscreen: false + property string layout: "Tiled" + property int focusedWorkspaceId: 1 + + property var workspaces: [] + property var workspaceCache: ({}) + property var windows: [] // tracked windows + + property bool initialized: false + property int screenW: 0 + property int screenH: 0 + property real screenScale: 1 + + function changeWorkspace(id) { + sendSocketCommand(niriCommandSocket, { + "Action": { + "focus_workspace": { + "reference": { "Id": id } + } + } + }) + dispatchProc.command = ["niri", "msg", "action", "focus-workspace", id.toString()] + dispatchProc.running = true + } + + function changeWorkspaceRelative(delta) { + const cmd = delta > 0 ? "focus-workspace-down" : "focus-workspace-up" + dispatchProc.command = ["niri", "msg", "action", cmd] + dispatchProc.running = true + } + + function isWorkspaceOccupied(id) { + for (const ws of workspaces) + if (ws.id === id) return true + return false + } + + function focusedWindowForWorkspace(id) { + // focused window in workspace + for (const win of windows) { + if (win.workspaceId === id && win.isFocused) { + return { class: win.appId, title: win.title } + } + } + + // fallback: any window in workspace + for (const win of windows) { + if (win.workspaceId === id) { + return { class: win.appId, title: win.title } + } + } + + return null + } + + function sendSocketCommand(sock, command) { + if (sock.connected) + sock.write(JSON.stringify(command) + "\n") + } + + function startEventStream() { + sendSocketCommand(niriEventStream, "EventStream") + } + + function updateWorkspaces() { + sendSocketCommand(niriCommandSocket, "Workspaces") + } + + function updateWindows() { + sendSocketCommand(niriCommandSocket, "Windows") + } + + function updateFocusedWindow() { + sendSocketCommand(niriCommandSocket, "FocusedWindow") + } + + function recollectWorkspaces(workspacesData) { + const list = [] + workspaceCache = {} + + for (const ws of workspacesData) { + const data = { + id: ws.idx !== undefined ? ws.idx + 1 : ws.id, + internalId: ws.id, + idx: ws.idx, + name: ws.name || "", + output: ws.output || "", + isFocused: ws.is_focused === true, + isActive: ws.is_active === true + } + + list.push(data) + workspaceCache[ws.id] = data + if (data.isFocused) + focusedWorkspaceId = data.id + } + + list.sort((a, b) => a.id - b.id) + workspaces = list + stateChanged() + } + + function recollectWindows(windowsData) { + const list = [] + + for (const win of windowsData) { + list.push({ + appId: win.app_id || "", + title: win.title || "", + workspaceId: win.workspace_id, + isFocused: win.is_focused === true + }) + } + + windows = list + stateChanged() + } + + function recollectFocusedWindow(win) { + if (win && win.title) { + title = win.title + isFullscreen = win.is_fullscreen || false + layout = "Tiled" + } else { + title = "~" + isFullscreen = false + layout = "Tiled" + } + stateChanged() + } + + Component.onCompleted: { + if (Quickshell.env("NIRI_SOCKET")) { + niriCommandSocket.connected = true + niriEventStream.connected = true + initialized = true + } + } + + Process { + id: niriOutputsProc + command: ["niri", "msg", "outputs"] + running: true + + stdout: StdioCollector { + onStreamFinished: { + const lines = this.text.split("\n") + let sizeRe = /Logical size:\s*(\d+)x(\d+)/ + let scaleRe = /Scale:\s*([\d.]+)/ + + for (const line of lines) { + let m + if ((m = sizeRe.exec(line))) { + screenW = parseInt(m[1], 10) + screenH = parseInt(m[2], 10) + } else if ((m = scaleRe.exec(line))) { + screenScale = parseFloat(m[1]) + } + } + stateChanged() + } + } + } + + Socket { + id: niriCommandSocket + path: Quickshell.env("NIRI_SOCKET") || "" + connected: false + + onConnectedChanged: { + if (connected) { + updateWorkspaces() + updateWindows() + updateFocusedWindow() + } + } + + parser: SplitParser { + onRead: (line) => { + if (!line.trim()) return + try { + const data = JSON.parse(line) + if (data?.Ok) { + const res = data.Ok + if (res.Workspaces) recollectWorkspaces(res.Workspaces) + else if (res.Windows) recollectWindows(res.Windows) + else if (res.FocusedWindow) recollectFocusedWindow(res.FocusedWindow) + } + } catch (e) { + console.warn("Niri socket parse error:", e) + } + } + } + } + + Socket { + id: niriEventStream + path: Quickshell.env("NIRI_SOCKET") || "" + connected: false + + onConnectedChanged: { + if (connected) + startEventStream() + } + + parser: SplitParser { + onRead: (data) => { + if (!data.trim()) return + try { + const event = JSON.parse(data.trim()) + + if (event.WorkspacesChanged) + recollectWorkspaces(event.WorkspacesChanged.workspaces) + else if (event.WorkspaceActivated) + updateWorkspaces() + else if ( + event.WindowFocusChanged || + event.WindowOpenedOrChanged || + event.WindowClosed + ) + updateWindows() + } catch (e) { + console.warn("Niri event stream parse error:", e) + } + } + } + } + + Process { id: dispatchProc } +} diff --git a/.config/quickshell/nucleus-shell/services/NotifServer.qml b/.config/quickshell/nucleus-shell/services/NotifServer.qml new file mode 100755 index 0000000..0e7cd8d --- /dev/null +++ b/.config/quickshell/nucleus-shell/services/NotifServer.qml @@ -0,0 +1,110 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Io +import Quickshell.Services.Notifications +import QtQuick +import qs.config + +// from github.com/end-4/dots-hyprland with modifications + +Singleton { + id: root + + property list data: [] + property list popups: data.filter(n => n.popup && !n.tracked) + property list history: data + + Loader { + active: Config.initialized && Config.runtime.notifications.enabled + sourceComponent: NotificationServer { + keepOnReload: false + actionsSupported: true + bodyHyperlinksSupported: true + bodyImagesSupported: true + bodyMarkupSupported: true + imageSupported: true + + onNotification: notif => { + notif.tracked = true; + + root.data.push(notifComp.createObject(root, { + popup: true, + notification: notif, + shown: false + })); + } + } + } + function removeById(id) { + const i = data.findIndex(n => n.notification.id === id); + if (i >= 0) { + data.splice(i, 1); + } + } + + + component Notif: QtObject { + id: notif + + property bool popup + readonly property date time: new Date() + readonly property string timeStr: { + const diff = Time.date.getTime() - time.getTime(); + const m = Math.floor(diff / 60000); + const h = Math.floor(m / 60); + + if (h < 1 && m < 1) + return "now"; + if (h < 1) + return `${m}m`; + return `${h}h`; + } + + property bool shown: false + required property Notification notification + readonly property string summary: notification.summary + readonly property string body: notification.body + readonly property string appIcon: notification.appIcon + readonly property string appName: notification.appName + readonly property string image: notification.image + readonly property int urgency: notification.urgency + readonly property list actions: notification.actions + + readonly property Timer timer: Timer { + running: notif.actions.length >= 0 + interval: notif.notification.expireTimeout > 0 ? notif.notification.expireTimeout : 5000 + onTriggered: { + if (true) + notif.popup = false; + } + } + + readonly property Connections conn: Connections { + target: notif.notification.Retainable + + function onDropped(): void { + root.data.splice(root.data.indexOf(notif), 1); + } + + function onAboutToDestroy(): void { + notif.destroy(); + } + } + readonly property Connections conn2: Connections { + target: notif.notification + + function onClosed(reason) { + root.data.splice(root.data.indexOf(notif), 1) + } + } + + } + + Component { + id: notifComp + + Notif {} + } +} diff --git a/.config/quickshell/nucleus-shell/services/Polkit.qml b/.config/quickshell/nucleus-shell/services/Polkit.qml new file mode 100644 index 0000000..df4deac --- /dev/null +++ b/.config/quickshell/nucleus-shell/services/Polkit.qml @@ -0,0 +1,22 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Io +import Quickshell.Services.Polkit +import QtQuick + +Singleton { + id: root + + property alias isActive: polkit.isActive + property alias isRegistered: polkit.isRegistered + property alias flow: polkit.flow + property alias path: polkit.path + + Component.onCompleted: { + } + PolkitAgent { + id: polkit + } +} \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/services/SystemDetails.qml b/.config/quickshell/nucleus-shell/services/SystemDetails.qml new file mode 100644 index 0000000..1b2a149 --- /dev/null +++ b/.config/quickshell/nucleus-shell/services/SystemDetails.qml @@ -0,0 +1,394 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import qs.config +pragma Singleton + +Singleton { + id: root + + property string hostname: "" + property string username: "" + property string osIcon: "" + property string osName: "" + property string kernelVersion: "" + property string architecture: "" + property string uptime: "" + property string qsVersion: "" + property string swapUsage: "—" + property real swapPercent: 0 + property string ipAddress: "—" + property int runningProcesses: 0 + property int loggedInUsers: 0 + property string ramUsage: "—" + property real ramPercent: 0 + property string cpuLoad: "—" + property real cpuPercent: 0 + property string diskUsage: "—" + property real diskPercent: 0 + property string cpuTemp: "—" + property string keyboardLayout: "none" + property real cpuTempPercent: 0 + property int prevIdle: -1 + property int prevTotal: -1 + + + readonly property var osIcons: ({ + "almalinux": "", + "alpine": "", + "arch": "󰣇", + "archcraft": "", + "arcolinux": "", + "artix": "", + "centos": "", + "debian": "", + "devuan": "", + "elementary": "", + "endeavouros": "", + "fedora": "", + "freebsd": "", + "garuda": "", + "gentoo": "", + "hyperbola": "", + "kali": "", + "linuxmint": "󰣭", + "mageia": "", + "openmandriva": "", + "manjaro": "", + "neon": "", + "nixos": "", + "opensuse": "", + "suse": "", + "sles": "", + "sles_sap": "", + "opensuse-tumbleweed": "", + "parrot": "", + "pop": "", + "raspbian": "", + "rhel": "", + "rocky": "", + "slackware": "", + "solus": "", + "steamos": "", + "tails": "", + "trisquel": "", + "ubuntu": "", + "vanilla": "", + "void": "", + "zorin": "", + "opensuse": "", + + }) + + + FileView { id: cpuStat; path: "/proc/stat" } + FileView { id: memInfo; path: "/proc/meminfo" } + FileView { id: uptimeFile; path: "/proc/uptime" } + + + Timer { + interval: 1000 + running: true + repeat: true + + onTriggered: { + + cpuStat.reload() + memInfo.reload() + uptimeFile.reload() + + /* CPU */ + + const cpuLine = cpuStat.text().split("\n")[0].trim().split(/\s+/) + + const cpuUser = parseInt(cpuLine[1]) + const cpuNice = parseInt(cpuLine[2]) + const cpuSystem = parseInt(cpuLine[3]) + const cpuIdle = parseInt(cpuLine[4]) + const cpuIowait = parseInt(cpuLine[5]) + const cpuIrq = parseInt(cpuLine[6]) + const cpuSoftirq = parseInt(cpuLine[7]) + + const cpuIdleAll = cpuIdle + cpuIowait + const cpuTotal = + cpuUser + cpuNice + cpuSystem + cpuIrq + cpuSoftirq + cpuIdleAll + + if (root.prevTotal >= 0) { + + const totalDiff = cpuTotal - root.prevTotal + const idleDiff = cpuIdleAll - root.prevIdle + + if (totalDiff > 0) + root.cpuPercent = (totalDiff - idleDiff) / totalDiff + } + + root.prevTotal = cpuTotal + root.prevIdle = cpuIdleAll + + root.cpuLoad = Math.round(root.cpuPercent * 100) + "%" + + + /* RAM */ + + const memLines = memInfo.text().split("\n") + + let memTotal = 0 + let memAvailable = 0 + + for (let line of memLines) { + + if (line.startsWith("MemTotal")) + memTotal = parseInt(line.match(/\d+/)[0]) + + if (line.startsWith("MemAvailable")) + memAvailable = parseInt(line.match(/\d+/)[0]) + } + + if (memTotal > 0) { + + const memUsed = memTotal - memAvailable + + root.ramPercent = memUsed / memTotal + root.ramUsage = + `${Math.round(memUsed/1024)}/${Math.round(memTotal/1024)} MB` + } + + + /* Uptime */ + + const uptimeSeconds = + parseFloat(uptimeFile.text().split(" ")[0]) + + const d = Math.floor(uptimeSeconds / 86400) + const h = Math.floor((uptimeSeconds % 86400) / 3600) + const m = Math.floor((uptimeSeconds % 3600) / 60) + + let upString = "Up " + + if (d > 0) upString += d + "d " + if (h > 0) upString += h + "h " + + upString += m + "m" + + root.uptime = upString + + + cpuTempProc.running = true + diskProc.running = true + ipProc.running = true + procCountProc.running = true + swapProc.running = true + keyboardLayoutProc.running = true + loggedUsersProc.running = true + } + } + + + /* CPU Temperature */ + + Process { + id: cpuTempProc + command: [ + "sh","-c", + "for f in /sys/class/hwmon/hwmon*/temp*_input; do cat $f && exit; done" + ] + + stdout: StdioCollector { + onStreamFinished: { + const raw = parseInt(text.trim()) + if (isNaN(raw)) return + + const c = raw / 1000 + root.cpuTemp = `${Math.round(c)}°C` + + const min = 30 + const max = 95 + + root.cpuTempPercent = + Math.max(0, Math.min(1,(c-min)/(max-min))) + } + } + } + + + /* Disk */ + + Process { + id: diskProc + command: ["df","-h","/"] + + stdout: StdioCollector { + onStreamFinished: { + + const lines = text.trim().split("\n") + if (lines.length < 2) return + + const parts = lines[1].split(/\s+/) + + const used = parts[2] + const total = parts[1] + const percent = parseInt(parts[4]) / 100 + + root.diskPercent = percent + root.diskUsage = `${used}/${total}` + } + } + } + + + /* Swap */ + + Process { + id: swapProc + command: ["sh","-c","free -m | grep Swap"] + + stdout: StdioCollector { + onStreamFinished: { + + const parts = text.trim().split(/\s+/) + if (parts.length < 3) return + + const total = parseInt(parts[1]) + const used = parseInt(parts[2]) + + root.swapPercent = used / total + root.swapUsage = `${used}/${total} MB` + } + } + } + + + /* IP */ + + Process { + id: ipProc + command: ["sh","-c","hostname -I | awk '{print $1}'"] + + stdout: StdioCollector { + onStreamFinished: root.ipAddress = text.trim() + } + } + + + /* Process Count */ + + Process { + id: procCountProc + command: ["sh","-c","ps -e --no-headers | wc -l"] + + stdout: StdioCollector { + onStreamFinished: root.runningProcesses = parseInt(text.trim()) + } + } + + + /* Logged Users */ + + Process { + id: loggedUsersProc + command: ["who","-q"] + + stdout: StdioCollector { + onStreamFinished: { + const lines = text.trim().split("\n") + if (lines.length > 0) + root.loggedInUsers = + parseInt(lines[lines.length-1].replace("# users=","")) + } + } + } + + + /* Keyboard Layout */ + + Process { + id: keyboardLayoutProc + command: [ + "sh","-c", + "hyprctl devices -j | jq -r '.keyboards[] | .layout' | head -n1" + ] + + stdout: StdioCollector { + onStreamFinished: { + const layout = text.trim() + if (layout) + root.keyboardLayout = layout + } + } + } + + + /* OS Info */ + + Process { + running: true + command: [ + "sh","-c", + "source /etc/os-release && echo \"$PRETTY_NAME|$ID\"" + ] + + stdout: StdioCollector { + onStreamFinished: { + const parts = text.trim().split("|") + root.osName = parts[0] + root.osIcon = root.osIcons[parts[1]] || "" + } + } + } + + + /* Quickshell Version */ + + Process { + running: true + command: ["qs","--version"] + + stdout: StdioCollector { + onStreamFinished: { + root.qsVersion = + text.trim().split(',')[0] + .replace("quickshell ","") + } + } + } + + + /* Static system info */ + + Process { + running: true + command: ["whoami"] + + stdout: StdioCollector { + onStreamFinished: root.username = text.trim() + } + } + + Process { + running: true + command: ["hostname"] + + stdout: StdioCollector { + onStreamFinished: root.hostname = text.trim() + } + } + + Process { + running: true + command: ["uname","-r"] + + stdout: StdioCollector { + onStreamFinished: root.kernelVersion = text.trim() + } + } + + Process { + running: true + command: ["uname","-m"] + + stdout: StdioCollector { + onStreamFinished: root.architecture = text.trim() + } + } + +} diff --git a/.config/quickshell/nucleus-shell/services/Theme.qml b/.config/quickshell/nucleus-shell/services/Theme.qml new file mode 100644 index 0000000..0289472 --- /dev/null +++ b/.config/quickshell/nucleus-shell/services/Theme.qml @@ -0,0 +1,54 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import qs.config +pragma Singleton + +Singleton { + id: root + property var map: ({ + }) + + function notifyMissingVariant(theme, variant) { + Quickshell.execDetached(["notify-send", "Nucleus Shell", `Theme '${theme}' does not have a ${variant} variant.`, "--urgency=normal", "--expire-time=5000"]); + } + + Timer { + interval: 5000 + repeat: true + running: true + onTriggered: loadThemes.running = true + } + + Process { + id: loadThemes + + command: ["ls", Directories.shellConfig + "/colorschemes"] + running: true + + stdout: StdioCollector { + onStreamFinished: { + const map = { + }; + text.split("\n").map((t) => { + return t.trim(); + }).filter((t) => { + return t.endsWith(".json"); + }).forEach((t) => { + const name = t.replace(/\.json$/, ""); + const parts = name.split("-"); + const variant = parts.pop(); + const base = parts.join("-"); + if (!map[base]) + map[base] = { + }; + + map[base][variant] = name; + }); + root.map = map; + } + } + + } + +} diff --git a/.config/quickshell/nucleus-shell/services/Time.qml b/.config/quickshell/nucleus-shell/services/Time.qml new file mode 100755 index 0000000..a494433 --- /dev/null +++ b/.config/quickshell/nucleus-shell/services/Time.qml @@ -0,0 +1,20 @@ +pragma Singleton +import Quickshell +import QtQuick + +Singleton { + id: root + + property alias date: clock.date // expose raw date/time + readonly property SystemClock clock: clock + + SystemClock { + id: clock + precision: SystemClock.Seconds + } + + // Helper function if you still want formatting ability: + function format(fmt) { + return Qt.formatDateTime(clock.date, fmt) + } +} diff --git a/.config/quickshell/nucleus-shell/services/UPower.qml b/.config/quickshell/nucleus-shell/services/UPower.qml new file mode 100644 index 0000000..a37cb6f --- /dev/null +++ b/.config/quickshell/nucleus-shell/services/UPower.qml @@ -0,0 +1,136 @@ +import QtQuick +import Quickshell +import Quickshell.Io +pragma Singleton + +Item { + // I have to make such services because quickshell services like Quickshell.Services.UPower don't work and are messy. + + id: root + + // Battery + property int percentage: 0 + property string state: "unknown" + property string iconName: "" + property bool onBattery: false + property bool charging: false + property bool batteryPresent: false + property bool rechargeable: false + // Energy metrics + property real energyWh: 0 + property real energyFullWh: 0 + property real energyRateW: 0 + property real capacityPercent: 0 + // AC / system + property bool acOnline: false + property bool lidClosed: false + property string battIcon: { + const b = percentage; + if (b > 80) + return "battery_6_bar"; + + if (b > 60) + return "battery_5_bar"; + + if (b > 50) + return "battery_4_bar"; + + if (b > 40) + return "battery_3_bar"; + + if (b > 30) + return "battery_2_bar"; + + if (b > 20) + return "battery_1_bar"; + + if (b > 10) + return "battery_alert"; + + return "battery_0_bar"; + } + + Timer { + interval: 2000 + running: true + repeat: true + onTriggered: upowerProc.running = true + } + + Process { + id: upowerProc + + command: ["upower", "-d"] + running: true + + stdout: StdioCollector { + onStreamFinished: { + // ---------- DisplayDevice (preferred) ---------- + // ---------- Rechargeable ---------- + // ---------- Physical battery (extra info) ---------- + // ---------- Daemon / system ---------- + // ---------- AC adapter ---------- + + const t = text; + let m; + m = t.match(/DisplayDevice[\s\S]*?present:\s+(yes|no)/); + if (m) { + root.batteryPresent = (m[1] === "yes"); + } else { + // fallback: physical battery + m = t.match(/battery_BAT\d+[\s\S]*?present:\s+(yes|no)/); + if (m) + root.batteryPresent = (m[1] === "yes"); + + } + m = t.match(/DisplayDevice[\s\S]*?rechargeable:\s+(yes|no)/); + if (m) + root.rechargeable = (m[1] === "yes"); + + m = t.match(/DisplayDevice[\s\S]*?percentage:\s+(\d+)%/); + if (m) + root.percentage = parseInt(m[1]); + + m = t.match(/DisplayDevice[\s\S]*?state:\s+([a-z\-]+)/); + if (m) { + root.state = m[1]; + root.charging = (m[1].includes("charge")); + } + m = t.match(/DisplayDevice[\s\S]*?icon-name:\s+'([^']+)'/); + if (m) + root.iconName = m[1]; + + m = t.match(/DisplayDevice[\s\S]*?energy:\s+([\d.]+)\s+Wh/); + if (m) + root.energyWh = parseFloat(m[1]); + + m = t.match(/DisplayDevice[\s\S]*?energy-full:\s+([\d.]+)\s+Wh/); + if (m) + root.energyFullWh = parseFloat(m[1]); + + m = t.match(/DisplayDevice[\s\S]*?energy-rate:\s+([\d.]+)\s+W/); + if (m) + root.energyRateW = parseFloat(m[1]); + + m = t.match(/capacity:\s+([\d.]+)%/); + if (m) + root.capacityPercent = parseFloat(m[1]); + + m = t.match(/on-battery:\s+(yes|no)/); + if (m) + root.onBattery = (m[1] === "yes"); + + m = t.match(/lid-is-closed:\s+(yes|no)/); + if (m) + root.lidClosed = (m[1] === "yes"); + + m = t.match(/line-power[\s\S]*?online:\s+(yes|no)/); + if (m) + root.acOnline = (m[1] === "yes"); + + } + } + + } + +} diff --git a/.config/quickshell/nucleus-shell/services/UpdateNotifier.qml b/.config/quickshell/nucleus-shell/services/UpdateNotifier.qml new file mode 100644 index 0000000..1fbdc4b --- /dev/null +++ b/.config/quickshell/nucleus-shell/services/UpdateNotifier.qml @@ -0,0 +1,100 @@ +import Qt.labs.platform +import QtQuick +import Quickshell +import Quickshell.Io +import qs.config + +Item { + id: updater + // Add 'v' arg to default local version because it is not stored + // as vX.Y.Z but X.Y.Z while on github its published as vX.Y.Z + + property string currentVersion: "" + property string latestVersion: "" + property bool notified: false + property string channel: Config.runtime.shell.releaseChannel || "stable" + + function readLocalVersion() { + currentVersion = "v" + (Config.runtime.shell.version || ""); + } + + function fetchLatestVersion() { + var xhr = new XMLHttpRequest(); + xhr.onreadystatechange = function() { + if (xhr.readyState === XMLHttpRequest.DONE) { + try { + const json = JSON.parse(xhr.responseText); + + if (channel === "stable") { + // /releases/latest returns a single object + if (json.tag_name) { + latestVersion = json.tag_name; + compareVersions(); + } else { + console.warn("Stable update check returned unexpected response:", json); + } + } else if (channel === "edge") { + // /releases returns an array, newest first + for (var i = 0; i < json.length; i++) { + if (json[i].prerelease === true) { + latestVersion = json[i].tag_name; + compareVersions(); + return; + } + } + console.warn("Edge channel: no pre-release found."); + } + } catch (e) { + console.warn("Update check JSON parse failed:", xhr.responseText); + } + } + }; + + if (channel === "stable") { + xhr.open( + "GET", + "https://api.github.com/repos/xzepyx/nucleus-shell/releases/latest" + ); + } else { + xhr.open( + "GET", + "https://api.github.com/repos/xzepyx/nucleus-shell/releases" + ); + } + + xhr.send(); + } + + function compareVersions() { + if (!currentVersion || !latestVersion) + return; + + if (currentVersion !== latestVersion && !notified) { + notifyUpdate(); + notified = true; + } + } + + function notifyUpdate() { + Quickshell.execDetached([ + "notify-send", + "-a", "Nucleus Shell", + "Update Available", + "Installed: " + currentVersion + + "\nLatest (" + channel + "): " + latestVersion + ]); + } + + visible: false + + Timer { + interval: 24 * 60 * 60 * 1000 // 24 hours + running: true + repeat: true + triggeredOnStart: true + onTriggered: { + readLocalVersion(); + fetchLatestVersion(); + } + } +} diff --git a/.config/quickshell/nucleus-shell/services/Volume.qml b/.config/quickshell/nucleus-shell/services/Volume.qml new file mode 100755 index 0000000..d1125eb --- /dev/null +++ b/.config/quickshell/nucleus-shell/services/Volume.qml @@ -0,0 +1,50 @@ +pragma Singleton +import QtQuick +import Quickshell +import Quickshell.Services.Pipewire + + +Singleton { + PwObjectTracker { + objects: [ + Pipewire.defaultAudioSource, + Pipewire.defaultAudioSink, + Pipewire.nodes, + Pipewire.links + ] + } + + property var sinks: Pipewire.nodes.values.filter(node => node.isSink && !node.isStream && node.audio) + property PwNode defaultSink: Pipewire.defaultAudioSink + + property var sources: Pipewire.nodes.values.filter(node => !node.isSink && !node.isStream && node.audio) + property PwNode defaultSource: Pipewire.defaultAudioSource + + property real volume: defaultSink?.audio?.volume ?? 0 + property bool muted: defaultSink?.audio?.muted ?? false + + function setVolume(to: real): void { + if (defaultSink?.ready && defaultSink?.audio) { + defaultSink.audio.muted = false; + defaultSink.audio.volume = Math.max(0, Math.min(1, to)); + } + } + + function setSourceVolume(to: real): void { + if (defaultSource?.ready && defaultSource?.audio) { + defaultSource.audio.muted = false; + defaultSource.audio.volume = Math.max(0, Math.min(1, to)); + } + } + + function setDefaultSink(sink: PwNode): void { + Pipewire.preferredDefaultAudioSink = sink; + } + + function setDefaultSource(source: PwNode): void { + Pipewire.preferredDefaultAudioSource = source; + } + + function init() { + } +} \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/services/WallpaperSlideshow.qml b/.config/quickshell/nucleus-shell/services/WallpaperSlideshow.qml new file mode 100644 index 0000000..a5c4d92 --- /dev/null +++ b/.config/quickshell/nucleus-shell/services/WallpaperSlideshow.qml @@ -0,0 +1,140 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Io +import qs.config + +Singleton { + id: root + + property var wallpapers: [] + property bool scanning: false + property string currentFolder: Config.initialized ? Config.runtime.appearance.background.slideshow.folder : ""; + property int intervalMinutes: Config.initialized ? Config.runtime.appearance.background.slideshow.interval : 5 + property bool enabled: Config.initialized ? Config.runtime.appearance.background.slideshow.enabled : false + property bool includeSubfolders: Config.initialized ? Config.runtime.appearance.background.slideshow.includeSubfolders : true + property bool initializedOnce: false + property bool hydratingFromConfig: true + + signal wallpaperChanged(string path) + + function nextWallpaper() { + if (wallpapers.length === 0) { + console.warn("WallpaperSlideshow: No wallpapers found in folder"); + return; + } + const randomIndex = Math.floor(Math.random() * wallpapers.length); + const selectedPath = "file://" + wallpapers[randomIndex]; + Config.updateKey("appearance.background.path", selectedPath); + wallpaperChanged(selectedPath); + + // Regenerate colors + if (Config.runtime.appearance.colors.autogenerated) { + Quickshell.execDetached([ + "nucleus", "ipc", "call", "global", "regenColors" + ]); + } + } + + function scanFolder() { + if (!currentFolder || currentFolder === "") { + wallpapers = []; + return; + } + scanning = true; + scanProcess.running = true; + } + + // Timer for automatic wallpaper rotation + Timer { + id: slideshowTimer + interval: root.intervalMinutes * 60 * 1000 // Convert minutes into miliseconds + repeat: true + running: root.enabled && root.wallpapers.length > 0 && root.initializedOnce + onTriggered: root.nextWallpaper() + } + + // Process to scan folder for images + Process { + id: scanProcess + command: root.includeSubfolders + ? ["find", root.currentFolder, "-type", "f", "-iregex", ".*\\.\\(jpg\\|jpeg\\|png\\|webp\\|bmp\\|svg\\)$"] + : ["find", root.currentFolder, "-maxdepth", "1", "-type", "f", "-iregex", ".*\\.\\(jpg\\|jpeg\\|png\\|webp\\|bmp\\|svg\\)$"] + + stdout: SplitParser { + splitMarker: "" + onRead: data => { + const lines = data.trim().split("\n").filter(line => line.length > 0); + root.wallpapers = lines; + root.scanning = false; + } + } + + onExited: (exitCode, exitStatus) => { + root.scanning = false; + if (exitCode !== 0) { + console.warn("WallpaperSlideshow: Failed to scan folder"); + root.wallpapers = []; + } + } + } + + // Watch for folder changes - rescan and immediately change wallpaper + onCurrentFolderChanged: { + if (root.hydratingFromConfig) + return; + if (!currentFolder || currentFolder === "") + return; + scanning = true; + folderChangeScanProcess.running = true; + } + + // Separate process for folder change scan (triggers immediate wallpaper change) + Process { + id: folderChangeScanProcess + command: root.includeSubfolders + ? ["find", root.currentFolder, "-type", "f", "-iregex", ".*\\.\\(jpg\\|jpeg\\|png\\|webp\\|bmp\\|svg\\)$"] + : ["find", root.currentFolder, "-maxdepth", "1", "-type", "f", "-iregex", ".*\\.\\(jpg\\|jpeg\\|png\\|webp\\|bmp\\|svg\\)$"] + + stdout: SplitParser { + splitMarker: "" + onRead: data => { + const lines = data.trim().split("\n").filter(line => line.length > 0); + root.wallpapers = lines; + root.scanning = false; + + // Immediately change wallpaper when folder is changed + if (root.wallpapers.length > 0) { + root.nextWallpaper(); + } + } + } + + onExited: (exitCode, exitStatus) => { + root.scanning = false; + if (exitCode !== 0) { + console.warn("WallpaperSlideshow: Failed to scan folder"); + root.wallpapers = []; + } + } + } + + // Watch for includeSubfolders changes + onIncludeSubfoldersChanged: { + if (currentFolder && currentFolder !== "") { + scanFolder(); + } + } + + // Initial scan when config is loaded + Connections { + target: Config + function onInitializedChanged() { + if (Config.initialized && root.currentFolder && root.currentFolder !== "") { + root.scanFolder(); + } + } + } +} diff --git a/.config/quickshell/nucleus-shell/services/Xrandr.qml b/.config/quickshell/nucleus-shell/services/Xrandr.qml new file mode 100644 index 0000000..46aebcb --- /dev/null +++ b/.config/quickshell/nucleus-shell/services/Xrandr.qml @@ -0,0 +1,55 @@ +import QtQuick +import Quickshell +import Quickshell.Io +pragma Singleton + +/* + + Why use this service? Good question. + :- Cause xrandr works with all compositors to fetch monitor data. + +*/ + +Item { + id: root + + // Array of monitor objects: { name, width, height, x, y, physWidth, physHeight } + property var monitors: [] + + // Refresh monitors every 5 seconds + Timer { + interval: 5000 + running: true + repeat: true + onTriggered: xrandrProc.running = true + } + + Process { + id: xrandrProc + command: ["bash", "-c", "xrandr --query | grep ' connected '"] + running: true + + stdout: StdioCollector { + onStreamFinished: { // I don't even know wtf is this I can't explain shit + const lines = text.trim().split("\n") + root.monitors = lines.map(line => { + const m = line.match(/^(\S+)\sconnected\s(\d+)x(\d+)\+(\d+)\+(\d+).*?(\d+)mm\sx\s(\d+)mm$/) + if (!m) return null + return { + name: m[1], + width: parseInt(m[2]), + height: parseInt(m[3]), + x: parseInt(m[4]), + y: parseInt(m[5]), + physWidth: parseInt(m[6]), + physHeight: parseInt(m[7]) + } + }).filter(m => m) + } + } + } + + function getMonitor(name) { + return monitors.find(m => m.name === name) || null + } +} \ No newline at end of file diff --git a/.config/quickshell/nucleus-shell/services/Zenith.qml b/.config/quickshell/nucleus-shell/services/Zenith.qml new file mode 100644 index 0000000..e2cae5c --- /dev/null +++ b/.config/quickshell/nucleus-shell/services/Zenith.qml @@ -0,0 +1,82 @@ +// Zenith.properties +pragma Singleton +import QtQuick +import Quickshell +import Quickshell.Io +import qs.config +import qs.modules.functions + +Singleton { + // current state and shit + property string currentChat: "default" + property string currentModel: "gpt-4o-mini" + property string pendingInput: "" + property bool loading: zenithProcess.running + + // signals (needed for ui loading) + signal chatsListed(string text) + signal chatLoaded(string text) + signal aiReply(string text) + + // process to load data and talk to zenith + + Timer { + interval: 1000 + repeat: true + running: true + onTriggered: listChatsProcess.running = true; + } + + Process { + id: listChatsProcess + command: ["ls", FileUtils.trimFileProtocol(Directories.config) + "/zenith/chats"] + running: true + + stdout: StdioCollector { + onStreamFinished: chatsListed(text) + } + } + + Process { + id: chatLoadProcess + + stdout: StdioCollector { + onStreamFinished: chatLoaded(text) + } + } + + Process { + id: zenithProcess + + command: [ + "zenith", + "--api", Config.runtime.misc.intelligence.apiKey, + "--chat", currentChat, + "-a", + "--model", currentModel, + pendingInput + ] + + stdout: StdioCollector { + onStreamFinished: { + if (text.trim() !== "") + aiReply(text.trim()) + } + } + } + + // api shit + + function loadChat(chatName) { + chatLoadProcess.command = [ + "cat", + FileUtils.trimFileProtocol(Directories.config) + + "/zenith/chats/" + chatName + ".txt" + ] + chatLoadProcess.running = true + } + + function send() { + zenithProcess.running = true + } +} diff --git a/.config/quickshell/nucleus-shell/shell.qml b/.config/quickshell/nucleus-shell/shell.qml new file mode 100644 index 0000000..836bee5 --- /dev/null +++ b/.config/quickshell/nucleus-shell/shell.qml @@ -0,0 +1,87 @@ +import Quickshell +import QtQuick + +import qs.config +import qs.plugins +import qs.services +import qs.modules.interface.bar +import qs.modules.interface.background +import qs.modules.interface.powermenu +import qs.modules.interface.launcher +import qs.modules.interface.notifications +// import qs.modules.interface.intelligence // Intelligence +import qs.modules.interface.overlays +import qs.modules.interface.sidebarRight +import qs.modules.interface.settings +import qs.modules.interface.sidebarLeft +import qs.modules.interface.lockscreen +import qs.modules.interface.screencapture +import qs.modules.interface.polkit + +ShellRoot { + id: shellroot + + // Load modules + + LazyLoader { + id: barLoader + source: Contracts.bar + active: Config.runtime.bar.enabled && !Contracts.overriddenBar + } + + LazyLoader { + id: backgroundLoader + source: Contracts.background + active: Config.runtime.appearance.background.enabled && !Contracts.overriddenBackground + } + + LazyLoader { + id: powerMenuLoader + source: Contracts.powerMenu + active: Globals.visiblility.powermenu && !Contracts.overriddenPowerMenu + } + + LazyLoader { + id: launcherLoader + source: Contracts.launcher + active: true && !Contracts.overriddenLauncher + } + + LazyLoader { + id: notificationsLoader + source: Contracts.notifications + active: Config.runtime.notifications.enabled && !Contracts.overriddenNotifications + } + + LazyLoader { + id: overlaysLoader + source: Contracts.overlays + active: Config.runtime.overlays.enabled && !Contracts.overriddenOverlays + } + + LazyLoader { + id: sidebarRightLoader + source: Contracts.sidebarRight + active: Globals.visiblility.sidebarRight && !Contracts.overriddenSidebarRight + } + + LazyLoader { + id: sidebarLeftLoader + source: Contracts.sidebarLeft + active: Globals.visiblility.sidebarLeft && !Contracts.overriddenSidebarLeft + } + + LazyLoader { + id: lockScreenLoader + source: Contracts.lockScreen + active: true && !Contracts.overriddenLockScreen + } + + Settings { } + Ipc { } + // Intelligence { } + UpdateNotifier { } + // PluginHost { } + ScreenCapture{ } + PolkitAgent { } +} diff --git a/.config/quickshell/zenbar/shell.qml b/.config/quickshell/zenbar/shell.qml new file mode 100644 index 0000000..4cca9d2 --- /dev/null +++ b/.config/quickshell/zenbar/shell.qml @@ -0,0 +1,87 @@ +import Quickshell +import QtQuick + +import qs.config +import qs.plugins +import qs.services +import qs.modules.interface.bar +import qs.modules.interface.background +import qs.modules.interface.powermenu +import qs.modules.interface.launcher +import qs.modules.interface.notifications +import qs.modules.interface.intelligence // Intelligence +import qs.modules.interface.overlays +import qs.modules.interface.sidebarRight +import qs.modules.interface.settings +import qs.modules.interface.sidebarLeft +import qs.modules.interface.lockscreen +import qs.modules.interface.screencapture +import qs.modules.interface.polkit + +ShellRoot { + id: shellroot + + // Load modules + + LazyLoader { + id: barLoader + source: Contracts.bar + active: Config.runtime.bar.enabled && !Contracts.overriddenBar + } + + LazyLoader { + id: backgroundLoader + source: Contracts.background + active: Config.runtime.appearance.background.enabled && !Contracts.overriddenBackground + } + + LazyLoader { + id: powerMenuLoader + source: Contracts.powerMenu + active: Globals.visiblility.powermenu && !Contracts.overriddenPowerMenu + } + + LazyLoader { + id: launcherLoader + source: Contracts.launcher + active: true && !Contracts.overriddenLauncher + } + + LazyLoader { + id: notificationsLoader + source: Contracts.notifications + active: Config.runtime.notifications.enabled && !Contracts.overriddenNotifications + } + + LazyLoader { + id: overlaysLoader + source: Contracts.overlays + active: Config.runtime.overlays.enabled && !Contracts.overriddenOverlays + } + + LazyLoader { + id: sidebarRightLoader + source: Contracts.sidebarRight + active: Globals.visiblility.sidebarRight && !Contracts.overriddenSidebarRight + } + + LazyLoader { + id: sidebarLeftLoader + source: Contracts.sidebarLeft + active: Globals.visiblility.sidebarLeft && !Contracts.overriddenSidebarLeft + } + + LazyLoader { + id: lockScreenLoader + source: Contracts.lockScreen + active: true && !Contracts.overriddenLockScreen + } + + Settings { } + Ipc { } + Intelligence { } + UpdateNotifier { } + PluginHost { } + ScreenCapture{ } + PolkitAgent { } +}