From ca514ac2fac448d917e0114c9ca73bbf3826803b Mon Sep 17 00:00:00 2001 From: Uyanide Date: Fri, 6 Mar 2026 14:12:18 +0100 Subject: [PATCH] better quickshell --- README.md | 10 +- config/niri/.config/niri/config/binds.kdl | 28 +- config/niri/.config/niri/config/execs.kdl | 5 +- config/niri/.config/niri/config/styles.kdl | 2 +- config/nwg-look/.config/gtk-3.0/bookmarks | 1 - .../quickshell/Assets/{Images => }/Avatar.jpg | Bin .../quickshell/Assets/Cache/.gitignore | 7 + .../quickshell/Assets/Config/.gitignore | 4 +- .../quickshell/Assets/Config/LyricsOffset.txt | 1 - .../quickshell/Assets/Config/Settings.json | 10 - .../quickshell/Assets/Config/colors.json | 5 + ...er-icons.ttf => noctalia-tabler-icons.ttf} | Bin 2409764 -> 2409476 bytes .../quickshell/Components/NScrollView.qml | 216 +++ .../.config/quickshell/Components/UBox.qml | 29 + .../UBusyIndicator.qml} | 3 +- .../.config/quickshell/Components/UButton.qml | 133 ++ .../.config/quickshell/Components/UClock.qml | 427 ++++++ .../UContextMenu.qml} | 90 +- .../NDivider.qml => Components/UDivider.qml} | 8 +- .../quickshell/Components/UDropShadow.qml | 31 + .../.config/quickshell/Components/UIcon.qml | 19 + .../quickshell/Components/UIconButton.qml | 83 ++ .../quickshell/Components/UImageRounded.qml | 69 + .../.config/quickshell/Components/ULabel.qml | 57 + .../UListView.qml} | 172 ++- .../UProgressExpand.qml} | 36 +- .../UScrollView.qml} | 4 +- .../.config/quickshell/Components/USlider.qml | 275 ++++ .../.config/quickshell/Components/UTabBar.qml | 88 ++ .../quickshell/Components/UTabButton.qml | 108 ++ .../NText.qml => Components/UText.qml} | 6 +- .../NToggle.qml => Components/UToggle.qml} | 45 +- .../.config/quickshell/Constants/Color.qml | 26 - .../.config/quickshell/Constants/Colors.qml | 178 ++- .../.config/quickshell/Constants/Fonts.qml | 5 +- .../.config/quickshell/Constants/Icons.qml | 58 +- .../{TablerIcons.qml => IconsTabler.qml} | 198 +-- .../.config/quickshell/Constants/Paths.qml | 11 + .../.config/quickshell/Constants/Style.qml | 24 +- .../Modules/Background/Background.qml | 167 +++ .../.config/quickshell/Modules/Bar/Bar.qml | 208 ++- .../Modules/Bar/Components/CpuTemp.qml | 28 - .../Modules/Bar/Components/CpuUsage.qml | 28 - .../Modules/Bar/Components/Separator.qml | 7 +- .../Bar/{Misc => Components}/SystemTray.qml | 6 +- .../Modules/Bar/Components/Time.qml | 22 - .../Bar/{Misc => Components}/TrayMenu.qml | 22 +- .../Modules/Bar/Misc/SymbolButton.qml | 64 - .../Bar/{Components => Modules}/Battery.qml | 11 +- .../{Components => Modules}/Brightness.qml | 8 +- .../Bar/{Components => Modules}/CavaBar.qml | 14 +- .../Modules/Bar/Modules/CpuTemp.qml | 17 + .../Modules/Bar/Modules/CpuUsage.qml | 20 + .../{Components => Modules}/FocusedWindow.qml | 24 +- .../Bar/{Components => Modules}/Ip.qml | 9 +- .../Bar/{Components => Modules}/LyricsBar.qml | 64 +- .../Bar/{Components => Modules}/MemUsage.qml | 23 +- .../{Components => Modules}/NetworkSpeed.qml | 22 +- .../RecordIndicator.qml | 36 +- .../quickshell/Modules/Bar/Modules/Time.qml | 10 + .../{Components => Modules}/TrayExpander.qml | 17 +- .../Bar/{Components => Modules}/Volume.qml | 10 +- .../Bar/{Components => Modules}/Workspace.qml | 160 +-- .../Bar/{Misc => Services}/CavaBarService.qml | 2 +- .../Modules/Bar/Services/MonitorProcess.qml | 24 + .../quickshell/Modules/Misc/Corner.qml | 2 +- .../quickshell/Modules/Misc/Corners.qml | 43 +- .../quickshell/Modules/Misc/Notification.qml | 1219 +++++++++++------ .../Modules/Panel/BluetoothPanel.qml | 254 ---- .../Modules/Panel/CalendarPanel.qml | 526 ------- .../Modules/Panel/Cards/LyricsControl.qml | 96 -- .../Modules/Panel/Cards/MediaCard.qml | 458 ------- .../Modules/Panel/Cards/SystemMonitorCard.qml | 95 -- .../Modules/Panel/Cards/TopLeftCard.qml | 175 --- .../Modules/Panel/ControlCenterPanel.qml | 87 -- .../Modules/Panel/Misc/MonitorSlider.qml | 54 - .../Panel/NotificationHistoryPanel.qml | 341 ----- .../quickshell/Modules/Panel/WiFiPanel.qml | 633 --------- .../Sidebar/Components/SidebarWrap.qml | 79 ++ .../Misc/BluetoothDevicesList.qml | 67 +- .../Modules/Sidebar/Modules/BluetoothCard.qml | 157 +++ .../Sidebar/Modules/CalendarHeaderCard.qml | 133 ++ .../Sidebar/Modules/CalendarMonthCard.qml | 344 +++++ .../Sidebar/Modules/ConnectionCard.qml | 211 +++ .../Cards => Sidebar/Modules}/LyricsCard.qml | 10 +- .../Modules/Sidebar/Modules/LyricsControl.qml | 111 ++ .../Modules/Sidebar/Modules/MediaCard.qml | 483 +++++++ .../Modules/NotificationHistoryCard.qml | 939 +++++++++++++ .../Modules/Sidebar/Modules/PowerMenuCard.qml | 240 ++++ .../Modules/Sidebar/Modules/WeatherCard.qml | 227 +++ .../Modules/Sidebar/Modules/WifiCard.qml | 559 ++++++++ .../quickshell/Modules/Sidebar/Sidebars.qml | 83 ++ .../.config/quickshell/Noctalia/NBox.qml | 28 - .../.config/quickshell/Noctalia/NButton.qml | 183 --- .../quickshell/Noctalia/NCircleStat.qml | 122 -- .../.config/quickshell/Noctalia/NIcon.qml | 28 - .../quickshell/Noctalia/NIconButton.qml | 92 -- .../quickshell/Noctalia/NImageCircled.qml | 85 -- .../quickshell/Noctalia/NImageRounded.qml | 103 -- .../.config/quickshell/Noctalia/NLabel.qml | 34 - .../.config/quickshell/Noctalia/NPanel.qml | 459 ------- .../.config/quickshell/Noctalia/NSlider.qml | 152 -- .../quickshell/Services/AudioService.qml | 666 +++++++-- .../quickshell/Services/BackgroundService.qml | 94 ++ .../quickshell/Services/BarService.qml | 62 + .../quickshell/Services/BatteryService.qml | 285 ++++ .../quickshell/Services/BluetoothService.qml | 8 +- .../quickshell/Services/BrightnessService.qml | 454 ++++-- .../quickshell/Services/CacheService.qml | 34 - .../.config/quickshell/Services/Caffeine.qml | 156 ++- .../quickshell/Services/HostService.qml | 67 + .../quickshell/Services/IPCService.qml | 115 +- .../quickshell/Services/ImageCacheService.qml | 771 +++++++++++ .../.config/quickshell/Services/Init.qml | 36 + .../.config/quickshell/Services/IpService.qml | 85 +- .../quickshell/Services/LocationService.qml | 258 ++-- .../quickshell/Services/LyricsService.qml | 33 +- .../quickshell/Services/MediaService.qml | 345 +++++ .../quickshell/Services/MusicManager.qml | 180 --- .../quickshell/Services/NetworkFetch.qml | 23 +- .../quickshell/Services/NetworkService.qml | 51 +- .../.config/quickshell/Services/Niri.qml | 643 ++++++--- .../Services/NotificationService.qml | 1005 ++++++++++---- .../.config/quickshell/Services/NukeKded6.qml | 6 +- .../quickshell/Services/PanelService.qml | 421 +++++- ...werProfileService.qml => PowerService.qml} | 31 +- .../quickshell/Services/RecordService.qml | 122 +- .../quickshell/Services/Screenshot.qml | 16 - .../quickshell/Services/SettingsService.qml | 34 +- .../quickshell/Services/ShellState.qml | 72 + .../quickshell/Services/SunsetService.qml | 79 +- .../quickshell/Services/SystemStatService.qml | 1151 +++++++++++----- .../quickshell/Services/ThemeIcons.qml | 309 ++++- .../quickshell/Services/WorkspaceManager.qml | 56 - .../quickshell/{Utils => Services}/sha256.js | 0 .../Shaders/frag/rounded_image.frag | 98 ++ .../Shaders/frag/weather_cloud.frag | 123 ++ .../quickshell/Shaders/frag/weather_rain.frag | 84 ++ .../quickshell/Shaders/frag/weather_snow.frag | 75 + .../Shaders/frag/weather_stars.frag | 130 ++ .../quickshell/Shaders/frag/weather_sun.frag | 148 ++ .../Shaders/qsb/circled_image.frag.qsb | Bin 1717 -> 0 bytes .../Shaders/qsb/rounded_image.frag.qsb | Bin 2767 -> 4113 bytes .../Shaders/qsb/weather_cloud.frag.qsb | Bin 0 -> 6082 bytes .../Shaders/qsb/weather_rain.frag.qsb | Bin 0 -> 5275 bytes .../Shaders/qsb/weather_snow.frag.qsb | Bin 0 -> 5327 bytes .../Shaders/qsb/weather_stars.frag.qsb | Bin 0 -> 6639 bytes .../Shaders/qsb/weather_sun.frag.qsb | Bin 0 -> 7373 bytes .../.config/quickshell/Utils/Cava.qml | 2 +- .../quickshell/Utils/CavaColorList.qml | 10 - .../.config/quickshell/Utils/FuzzySort.qml | 883 ++++++++++++ .../.config/quickshell/Utils/Logger.qml | 55 +- .../.config/quickshell/Utils/Time.qml | 84 +- .../quickshell/.config/quickshell/apply-color | 13 +- .../quickshell/.config/quickshell/shell.qml | 45 +- config/wallpaper/.config/wallreel/config.json | 65 +- config/yazi/.config/yazi/package.toml | 6 +- .../.config/yazi/plugins/git.yazi/main.lua | 3 +- 158 files changed, 14613 insertions(+), 7286 deletions(-) rename config/quickshell/.config/quickshell/Assets/{Images => }/Avatar.jpg (100%) create mode 100644 config/quickshell/.config/quickshell/Assets/Cache/.gitignore delete mode 100644 config/quickshell/.config/quickshell/Assets/Config/LyricsOffset.txt delete mode 100644 config/quickshell/.config/quickshell/Assets/Config/Settings.json create mode 100644 config/quickshell/.config/quickshell/Assets/Config/colors.json rename config/quickshell/.config/quickshell/Assets/Fonts/tabler/{tabler-icons.ttf => noctalia-tabler-icons.ttf} (92%) create mode 100644 config/quickshell/.config/quickshell/Components/NScrollView.qml create mode 100644 config/quickshell/.config/quickshell/Components/UBox.qml rename config/quickshell/.config/quickshell/{Noctalia/NBusyIndicator.qml => Components/UBusyIndicator.qml} (95%) create mode 100644 config/quickshell/.config/quickshell/Components/UButton.qml create mode 100644 config/quickshell/.config/quickshell/Components/UClock.qml rename config/quickshell/.config/quickshell/{Noctalia/NContextMenu.qml => Components/UContextMenu.qml} (55%) rename config/quickshell/.config/quickshell/{Noctalia/NDivider.qml => Components/UDivider.qml} (76%) create mode 100644 config/quickshell/.config/quickshell/Components/UDropShadow.qml create mode 100644 config/quickshell/.config/quickshell/Components/UIcon.qml create mode 100644 config/quickshell/.config/quickshell/Components/UIconButton.qml create mode 100644 config/quickshell/.config/quickshell/Components/UImageRounded.qml create mode 100644 config/quickshell/.config/quickshell/Components/ULabel.qml rename config/quickshell/.config/quickshell/{Noctalia/NListView.qml => Components/UListView.qml} (53%) rename config/quickshell/.config/quickshell/{Modules/Bar/Misc/MonitorItem.qml => Components/UProgressExpand.qml} (86%) rename config/quickshell/.config/quickshell/{Noctalia/NScrollView.qml => Components/UScrollView.qml} (97%) create mode 100644 config/quickshell/.config/quickshell/Components/USlider.qml create mode 100644 config/quickshell/.config/quickshell/Components/UTabBar.qml create mode 100644 config/quickshell/.config/quickshell/Components/UTabButton.qml rename config/quickshell/.config/quickshell/{Noctalia/NText.qml => Components/UText.qml} (72%) rename config/quickshell/.config/quickshell/{Noctalia/NToggle.qml => Components/UToggle.qml} (55%) delete mode 100644 config/quickshell/.config/quickshell/Constants/Color.qml rename config/quickshell/.config/quickshell/Constants/{TablerIcons.qml => IconsTabler.qml} (97%) create mode 100644 config/quickshell/.config/quickshell/Constants/Paths.qml create mode 100644 config/quickshell/.config/quickshell/Modules/Background/Background.qml delete mode 100644 config/quickshell/.config/quickshell/Modules/Bar/Components/CpuTemp.qml delete mode 100644 config/quickshell/.config/quickshell/Modules/Bar/Components/CpuUsage.qml rename config/quickshell/.config/quickshell/Modules/Bar/{Misc => Components}/SystemTray.qml (95%) delete mode 100644 config/quickshell/.config/quickshell/Modules/Bar/Components/Time.qml rename config/quickshell/.config/quickshell/Modules/Bar/{Misc => Components}/TrayMenu.qml (93%) delete mode 100644 config/quickshell/.config/quickshell/Modules/Bar/Misc/SymbolButton.qml rename config/quickshell/.config/quickshell/Modules/Bar/{Components => Modules}/Battery.qml (67%) rename config/quickshell/.config/quickshell/Modules/Bar/{Components => Modules}/Brightness.qml (87%) rename config/quickshell/.config/quickshell/Modules/Bar/{Components => Modules}/CavaBar.qml (90%) create mode 100644 config/quickshell/.config/quickshell/Modules/Bar/Modules/CpuTemp.qml create mode 100644 config/quickshell/.config/quickshell/Modules/Bar/Modules/CpuUsage.qml rename config/quickshell/.config/quickshell/Modules/Bar/{Components => Modules}/FocusedWindow.qml (87%) rename config/quickshell/.config/quickshell/Modules/Bar/{Components => Modules}/Ip.qml (87%) rename config/quickshell/.config/quickshell/Modules/Bar/{Components => Modules}/LyricsBar.qml (51%) rename config/quickshell/.config/quickshell/Modules/Bar/{Components => Modules}/MemUsage.qml (56%) rename config/quickshell/.config/quickshell/Modules/Bar/{Components => Modules}/NetworkSpeed.qml (61%) rename config/quickshell/.config/quickshell/Modules/Bar/{Components => Modules}/RecordIndicator.qml (68%) create mode 100644 config/quickshell/.config/quickshell/Modules/Bar/Modules/Time.qml rename config/quickshell/.config/quickshell/Modules/Bar/{Components => Modules}/TrayExpander.qml (70%) rename config/quickshell/.config/quickshell/Modules/Bar/{Components => Modules}/Volume.qml (62%) rename config/quickshell/.config/quickshell/Modules/Bar/{Components => Modules}/Workspace.qml (53%) rename config/quickshell/.config/quickshell/Modules/Bar/{Misc => Services}/CavaBarService.qml (93%) create mode 100644 config/quickshell/.config/quickshell/Modules/Bar/Services/MonitorProcess.qml delete mode 100644 config/quickshell/.config/quickshell/Modules/Panel/BluetoothPanel.qml delete mode 100644 config/quickshell/.config/quickshell/Modules/Panel/CalendarPanel.qml delete mode 100644 config/quickshell/.config/quickshell/Modules/Panel/Cards/LyricsControl.qml delete mode 100644 config/quickshell/.config/quickshell/Modules/Panel/Cards/MediaCard.qml delete mode 100644 config/quickshell/.config/quickshell/Modules/Panel/Cards/SystemMonitorCard.qml delete mode 100644 config/quickshell/.config/quickshell/Modules/Panel/Cards/TopLeftCard.qml delete mode 100644 config/quickshell/.config/quickshell/Modules/Panel/ControlCenterPanel.qml delete mode 100644 config/quickshell/.config/quickshell/Modules/Panel/Misc/MonitorSlider.qml delete mode 100644 config/quickshell/.config/quickshell/Modules/Panel/NotificationHistoryPanel.qml delete mode 100644 config/quickshell/.config/quickshell/Modules/Panel/WiFiPanel.qml create mode 100644 config/quickshell/.config/quickshell/Modules/Sidebar/Components/SidebarWrap.qml rename config/quickshell/.config/quickshell/Modules/{Panel => Sidebar}/Misc/BluetoothDevicesList.qml (76%) create mode 100644 config/quickshell/.config/quickshell/Modules/Sidebar/Modules/BluetoothCard.qml create mode 100644 config/quickshell/.config/quickshell/Modules/Sidebar/Modules/CalendarHeaderCard.qml create mode 100644 config/quickshell/.config/quickshell/Modules/Sidebar/Modules/CalendarMonthCard.qml create mode 100644 config/quickshell/.config/quickshell/Modules/Sidebar/Modules/ConnectionCard.qml rename config/quickshell/.config/quickshell/Modules/{Panel/Cards => Sidebar/Modules}/LyricsCard.qml (88%) create mode 100644 config/quickshell/.config/quickshell/Modules/Sidebar/Modules/LyricsControl.qml create mode 100644 config/quickshell/.config/quickshell/Modules/Sidebar/Modules/MediaCard.qml create mode 100644 config/quickshell/.config/quickshell/Modules/Sidebar/Modules/NotificationHistoryCard.qml create mode 100644 config/quickshell/.config/quickshell/Modules/Sidebar/Modules/PowerMenuCard.qml create mode 100644 config/quickshell/.config/quickshell/Modules/Sidebar/Modules/WeatherCard.qml create mode 100644 config/quickshell/.config/quickshell/Modules/Sidebar/Modules/WifiCard.qml create mode 100644 config/quickshell/.config/quickshell/Modules/Sidebar/Sidebars.qml delete mode 100644 config/quickshell/.config/quickshell/Noctalia/NBox.qml delete mode 100644 config/quickshell/.config/quickshell/Noctalia/NButton.qml delete mode 100644 config/quickshell/.config/quickshell/Noctalia/NCircleStat.qml delete mode 100644 config/quickshell/.config/quickshell/Noctalia/NIcon.qml delete mode 100644 config/quickshell/.config/quickshell/Noctalia/NIconButton.qml delete mode 100644 config/quickshell/.config/quickshell/Noctalia/NImageCircled.qml delete mode 100644 config/quickshell/.config/quickshell/Noctalia/NImageRounded.qml delete mode 100644 config/quickshell/.config/quickshell/Noctalia/NLabel.qml delete mode 100644 config/quickshell/.config/quickshell/Noctalia/NPanel.qml delete mode 100644 config/quickshell/.config/quickshell/Noctalia/NSlider.qml create mode 100644 config/quickshell/.config/quickshell/Services/BackgroundService.qml create mode 100644 config/quickshell/.config/quickshell/Services/BarService.qml create mode 100644 config/quickshell/.config/quickshell/Services/BatteryService.qml delete mode 100644 config/quickshell/.config/quickshell/Services/CacheService.qml create mode 100644 config/quickshell/.config/quickshell/Services/HostService.qml create mode 100644 config/quickshell/.config/quickshell/Services/ImageCacheService.qml create mode 100644 config/quickshell/.config/quickshell/Services/Init.qml create mode 100644 config/quickshell/.config/quickshell/Services/MediaService.qml delete mode 100644 config/quickshell/.config/quickshell/Services/MusicManager.qml rename config/quickshell/.config/quickshell/Services/{PowerProfileService.qml => PowerService.qml} (77%) delete mode 100644 config/quickshell/.config/quickshell/Services/Screenshot.qml create mode 100644 config/quickshell/.config/quickshell/Services/ShellState.qml delete mode 100644 config/quickshell/.config/quickshell/Services/WorkspaceManager.qml rename config/quickshell/.config/quickshell/{Utils => Services}/sha256.js (100%) create mode 100644 config/quickshell/.config/quickshell/Shaders/frag/rounded_image.frag create mode 100644 config/quickshell/.config/quickshell/Shaders/frag/weather_cloud.frag create mode 100644 config/quickshell/.config/quickshell/Shaders/frag/weather_rain.frag create mode 100644 config/quickshell/.config/quickshell/Shaders/frag/weather_snow.frag create mode 100644 config/quickshell/.config/quickshell/Shaders/frag/weather_stars.frag create mode 100644 config/quickshell/.config/quickshell/Shaders/frag/weather_sun.frag delete mode 100644 config/quickshell/.config/quickshell/Shaders/qsb/circled_image.frag.qsb create mode 100644 config/quickshell/.config/quickshell/Shaders/qsb/weather_cloud.frag.qsb create mode 100644 config/quickshell/.config/quickshell/Shaders/qsb/weather_rain.frag.qsb create mode 100644 config/quickshell/.config/quickshell/Shaders/qsb/weather_snow.frag.qsb create mode 100644 config/quickshell/.config/quickshell/Shaders/qsb/weather_stars.frag.qsb create mode 100644 config/quickshell/.config/quickshell/Shaders/qsb/weather_sun.frag.qsb delete mode 100644 config/quickshell/.config/quickshell/Utils/CavaColorList.qml create mode 100644 config/quickshell/.config/quickshell/Utils/FuzzySort.qml diff --git a/README.md b/README.md index 7605bcc..590be35 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,6 @@ Niri & Quickshell -https://github.com/user-attachments/assets/7e2db305-58bc-4b3d-9c65-7dc0461aead7 -
@@ -48,11 +46,11 @@ https://github.com/user-attachments/assets/7e2db305-58bc-4b3d-9c65-7dc0461aead7 - Shell: **Fish** - Prompt: **Oh My Posh** - Terminal: **Kitty** & (**WezTerm** | Ghostty) -- Power Menu: **Wlogout** +- Power Menu: **Wlogout** & Quickshell - Colorscheme: **Catppuccin Mocha** - App Launcher: **Rofi** | Fuzzel - Desktop Widgets: Eww | **Quickshell** -- Wallpaper Daemon: **Awww** (previously Swww) +- Wallpaper Daemon: Awww | **Quickshell** - Notification Daemon: Mako | **Quickshell** (**bold**: currently preferred) @@ -67,7 +65,7 @@ Ported from Hyprland, and shares some of the desktop components such as hyprlock ## Quickshell -Not based on, but heavily depends on many modules from [noctalia-shell](https://github.com/noctalia-dev/noctalia-shell). A thousand thanks to their great work. +Not based on, but heavily depends on many modules from (an old version of) [noctalia-shell](https://github.com/noctalia-dev/noctalia-shell). A thousand thanks to their great work. This setup is currently only adapted for Niri. @@ -80,8 +78,6 @@ This setup is currently only adapted for Niri. ## Wallpaper & Colortheme - [WallReel](https://github.com/Uyanide/WallReel): an Image Carousel implemented with QtQuick to browse and set wallpapers from. -- [wallpaper-daemon](./config/scripts/.local/scripts/wallpaper-daemon): automatic blur (only works in niri). -- [change-wallpaper](./config/scripts/.local/scripts/change-wallpaper): script that changes wallpaper with a few extra features. - [change-colortheme](./config/scripts/.local/scripts/change-colortheme): script that extract colors from the current wallpaper and generate a catppuccin color scheme accordingly. - [backgrounds](https://github.com/Uyanide/backgrounds) collection for personal use (mostly waifus). diff --git a/config/niri/.config/niri/config/binds.kdl b/config/niri/.config/niri/config/binds.kdl index 27df1c4..4c7105c 100644 --- a/config/niri/.config/niri/config/binds.kdl +++ b/config/niri/.config/niri/config/binds.kdl @@ -21,11 +21,12 @@ binds { Mod+Return { spawn "kitty"; } Mod+Shift+W { spawn "wallreel"; } Mod+O { spawn-sh "pkill -x -n pwvucontrol || pwvucontrol"; } + Ctrl+Alt+Delete { spawn-sh "pkill -x wlogout || wlogout"; } // Quickshell - Mod+Space { spawn "qs" "ipc" "call" "panels" "toggleControlCenter"; } - Mod+Shift+D { spawn "qs" "ipc" "call" "panels" "toggleCalendar"; } - Mod+Shift+L { spawn "qs" "ipc" "call" "lyrics" "toggleBarLyrics"; } + Mod+Space { spawn "qs" "ipc" "call" "bars" "toggleLeft"; } + Mod+N { spawn "qs" "ipc" "call" "bars" "toggleRight"; } + Mod+Shift+L { spawn "qs" "ipc" "call" "bars" "toggleLyrics"; } Mod+Shift+K { spawn-sh "quickshell-kill || quickshell"; } Mod+I { spawn "qs" "ipc" "call" "idleInhibitor" "toggleInhibitor"; } Mod+Alt+R { spawn "qs" "ipc" "call" "recording" "startOrStopRecording"; } @@ -38,7 +39,6 @@ binds { // Actions Mod+V { spawn-sh "fzfclip-wrap"; } Mod+Period { spawn-sh "pkill -x rofi || rofi-emoji"; } - Ctrl+Alt+Delete { spawn-sh "pkill -x wlogout || wlogout -p layer-shell"; } Print { spawn "niri" "msg" "action" "screenshot-screen"; } Mod+Shift+S { spawn "niri" "msg" "action" "screenshot"; } Mod+Ctrl+Shift+S { spawn "niri" "msg" "action" "screenshot-window"; } @@ -48,18 +48,18 @@ binds { Mod+L { spawn "loginctl" "lock-session"; } // Media - XF86AudioRaiseVolume allow-when-locked=true { spawn-sh "wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 5%+"; } - XF86AudioLowerVolume allow-when-locked=true { spawn-sh "wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 5%-"; } - XF86AudioMute allow-when-locked=true { spawn-sh "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"; } - XF86AudioMicMute allow-when-locked=true { spawn-sh "wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle"; } - XF86AudioPlay allow-when-locked=true { spawn-sh "playerctl play-pause"; } - XF86AudioPause allow-when-locked=true { spawn-sh "playerctl play-pause"; } - XF86AudioNext allow-when-locked=true { spawn-sh "playerctl next"; } - XF86AudioPrev allow-when-locked=true { spawn-sh "playerctl previous"; } + XF86AudioRaiseVolume allow-when-locked=true { spawn "qs" "ipc" "call" "media" "volumeUp"; } + XF86AudioLowerVolume allow-when-locked=true { spawn "qs" "ipc" "call" "media" "volumeDown"; } + XF86AudioMute allow-when-locked=true { spawn "qs" "ipc" "call" "media" "toggleOutputMute"; } + XF86AudioMicMute allow-when-locked=true { spawn "qs" "ipc" "call" "media" "toggleInputMute"; } + XF86AudioPlay allow-when-locked=true { spawn "qs" "ipc" "call" "media" "playPause"; } + XF86AudioPause allow-when-locked=true { spawn "qs" "ipc" "call" "media" "playPause"; } + XF86AudioNext allow-when-locked=true { spawn "qs" "ipc" "call" "media" "next"; } + XF86AudioPrev allow-when-locked=true { spawn "qs" "ipc" "call" "media" "previous"; } // Brightness - XF86MonBrightnessUp allow-when-locked=true { spawn "set-brightness" "+10%"; } - XF86MonBrightnessDown allow-when-locked=true { spawn "set-brightness" "10%-"; } + XF86MonBrightnessUp allow-when-locked=true { spawn "qs" "ipc" "call" "brightness" "brightnessUp"; } + XF86MonBrightnessDown allow-when-locked=true { spawn "qs" "ipc" "call" "brightness" "brightnessDown"; } // Window management Mod+Tab repeat=false { toggle-overview; } diff --git a/config/niri/.config/niri/config/execs.kdl b/config/niri/.config/niri/config/execs.kdl index 88bfc48..afbb1ad 100644 --- a/config/niri/.config/niri/config/execs.kdl +++ b/config/niri/.config/niri/config/execs.kdl @@ -1,9 +1,6 @@ // Switch configs spawn-at-startup "config-switch" "niri" -// Wallpaper -spawn-at-startup "wallpaper-daemon" - // Not necessary maybe ... spawn-at-startup "fcitx5" @@ -23,7 +20,7 @@ spawn-at-startup "wl-paste" "--type" "image" "--watch" "cliphist" "store" spawn-at-startup "solaar" "-w" "hide" // Some other heavy apps -spawn-at-startup "sunshine" +// spawn-at-startup "sunshine" // spawn-at-startup "spotify" // spawn-at-startup "thunderbird" diff --git a/config/niri/.config/niri/config/styles.kdl b/config/niri/.config/niri/config/styles.kdl index fc86629..5e4f03f 100644 --- a/config/niri/.config/niri/config/styles.kdl +++ b/config/niri/.config/niri/config/styles.kdl @@ -60,7 +60,7 @@ animations { } layer-rule { - match namespace="^swww-daemonbackdrop$" + match namespace="backdrop$" place-within-backdrop true } diff --git a/config/nwg-look/.config/gtk-3.0/bookmarks b/config/nwg-look/.config/gtk-3.0/bookmarks index 8415b45..6c40a30 100644 --- a/config/nwg-look/.config/gtk-3.0/bookmarks +++ b/config/nwg-look/.config/gtk-3.0/bookmarks @@ -1,2 +1 @@ file:///home/kolkas/Desktop -file:///home/kolkas/Nextcloud diff --git a/config/quickshell/.config/quickshell/Assets/Images/Avatar.jpg b/config/quickshell/.config/quickshell/Assets/Avatar.jpg similarity index 100% rename from config/quickshell/.config/quickshell/Assets/Images/Avatar.jpg rename to config/quickshell/.config/quickshell/Assets/Avatar.jpg diff --git a/config/quickshell/.config/quickshell/Assets/Cache/.gitignore b/config/quickshell/.config/quickshell/Assets/Cache/.gitignore new file mode 100644 index 0000000..3b524ba --- /dev/null +++ b/config/quickshell/.config/quickshell/Assets/Cache/.gitignore @@ -0,0 +1,7 @@ +ip.json +spotify-lyrics-offset.txt +notifications.json +images +location.json +network.json +shell-state.json diff --git a/config/quickshell/.config/quickshell/Assets/Config/.gitignore b/config/quickshell/.config/quickshell/Assets/Config/.gitignore index f287df1..e38da20 100644 --- a/config/quickshell/.config/quickshell/Assets/Config/.gitignore +++ b/config/quickshell/.config/quickshell/Assets/Config/.gitignore @@ -1,3 +1 @@ -# some sensitive files -GeoInfoToken.txt -IpAliases.json \ No newline at end of file +settings.json diff --git a/config/quickshell/.config/quickshell/Assets/Config/LyricsOffset.txt b/config/quickshell/.config/quickshell/Assets/Config/LyricsOffset.txt deleted file mode 100644 index c227083..0000000 --- a/config/quickshell/.config/quickshell/Assets/Config/LyricsOffset.txt +++ /dev/null @@ -1 +0,0 @@ -0 \ No newline at end of file diff --git a/config/quickshell/.config/quickshell/Assets/Config/Settings.json b/config/quickshell/.config/quickshell/Assets/Config/Settings.json deleted file mode 100644 index 58fd139..0000000 --- a/config/quickshell/.config/quickshell/Assets/Config/Settings.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "location": "Munich", - "notifications": { - "doNotDisturb": false - }, - "primaryColor": "#89b4fa", - "showLyricsBar": false, - "sunsetDefaultEnabled": true, - "wifiEnabled": true -} diff --git a/config/quickshell/.config/quickshell/Assets/Config/colors.json b/config/quickshell/.config/quickshell/Assets/Config/colors.json new file mode 100644 index 0000000..e4c61cc --- /dev/null +++ b/config/quickshell/.config/quickshell/Assets/Config/colors.json @@ -0,0 +1,5 @@ +{ + "colors": { + "mPrimary": "#89b4fa" + } +} diff --git a/config/quickshell/.config/quickshell/Assets/Fonts/tabler/tabler-icons.ttf b/config/quickshell/.config/quickshell/Assets/Fonts/tabler/noctalia-tabler-icons.ttf similarity index 92% rename from config/quickshell/.config/quickshell/Assets/Fonts/tabler/tabler-icons.ttf rename to config/quickshell/.config/quickshell/Assets/Fonts/tabler/noctalia-tabler-icons.ttf index acc574e8273df1a0aca63523e3ba73f0e2c13400..ee8b20f05b987407f10b2ce2c006348636e7a921 100644 GIT binary patch delta 92605 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

m4b^ZNSN3K70Q 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 delta 92896 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­bjf< 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 F{{}@-nG*m2 diff --git a/config/quickshell/.config/quickshell/Components/NScrollView.qml b/config/quickshell/.config/quickshell/Components/NScrollView.qml new file mode 100644 index 0000000..f268785 --- /dev/null +++ b/config/quickshell/.config/quickshell/Components/NScrollView.qml @@ -0,0 +1,216 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Templates as T +import qs.Constants + +ScrollView { + id: root + + property color handleColor: Qt.alpha(Colors.mHover, 0.8) + property color handleHoverColor: handleColor + property color handlePressedColor: handleColor + property color trackColor: "transparent" + property real handleWidth: Math.round(6 * Style.uiScaleRatio) + property real handleRadius: Style.radiusM + property int verticalPolicy: ScrollBar.AsNeeded + property int horizontalPolicy: ScrollBar.AsNeeded + property bool preventHorizontalScroll: horizontalPolicy === ScrollBar.AlwaysOff + property int boundsBehavior: Flickable.StopAtBounds + readonly property bool verticalScrollable: (contentItem.contentHeight > contentItem.height) || (verticalPolicy == ScrollBar.AlwaysOn) + readonly property bool horizontalScrollable: (contentItem.contentWidth > contentItem.width) || (horizontalPolicy == ScrollBar.AlwaysOn) + property bool showGradientMasks: true + property color gradientColor: Colors.mSurfaceVariant + property int gradientHeight: 16 + property bool reserveScrollbarSpace: true + property real userRightPadding: 0 + + // Scroll speed multiplier for mouse wheel (1.0 = default, higher = faster) + property real wheelScrollMultiplier: 2.0 + + rightPadding: userRightPadding + (reserveScrollbarSpace && verticalScrollable ? handleWidth + Style.marginXS : 0) + + implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset, contentWidth + leftPadding + rightPadding) + implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset, contentHeight + topPadding + bottomPadding) + + // Configure the internal flickable when it becomes available + Component.onCompleted: { + configureFlickable(); + createGradients(); + } + + // Dynamically create gradient overlays to avoid interfering with ScrollView content management + function createGradients() { + if (!showGradientMasks) + return; + + Qt.createQmlObject(` + import QtQuick + import qs.Constants + Rectangle { + x: root.leftPadding + y: root.topPadding + width: root.availableWidth + height: root.gradientHeight + z: 1 + visible: root.showGradientMasks && root.verticalScrollable + opacity: root.contentItem.contentY <= 1 ? 0 : 1 + Behavior on opacity { + NumberAnimation { duration: Style.animationFast; easing.type: Easing.InOutQuad } + } + gradient: Gradient { + GradientStop { position: 0.0; color: root.gradientColor } + GradientStop { position: 1.0; color: "transparent" } + } + } + `, root, "topGradient"); + + Qt.createQmlObject(` + import QtQuick + import qs.Constants + Rectangle { + x: root.leftPadding + y: root.height - root.bottomPadding - height + 1 + width: root.availableWidth + height: root.gradientHeight + 1 + z: 1 + visible: root.showGradientMasks && root.verticalScrollable + opacity: (root.contentItem.contentY + root.contentItem.height >= root.contentItem.contentHeight - 1) ? 0 : 1 + Behavior on opacity { + NumberAnimation { duration: Style.animationFast; easing.type: Easing.InOutQuad } + } + gradient: Gradient { + GradientStop { position: 0.0; color: "transparent" } + GradientStop { position: 1.0; color: root.gradientColor } + } + } + `, root, "bottomGradient"); + } + + // Reference to the internal Flickable for wheel handling + property Flickable _internalFlickable: null + + // Function to configure the underlying Flickable + function configureFlickable() { + // Find the internal Flickable (it's usually the first child) + for (var i = 0; i < children.length; i++) { + var child = children[i]; + if (child.toString().indexOf("Flickable") !== -1) { + // Configure the flickable to prevent horizontal scrolling + child.boundsBehavior = root.boundsBehavior; + root._internalFlickable = child; + + if (root.preventHorizontalScroll) { + child.flickableDirection = Flickable.VerticalFlick; + child.contentWidth = Qt.binding(() => child.width); + } + break; + } + } + } + + WheelHandler { + enabled: root.wheelScrollMultiplier !== 1.0 && root._internalFlickable !== null + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + onWheel: event => { + if (!root._internalFlickable) + return; + const flickable = root._internalFlickable; + const delta = event.pixelDelta.y !== 0 ? event.pixelDelta.y : event.angleDelta.y / 2; + const newY = flickable.contentY - (delta * root.wheelScrollMultiplier); + flickable.contentY = Math.max(0, Math.min(newY, flickable.contentHeight - flickable.height)); + event.accepted = true; + } + } + + // Watch for changes in horizontalPolicy + onHorizontalPolicyChanged: { + preventHorizontalScroll = (horizontalPolicy === ScrollBar.AlwaysOff); + configureFlickable(); + } + + ScrollBar.vertical: ScrollBar { + parent: root + x: root.mirrored ? 0 : root.width - width + y: root.topPadding + height: root.availableHeight + policy: root.verticalPolicy + interactive: root.verticalScrollable + + contentItem: Rectangle { + implicitWidth: root.handleWidth + implicitHeight: 100 + radius: root.handleRadius + color: parent.pressed ? root.handlePressedColor : parent.hovered ? root.handleHoverColor : root.handleColor + opacity: parent.policy === ScrollBar.AlwaysOn ? 1.0 : root.verticalScrollable ? (parent.active ? 1.0 : 0.0) : 0.0 + + Behavior on opacity { + NumberAnimation { + duration: Style.animationFast + } + } + + Behavior on color { + ColorAnimation { + duration: Style.animationFast + } + } + } + + background: Rectangle { + implicitWidth: root.handleWidth + implicitHeight: 100 + color: root.trackColor + opacity: parent.policy === ScrollBar.AlwaysOn ? 0.3 : root.verticalScrollable ? (parent.active ? 0.3 : 0.0) : 0.0 + radius: root.handleRadius / 2 + + Behavior on opacity { + NumberAnimation { + duration: Style.animationFast + } + } + } + } + + ScrollBar.horizontal: ScrollBar { + parent: root + x: root.leftPadding + y: root.height - height + width: root.availableWidth + policy: root.horizontalPolicy + interactive: root.horizontalScrollable + + contentItem: Rectangle { + implicitWidth: 100 + implicitHeight: root.handleWidth + radius: root.handleRadius + color: parent.pressed ? root.handlePressedColor : parent.hovered ? root.handleHoverColor : root.handleColor + opacity: parent.policy === ScrollBar.AlwaysOn ? 1.0 : root.horizontalScrollable ? (parent.active ? 1.0 : 0.0) : 0.0 + + Behavior on opacity { + NumberAnimation { + duration: Style.animationFast + } + } + + Behavior on color { + ColorAnimation { + duration: Style.animationFast + } + } + } + + background: Rectangle { + implicitWidth: 100 + implicitHeight: root.handleWidth + color: root.trackColor + opacity: parent.policy === ScrollBar.AlwaysOn ? 0.3 : root.horizontalScrollable ? (parent.active ? 0.3 : 0.0) : 0.0 + radius: root.handleRadius / 2 + + Behavior on opacity { + NumberAnimation { + duration: Style.animationFast + } + } + } + } +} diff --git a/config/quickshell/.config/quickshell/Components/UBox.qml b/config/quickshell/.config/quickshell/Components/UBox.qml new file mode 100644 index 0000000..e341e3f --- /dev/null +++ b/config/quickshell/.config/quickshell/Components/UBox.qml @@ -0,0 +1,29 @@ +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Effects +import QtQuick.Layouts +import qs.Components +import qs.Constants + +// Rounded group container using the variant surface color. +// To be used in side panels and settings panes to group fields or buttons. +Rectangle { + id: root + + property bool compact: false + + color: compact ? Colors.transparent : Colors.mSurfaceVariant + radius: Style.radiusM + layer.enabled: !compact + + layer.effect: MultiEffect { + shadowEnabled: true + blurMax: Style.shadowBlurMax + shadowBlur: Style.shadowBlur + shadowOpacity: Style.shadowOpacity + shadowColor: Colors.mShadow + shadowHorizontalOffset: Style.shadowHorizontalOffset + shadowVerticalOffset: Style.shadowVerticalOffset + } + +} diff --git a/config/quickshell/.config/quickshell/Noctalia/NBusyIndicator.qml b/config/quickshell/.config/quickshell/Components/UBusyIndicator.qml similarity index 95% rename from config/quickshell/.config/quickshell/Noctalia/NBusyIndicator.qml rename to config/quickshell/.config/quickshell/Components/UBusyIndicator.qml index 137f37b..353e3c7 100644 --- a/config/quickshell/.config/quickshell/Noctalia/NBusyIndicator.qml +++ b/config/quickshell/.config/quickshell/Components/UBusyIndicator.qml @@ -1,12 +1,11 @@ import QtQuick import qs.Constants -import qs.Noctalia Item { id: root property bool running: true - property color color: Color.mPrimary + property color color: Colors.mPrimary property int size: Style.baseWidgetSize property int strokeWidth: Style.borderL property int duration: Style.animationSlow * 2 diff --git a/config/quickshell/.config/quickshell/Components/UButton.qml b/config/quickshell/.config/quickshell/Components/UButton.qml new file mode 100644 index 0000000..acd8bc8 --- /dev/null +++ b/config/quickshell/.config/quickshell/Components/UButton.qml @@ -0,0 +1,133 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import qs.Components +import qs.Constants + +Rectangle { + id: root + + // Public properties + property string text: "" + property string icon: "" + property string tooltipText + property color backgroundColor: Colors.mPrimary + property color textColor: Colors.mOnPrimary + property color hoverColor: Colors.mHover + property color textHoverColor: Colors.mOnHover + property real fontSize: Style.fontSizeM + property int fontWeight: Style.fontWeightSemiBold + property real iconSize: Style.fontSizeL + property bool outlined: false + property int horizontalAlignment: Qt.AlignHCenter + property real buttonRadius: Style.radiusS + // Internal properties + property bool hovered: false + readonly property color contentColor: { + if (!root.enabled) + return Colors.mOnSurfaceVariant; + + if (root.hovered) + return root.textHoverColor; + + if (root.outlined) + return root.backgroundColor; + + return root.textColor; + } + + // Signals + signal clicked() + signal rightClicked() + signal middleClicked() + signal entered() + signal exited() + + // Dimensions + implicitWidth: contentRow.implicitWidth + (fontSize * 2) + implicitHeight: contentRow.implicitHeight + (fontSize) + // Appearance + radius: root.buttonRadius + color: { + if (!root.enabled) + return outlined ? "transparent" : Qt.lighter(Colors.mSurfaceVariant, 1.2); + + if (root.hovered) + return hoverColor; + + return root.outlined ? "transparent" : root.backgroundColor; + } + border.width: outlined ? Style.borderS : 0 + border.color: { + if (!root.enabled) + return Colors.mOutline; + + if (root.hovered) + return hoverColor; + + return root.outlined ? root.backgroundColor : "transparent"; + } + opacity: enabled ? 1 : 0.6 + + // Content + RowLayout { + id: contentRow + + anchors.verticalCenter: parent.verticalCenter + anchors.left: root.horizontalAlignment === Qt.AlignLeft ? parent.left : undefined + anchors.horizontalCenter: root.horizontalAlignment === Qt.AlignHCenter ? parent.horizontalCenter : undefined + anchors.leftMargin: root.horizontalAlignment === Qt.AlignLeft ? Style.marginL : 0 + spacing: Style.marginXS + + // Icon (optional) + UIcon { + Layout.alignment: Qt.AlignVCenter + visible: root.icon !== "" + iconName: root.icon + iconSize: root.iconSize + color: contentColor + } + + // Text + UText { + Layout.alignment: Qt.AlignVCenter + visible: root.text !== "" + text: root.text + pointSize: root.fontSize + font.weight: root.fontWeight + color: contentColor + } + + } + + // Mouse interaction + MouseArea { + id: mouseArea + + anchors.fill: parent + enabled: root.enabled + hoverEnabled: true + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + cursorShape: root.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onEntered: { + root.hovered = true; + root.entered(); + } + onExited: { + root.hovered = false; + root.exited(); + } + onPressed: (mouse) => { + if (mouse.button === Qt.LeftButton) + root.clicked(); + else if (mouse.button == Qt.RightButton) + root.rightClicked(); + else if (mouse.button == Qt.MiddleButton) + root.middleClicked(); + } + onCanceled: { + root.hovered = false; + } + } + +} diff --git a/config/quickshell/.config/quickshell/Components/UClock.qml b/config/quickshell/.config/quickshell/Components/UClock.qml new file mode 100644 index 0000000..2b011bf --- /dev/null +++ b/config/quickshell/.config/quickshell/Components/UClock.qml @@ -0,0 +1,427 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import qs.Components +import qs.Constants +import qs.Utils + +Item { + id: root + + property var now: Time.now + // Style: "analog" or "digital" + property string clockStyle: "analog" + // Show seconds progress ring (digital only) + property bool showProgress: true + // Colors properties + property color backgroundColor: Colors.mPrimary + property color clockColor: Colors.mOnPrimary + property color secondHandColor: Colors.mError + property color progressColor: root.secondHandColor + // Font size properties for digital clock + property real hoursFontSize: Style.fontSizeXS + property real minutesFontSize: Style.fontSizeXXS + property int hoursFontWeight: Style.fontWeightBold + property int minutesFontWeight: Style.fontWeightBold + // Scale ratio for canvas line widths (used by desktop widget scaling) + property real scaleRatio: 1 + + height: Math.round((Style.fontSizeXXXL * 1.9) / 2) * 2 + width: root.height + + Loader { + id: clockLoader + + anchors.fill: parent + sourceComponent: { + if (root.clockStyle === "analog") + return analogClockComponent; + + if (root.clockStyle === "binary") + return binaryClockComponent; + + return digitalClockComponent; + } + onLoaded: { + item.now = Qt.binding(function() { + return root.now; + }); + item.backgroundColor = Qt.binding(function() { + return root.backgroundColor; + }); + item.clockColor = Qt.binding(function() { + return root.clockColor; + }); + if (item.hasOwnProperty("secondHandColor")) + item.secondHandColor = Qt.binding(function() { + return root.secondHandColor; + }); + + if (item.hasOwnProperty("progressColor")) + item.progressColor = Qt.binding(function() { + return root.progressColor; + }); + + if (item.hasOwnProperty("hoursFontSize")) + item.hoursFontSize = Qt.binding(function() { + return root.hoursFontSize; + }); + + if (item.hasOwnProperty("minutesFontSize")) + item.minutesFontSize = Qt.binding(function() { + return root.minutesFontSize; + }); + + if ("hoursFontWeight" in item) + item.hoursFontWeight = Qt.binding(function() { + return root.hoursFontWeight; + }); + + if ("minutesFontWeight" in item) + item.minutesFontWeight = Qt.binding(function() { + return root.minutesFontWeight; + }); + + if (item.hasOwnProperty("scaleRatio")) + item.scaleRatio = Qt.binding(function() { + return root.scaleRatio; + }); + + if ("showProgress" in item) + item.showProgress = Qt.binding(function() { + return root.showProgress; + }); + + } + } + + Component { + id: analogClockComponent + + UClockAnalog { + } + + } + + Component { + id: digitalClockComponent + + UClockDigital { + } + + } + + Component { + id: binaryClockComponent + + UClockBinary { + } + + } + + // Analog Clock Component + component UClockAnalog: Item { + property var now + property color backgroundColor: Colors.mPrimary + property color clockColor: Colors.mOnPrimary + property color secondHandColor: Colors.mError + property real scaleRatio: 1 + + anchors.fill: parent + + Canvas { + id: clockCanvas + + anchors.fill: parent + onPaint: { + var currentTime = Time.now; + var hours = currentTime.getHours(); + var minutes = currentTime.getMinutes(); + var seconds = currentTime.getSeconds(); + const markAlpha = 0.7; + var ctx = getContext("2d"); + ctx.reset(); + ctx.translate(width / 2, height / 2); + var radius = Math.min(width, height) / 2; + // Hour marks + ctx.strokeStyle = Qt.alpha(clockColor, markAlpha); + ctx.lineWidth = 2 * scaleRatio; + var scaleFactor = 0.7; + for (var i = 0; i < 12; i++) { + var scaleFactor = 0.8; + if (i % 3 === 0) + scaleFactor = 0.65; + + ctx.save(); + ctx.rotate(i * Math.PI / 6); + ctx.beginPath(); + ctx.moveTo(0, -radius * scaleFactor); + ctx.lineTo(0, -radius); + ctx.stroke(); + ctx.restore(); + } + // Hour hand + ctx.save(); + var hourAngle = (hours % 12 + minutes / 60) * Math.PI / 6; + ctx.rotate(hourAngle); + ctx.strokeStyle = clockColor; + ctx.lineWidth = 3 * scaleRatio; + ctx.lineCap = "round"; + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.lineTo(0, -radius * 0.6); + ctx.stroke(); + ctx.restore(); + // Minute hand + ctx.save(); + var minuteAngle = (minutes + seconds / 60) * Math.PI / 30; + ctx.rotate(minuteAngle); + ctx.strokeStyle = clockColor; + ctx.lineWidth = 2 * scaleRatio; + ctx.lineCap = "round"; + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.lineTo(0, -radius * 0.9); + ctx.stroke(); + ctx.restore(); + // Second hand + ctx.save(); + var secondAngle = seconds * Math.PI / 30; + ctx.rotate(secondAngle); + ctx.strokeStyle = secondHandColor; + ctx.lineWidth = 1.6 * scaleRatio; + ctx.lineCap = "round"; + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.lineTo(0, -radius); + ctx.stroke(); + ctx.restore(); + // Center dot + ctx.beginPath(); + ctx.arc(0, 0, 3 * scaleRatio, 0, 2 * Math.PI); + ctx.fillStyle = clockColor; + ctx.fill(); + } + Component.onCompleted: requestPaint() + + Connections { + function onNowChanged() { + clockCanvas.requestPaint(); + } + + target: Time + } + + } + + } + + // Digital Clock Component + component UClockDigital: Item { + property var now + property color backgroundColor: Colors.mPrimary + property color clockColor: Colors.mOnPrimary + property color progressColor: Colors.mError + property real hoursFontSize: Style.fontSizeXS + property real minutesFontSize: Style.fontSizeXXS + property int hoursFontWeight: Style.fontWeightBold + property int minutesFontWeight: Style.fontWeightBold + property real scaleRatio: 1 + property bool showProgress: true + + anchors.fill: parent + + // Digital clock's seconds circular progress + Canvas { + id: secondsProgress + + property real progress: now.getSeconds() / 60 + + anchors.fill: parent + visible: showProgress + onProgressChanged: requestPaint() + onPaint: { + var ctx = getContext("2d"); + var centerX = width / 2; + var centerY = height / 2; + var radius = Math.min(width, height) / 2 - 3 * scaleRatio; + ctx.reset(); + // Background circle + ctx.beginPath(); + ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI); + ctx.lineWidth = 2.5 * scaleRatio; + ctx.strokeStyle = Qt.alpha(clockColor, 0.15); + ctx.stroke(); + // Progress arc + ctx.beginPath(); + ctx.arc(centerX, centerY, radius, -Math.PI / 2, -Math.PI / 2 + progress * 2 * Math.PI); + ctx.lineWidth = 2.5 * scaleRatio; + ctx.strokeStyle = progressColor; + ctx.lineCap = "round"; + ctx.stroke(); + } + + Connections { + function onNowChanged() { + const total = now.getSeconds() * 1000 + now.getMilliseconds(); + secondsProgress.progress = total / 60000; + } + + target: Time + } + + } + + // Digital clock + ColumnLayout { + anchors.centerIn: parent + spacing: -Style.marginXXS + + UText { + text: Qt.formatTime(now, "HH") + pointSize: hoursFontSize + font.weight: hoursFontWeight + color: clockColor + Layout.alignment: Qt.AlignHCenter + } + + UText { + text: Qt.formatTime(now, "mm") + pointSize: minutesFontSize + font.weight: minutesFontWeight + color: clockColor + Layout.alignment: Qt.AlignHCenter + } + + } + + } + + // Binary Clock Component + component UClockBinary: Item { + // BCD (Binary Coded Decimal) Format: + // H1 H2 : M1 M2 : S1 S2 + // H1 (tens): 0-2 (2 bits) + // H2 (ones): 0-9 (4 bits) + // M1 (tens): 0-5 (3 bits) + // M2 (ones): 0-9 (4 bits) + // S1 (tens): 0-5 (3 bits) + // S2 (ones): 0-9 (4 bits) + + property var now + property color backgroundColor + property color clockColor: Colors.mOnPrimary + readonly property int h: now.getHours() + readonly property int m: now.getMinutes() + readonly property int s: now.getSeconds() + + anchors.fill: parent + + RowLayout { + anchors.centerIn: parent + spacing: parent.width * 0.05 + + // Hours + RowLayout { + spacing: parent.parent.width * 0.02 + + BinaryColumn { + value: Math.floor(h / 10) + bits: 2 + dotSize: root.width * 0.08 + activeColor: clockColor + Layout.alignment: Qt.AlignBottom + } + + BinaryColumn { + value: h % 10 + bits: 4 + dotSize: root.width * 0.08 + activeColor: clockColor + Layout.alignment: Qt.AlignBottom + } + + } + + // Minutes + RowLayout { + spacing: parent.parent.width * 0.02 + + BinaryColumn { + value: Math.floor(m / 10) + bits: 3 + dotSize: root.width * 0.08 + activeColor: clockColor + Layout.alignment: Qt.AlignBottom + } + + BinaryColumn { + value: m % 10 + bits: 4 + dotSize: root.width * 0.08 + activeColor: clockColor + Layout.alignment: Qt.AlignBottom + } + + } + + // Seconds + RowLayout { + spacing: parent.parent.width * 0.02 + + BinaryColumn { + value: Math.floor(s / 10) + bits: 3 + dotSize: root.width * 0.08 + activeColor: clockColor + Layout.alignment: Qt.AlignBottom + } + + BinaryColumn { + value: s % 10 + bits: 4 + dotSize: root.width * 0.08 + activeColor: clockColor + Layout.alignment: Qt.AlignBottom + } + + } + + } + + } + + component BinaryColumn: Column { + property int value: 0 + property int bits: 4 + property real dotSize: 10 + property color activeColor: "white" + + spacing: dotSize * 0.4 + + Repeater { + model: bits + + Rectangle { + property int bitIndex: (bits - 1) - index + property bool isActive: (value >> bitIndex) & 1 + + width: dotSize + height: dotSize + radius: dotSize / 2 + color: isActive ? activeColor : Qt.alpha(activeColor, 0.2) + + Behavior on color { + ColorAnimation { + duration: 200 + } + + } + + } + + } + + } + +} diff --git a/config/quickshell/.config/quickshell/Noctalia/NContextMenu.qml b/config/quickshell/.config/quickshell/Components/UContextMenu.qml similarity index 55% rename from config/quickshell/.config/quickshell/Noctalia/NContextMenu.qml rename to config/quickshell/.config/quickshell/Components/UContextMenu.qml index c2d735d..97c912a 100644 --- a/config/quickshell/.config/quickshell/Noctalia/NContextMenu.qml +++ b/config/quickshell/.config/quickshell/Components/UContextMenu.qml @@ -2,16 +2,55 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts import qs.Constants -import qs.Noctalia -import qs.Services -import qs.Utils +/* +* UContextMenu - Popup-based context menu for use inside panels and dialogs +* +* Use this component when you need a context menu inside: +* - Settings panels +* - Dialogs +* - Repeater delegates +* - Any nested component context +* +* For bar widgets and top-level window contexts, use NPopupContextMenu instead, +* which provides better screen boundary handling and compositor integration. +* +* Usage: +* UContextMenu { +* id: contextMenu +* parent: Overlay.overlay +* model: [ +* { "label": "Action 1", "action": "action1", "icon": "icon-name" }, +* { "label": "Action 2", "action": "action2" } +* ] +* onTriggered: action => { Logger.i("MyModule", "Selected:", action) } +* } +* +* MouseArea { +* onClicked: contextMenu.openAtItem(parent, mouse.x, mouse.y) +* } +*/ Popup { id: root - property alias model: listView.model + property var model: [] property real itemHeight: 36 property real itemPadding: Style.marginM + property int verticalPolicy: ScrollBar.AsNeeded + property int horizontalPolicy: ScrollBar.AsNeeded + // Filter out hidden items to avoid spacing artifacts from zero-height items + readonly property var filteredModel: { + if (!model || model.length === 0) + return []; + + var filtered = []; + for (var i = 0; i < model.length; i++) { + if (model[i].visible !== false) + filtered.push(model[i]); + + } + return filtered; + } signal triggered(string action) @@ -30,22 +69,24 @@ Popup { width: 180 padding: Style.marginS - onOpened: PanelService.willOpenPopup(root) - onClosed: PanelService.willClosePopup(root) background: Rectangle { - color: Color.mSurfaceVariant - border.color: Color.mOutline - border.width: Math.max(1, Style.borderS) + color: Colors.mSurfaceVariant + border.color: Colors.mOutline + border.width: Style.borderS radius: Style.radiusM } - contentItem: NListView { + contentItem: UListView { id: listView - implicitHeight: contentHeight + implicitHeight: Math.max(contentHeight, root.itemHeight) spacing: Style.marginXXS interactive: contentHeight > root.height + verticalPolicy: root.verticalPolicy + horizontalPolicy: root.horizontalPolicy + reserveScrollbarSpace: false + model: root.filteredModel delegate: ItemDelegate { id: menuItem @@ -53,9 +94,8 @@ Popup { // Store reference to the popup property var popup: root - width: listView.width - height: modelData.visible !== false ? root.itemHeight : 0 - visible: modelData.visible !== false + width: listView.availableWidth + height: root.itemHeight opacity: modelData.enabled !== false ? 1 : 0.5 enabled: modelData.enabled !== false onClicked: { @@ -66,27 +106,19 @@ Popup { } background: Rectangle { - color: menuItem.hovered && menuItem.enabled ? Color.mTertiary : Color.transparent + color: menuItem.hovered && menuItem.enabled ? Colors.mHover : "transparent" radius: Style.radiusS - - Behavior on color { - ColorAnimation { - duration: Style.animationFast - } - - } - } contentItem: RowLayout { spacing: Style.marginS // Optional icon - NIcon { + UIcon { visible: modelData.icon !== undefined - icon: modelData.icon || "" - pointSize: Style.fontSizeM - color: menuItem.hovered && menuItem.enabled ? Color.mOnTertiary : Color.mOnSurface + iconName: modelData.icon || "" + iconSize: Style.fontSizeM + color: menuItem.hovered && menuItem.enabled ? Colors.mOnHover : Colors.mOnSurface Layout.leftMargin: root.itemPadding Behavior on color { @@ -98,10 +130,10 @@ Popup { } - NText { + UText { text: modelData.label || modelData.text || "" pointSize: Style.fontSizeM - color: menuItem.hovered && menuItem.enabled ? Color.mOnTertiary : Color.mOnSurface + color: menuItem.hovered && menuItem.enabled ? Colors.mOnHover : Colors.mOnSurface verticalAlignment: Text.AlignVCenter Layout.fillWidth: true Layout.leftMargin: modelData.icon === undefined ? root.itemPadding : 0 diff --git a/config/quickshell/.config/quickshell/Noctalia/NDivider.qml b/config/quickshell/.config/quickshell/Components/UDivider.qml similarity index 76% rename from config/quickshell/.config/quickshell/Noctalia/NDivider.qml rename to config/quickshell/.config/quickshell/Components/UDivider.qml index d476143..65a94a9 100644 --- a/config/quickshell/.config/quickshell/Noctalia/NDivider.qml +++ b/config/quickshell/.config/quickshell/Components/UDivider.qml @@ -12,22 +12,22 @@ Rectangle { GradientStop { position: 0 - color: Color.transparent + color: Colors.transparent } GradientStop { position: 0.1 - color: Color.mOutline + color: Colors.mOutline } GradientStop { position: 0.9 - color: Color.mOutline + color: Colors.mOutline } GradientStop { position: 1 - color: Color.transparent + color: Colors.transparent } } diff --git a/config/quickshell/.config/quickshell/Components/UDropShadow.qml b/config/quickshell/.config/quickshell/Components/UDropShadow.qml new file mode 100644 index 0000000..12b9ebc --- /dev/null +++ b/config/quickshell/.config/quickshell/Components/UDropShadow.qml @@ -0,0 +1,31 @@ +import QtQuick +import QtQuick.Effects +import qs.Constants + +// Unified shadow system +Item { + id: root + + required property var source + property bool autoPaddingEnabled: false + property real shadowHorizontalOffset: Style.shadowHorizontalOffset + property real shadowVerticalOffset: Style.shadowVerticalOffset + property real shadowOpacity: Style.shadowOpacity + property color shadowColor: Colors.mShadow + property real shadowBlur: Style.shadowBlur + + layer.enabled: true + + layer.effect: MultiEffect { + source: root.source + shadowEnabled: true + blurMax: Style.shadowBlurMax + shadowBlur: root.shadowBlur + shadowOpacity: root.shadowOpacity + shadowColor: root.shadowColor + shadowHorizontalOffset: root.shadowHorizontalOffset + shadowVerticalOffset: root.shadowVerticalOffset + autoPaddingEnabled: root.autoPaddingEnabled + } + +} diff --git a/config/quickshell/.config/quickshell/Components/UIcon.qml b/config/quickshell/.config/quickshell/Components/UIcon.qml new file mode 100644 index 0000000..16e94f8 --- /dev/null +++ b/config/quickshell/.config/quickshell/Components/UIcon.qml @@ -0,0 +1,19 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import qs.Constants + +Text { + id: root + + property string iconName: "" + property string textOverride: "" + property string fontFamily: Fonts.icon + property int iconSize: Style.fontSizeL + + color: Colors.mPrimary + text: textOverride ? textOverride : Icons.get(iconName) || Icons.get(Icons.defaultIcon) + font.family: fontFamily + font.pointSize: iconSize + font.bold: false +} diff --git a/config/quickshell/.config/quickshell/Components/UIconButton.qml b/config/quickshell/.config/quickshell/Components/UIconButton.qml new file mode 100644 index 0000000..ee8013e --- /dev/null +++ b/config/quickshell/.config/quickshell/Components/UIconButton.qml @@ -0,0 +1,83 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import qs.Components +import qs.Constants + +Item { + id: root + + property alias iconName: icon.iconName + property alias textOverride: icon.textOverride + property alias fontFamily: icon.fontFamily + property int baseSize: Style.fontSizeXXXL + property alias iconSize: icon.iconSize + property color colorFg: Colors.mPrimary + property color colorBg: Colors.transparent + property color colorFgHover: Colors.mOnPrimary + property color colorBgHover: colorFg + readonly property bool hovered: alwaysHover || mouseArea.containsMouse + property real radius: Style.radiusS + property bool disabledHover: false + property bool alwaysHover: false + + signal entered() + signal exited() + signal clicked() + signal rightClicked() + signal middleClicked() + + implicitWidth: baseSize + implicitHeight: baseSize + + MouseArea { + id: mouseArea + + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + hoverEnabled: !disabledHover + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + onEntered: root.entered() + onExited: root.exited() + onClicked: (mouse) => { + if (mouse.button === Qt.RightButton) + root.rightClicked(); + else if (mouse.button === Qt.MiddleButton) + root.middleClicked(); + else if (mouse.button === Qt.LeftButton) + root.clicked(); + } + } + + Rectangle { + anchors.fill: parent + color: root.hovered ? colorBgHover : colorBg + radius: root.radius + + Behavior on color { + ColorAnimation { + duration: Style.animationNormal + easing.type: Easing.InOutCubic + } + + } + + } + + UIcon { + id: icon + + color: root.hovered ? colorFgHover : colorFg + anchors.centerIn: parent + + Behavior on color { + ColorAnimation { + duration: Style.animationNormal + easing.type: Easing.InOutCubic + } + + } + + } + +} diff --git a/config/quickshell/.config/quickshell/Components/UImageRounded.qml b/config/quickshell/.config/quickshell/Components/UImageRounded.qml new file mode 100644 index 0000000..e5e509d --- /dev/null +++ b/config/quickshell/.config/quickshell/Components/UImageRounded.qml @@ -0,0 +1,69 @@ +import QtQuick +import QtQuick.Effects +import Quickshell +import Quickshell.Widgets +import qs.Constants + +Item { + id: root + + property real radius: 0 + property string imagePath: "" + property string fallbackIcon: "" + property real fallbackIconSize: Style.fontSizeXXL + property real borderWidth: 0 + property color borderColor: "transparent" + property int imageFillMode: Image.PreserveAspectCrop + readonly property bool showFallback: (fallbackIcon !== undefined && fallbackIcon !== "") && (imagePath === undefined || imagePath === "" || imageSource.status === Image.Error) + readonly property int status: imageSource.status + + Rectangle { + anchors.fill: parent + radius: root.radius + color: "transparent" + border.width: root.borderWidth + border.color: root.borderColor + + Image { + id: imageSource + + anchors.fill: parent + anchors.margins: root.borderWidth + visible: false + source: root.imagePath + mipmap: true + smooth: true + asynchronous: true + antialiasing: true + fillMode: root.imageFillMode + } + + ShaderEffect { + property variant source: imageSource + property real itemWidth: width + property real itemHeight: height + property real sourceWidth: imageSource.sourceSize.width + property real sourceHeight: imageSource.sourceSize.height + property real cornerRadius: Math.max(0, root.radius - root.borderWidth) + property real imageOpacity: 1 + property int fillMode: root.imageFillMode + + anchors.fill: parent + anchors.margins: root.borderWidth + visible: !root.showFallback + fragmentShader: Qt.resolvedUrl(Quickshell.shellDir + "/Shaders/qsb/rounded_image.frag.qsb") + supportsAtlasTextures: false + blending: true + } + + UIcon { + anchors.fill: parent + anchors.margins: root.borderWidth + visible: root.showFallback + iconName: root.fallbackIcon + iconSize: root.fallbackIconSize + } + + } + +} diff --git a/config/quickshell/.config/quickshell/Components/ULabel.qml b/config/quickshell/.config/quickshell/Components/ULabel.qml new file mode 100644 index 0000000..82034a5 --- /dev/null +++ b/config/quickshell/.config/quickshell/Components/ULabel.qml @@ -0,0 +1,57 @@ +import QtQuick +import QtQuick.Layouts +import qs.Components +import qs.Constants + +ColumnLayout { + id: root + + property string label: "" + property string description: "" + property string icon: "" + property color labelColor: Colors.mOnSurface + property color descriptionColor: Colors.mOnSurfaceVariant + property color iconColor: Colors.mOnSurface + property bool showIndicator: false + property string indicatorTooltip: "" + + opacity: enabled ? 1 : 0.6 + spacing: Style.marginXXS + visible: root.label != "" || root.description != "" + Layout.fillWidth: true + + RowLayout { + spacing: Style.marginXS + Layout.fillWidth: true + visible: root.label !== "" + + UIcon { + visible: root.icon !== "" + iconName: root.icon + iconSize: Style.fontSizeXXL + color: root.iconColor + Layout.rightMargin: Style.marginS + } + + UText { + Layout.fillWidth: !root.showIndicator + text: root.label + pointSize: Style.fontSizeL + font.weight: Style.fontWeightSemiBold + color: labelColor + wrapMode: Text.WordWrap + } + + } + + UText { + visible: root.description !== "" + Layout.fillWidth: true + text: root.description + pointSize: Style.fontSizeS + color: root.descriptionColor + wrapMode: Text.WordWrap + textFormat: Text.StyledText + } + +} diff --git a/config/quickshell/.config/quickshell/Noctalia/NListView.qml b/config/quickshell/.config/quickshell/Components/UListView.qml similarity index 53% rename from config/quickshell/.config/quickshell/Noctalia/NListView.qml rename to config/quickshell/.config/quickshell/Components/UListView.qml index 2d79633..a9f3f74 100644 --- a/config/quickshell/.config/quickshell/Noctalia/NListView.qml +++ b/config/quickshell/.config/quickshell/Components/UListView.qml @@ -2,19 +2,31 @@ import QtQuick import QtQuick.Controls import QtQuick.Templates as T import qs.Constants -import qs.Noctalia Item { id: root - property color handleColor: Qt.alpha(Color.mTertiary, 0.8) + property color handleColor: Qt.alpha(Colors.mHover, 0.8) property color handleHoverColor: handleColor property color handlePressedColor: handleColor - property color trackColor: Color.transparent + property color trackColor: "transparent" property real handleWidth: 6 property real handleRadius: Style.radiusM property int verticalPolicy: ScrollBar.AsNeeded - property int horizontalPolicy: ScrollBar.AsNeeded + property int horizontalPolicy: ScrollBar.AlwaysOff + readonly property bool verticalScrollBarActive: { + if (listView.ScrollBar.vertical.policy === ScrollBar.AlwaysOff) + return false; + + return listView.contentHeight > listView.height; + } + readonly property bool contentOverflows: listView.contentHeight > listView.height + property bool showGradientMasks: true + property color gradientColor: Colors.mSurfaceVariant + property int gradientHeight: 16 + property bool reserveScrollbarSpace: true + // Available width for content (excludes scrollbar space when reserveScrollbarSpace is true) + readonly property real availableWidth: width - (reserveScrollbarSpace ? handleWidth + Style.marginXS : 0) // Forward ListView properties property alias model: listView.model property alias delegate: listView.delegate @@ -53,6 +65,8 @@ Item { property alias dragging: listView.dragging property alias horizontalVelocity: listView.horizontalVelocity property alias verticalVelocity: listView.verticalVelocity + // Scroll speed multiplier for mouse wheel (1.0 = default, higher = faster) + property real wheelScrollMultiplier: 2 // Forward ListView methods function positionViewAtIndex(index, mode) { @@ -99,84 +113,110 @@ Item { return listView.itemAtIndex(index); } + // Dynamically create gradient overlays + function createGradients() { + if (!showGradientMasks) + return ; + + Qt.createQmlObject(` + import QtQuick + import qs.Constants + Rectangle { + x: 0 + y: 0 + width: root.availableWidth + height: root.gradientHeight + z: 1 + visible: root.showGradientMasks && root.contentOverflows + opacity: { + if (listView.contentY <= 1) return 0; + if (listView.currentItem && listView.currentItem.y - listView.contentY < root.gradientHeight) return 0; + return 1; + } + Behavior on opacity { + NumberAnimation { duration: Style.animationFast; easing.type: Easing.InOutQuad } + } + gradient: Gradient { + GradientStop { position: 0.0; color: root.gradientColor } + GradientStop { position: 1.0; color: "transparent" } + } + } + `, root, "topGradient"); + Qt.createQmlObject(` + import QtQuick + import qs.Constants + Rectangle { + x: 0 + anchors.bottom: parent.bottom + anchors.bottomMargin: -1 + width: root.availableWidth + height: root.gradientHeight + 1 + z: 1 + visible: root.showGradientMasks && root.contentOverflows + opacity: { + if (listView.contentY + listView.height >= listView.contentHeight - 1) return 0; + if (listView.currentItem && listView.currentItem.y + listView.currentItem.height > listView.contentY + listView.height - root.gradientHeight) return 0; + return 1; + } + Behavior on opacity { + NumberAnimation { duration: Style.animationFast; easing.type: Easing.InOutQuad } + } + gradient: Gradient { + GradientStop { position: 0.0; color: "transparent" } + GradientStop { position: 1.0; color: root.gradientColor } + } + } + `, root, "bottomGradient"); + } + // Set reasonable implicit sizes for Layout usage implicitWidth: 200 implicitHeight: 200 + Component.onCompleted: { + createGradients(); + } ListView { id: listView anchors.fill: parent - // Enable clipping to keep content within bounds + anchors.rightMargin: root.reserveScrollbarSpace ? root.handleWidth + Style.marginXS : 0 clip: true - // Enable flickable for smooth scrolling boundsBehavior: Flickable.StopAtBounds - ScrollBar.vertical: ScrollBar { - parent: listView - x: listView.mirrored ? 0 : listView.width - width - y: 0 - height: listView.height - active: listView.ScrollBar.horizontal.active - policy: root.verticalPolicy - - contentItem: Rectangle { - implicitWidth: root.handleWidth - implicitHeight: 100 - radius: root.handleRadius - color: parent.pressed ? root.handlePressedColor : parent.hovered ? root.handleHoverColor : root.handleColor - opacity: parent.policy === ScrollBar.AlwaysOn || parent.active ? 1 : 0 - - Behavior on opacity { - NumberAnimation { - duration: Style.animationFast - } - - } - - Behavior on color { - ColorAnimation { - duration: Style.animationFast - } - - } - + WheelHandler { + enabled: !root.contentOverflows + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + onWheel: (event) => { + event.accepted = true; } - - background: Rectangle { - implicitWidth: root.handleWidth - implicitHeight: 100 - color: root.trackColor - opacity: parent.policy === ScrollBar.AlwaysOn || parent.active ? 0.3 : 0 - radius: root.handleRadius / 2 - - Behavior on opacity { - NumberAnimation { - duration: Style.animationFast - } - - } - - } - } - ScrollBar.horizontal: ScrollBar { - id: horizontalScrollBar + WheelHandler { + enabled: root.wheelScrollMultiplier !== 1 && root.contentOverflows + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + onWheel: (event) => { + const delta = event.pixelDelta.y !== 0 ? event.pixelDelta.y : event.angleDelta.y / 2; + const newY = listView.contentY - (delta * root.wheelScrollMultiplier); + listView.contentY = Math.max(0, Math.min(newY, listView.contentHeight - listView.height)); + event.accepted = true; + } + } - parent: listView - x: 0 - y: listView.height - height - width: listView.width - active: listView.ScrollBar.vertical.active - policy: root.horizontalPolicy + ScrollBar.vertical: ScrollBar { + parent: root + x: root.mirrored ? 0 : root.width - width + y: 0 + height: root.height + policy: root.verticalPolicy + visible: policy === ScrollBar.AlwaysOn || root.verticalScrollBarActive contentItem: Rectangle { - implicitWidth: 100 - implicitHeight: root.handleWidth + implicitWidth: root.handleWidth + implicitHeight: 100 radius: root.handleRadius color: parent.pressed ? root.handlePressedColor : parent.hovered ? root.handleHoverColor : root.handleColor - opacity: parent.policy === ScrollBar.AlwaysOn || parent.active ? 1 : 0 + opacity: parent.policy === ScrollBar.AlwaysOn ? 1 : root.verticalScrollBarActive ? (parent.active ? 1 : 0) : 0 Behavior on opacity { NumberAnimation { @@ -195,10 +235,10 @@ Item { } background: Rectangle { - implicitWidth: 100 - implicitHeight: root.handleWidth + implicitWidth: root.handleWidth + implicitHeight: 100 color: root.trackColor - opacity: parent.policy === ScrollBar.AlwaysOn || parent.active ? 0.3 : 0 + opacity: parent.policy === ScrollBar.AlwaysOn ? 0.3 : root.verticalScrollBarActive ? (parent.active ? 0.3 : 0) : 0 radius: root.handleRadius / 2 Behavior on opacity { diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Misc/MonitorItem.qml b/config/quickshell/.config/quickshell/Components/UProgressExpand.qml similarity index 86% rename from config/quickshell/.config/quickshell/Modules/Bar/Misc/MonitorItem.qml rename to config/quickshell/.config/quickshell/Components/UProgressExpand.qml index 405d52e..46fcaee 100644 --- a/config/quickshell/.config/quickshell/Modules/Bar/Misc/MonitorItem.qml +++ b/config/quickshell/.config/quickshell/Components/UProgressExpand.qml @@ -7,12 +7,12 @@ import qs.Constants Item { id: root - required property string symbol - property int symbolSize: Fonts.icon + required property string iconName + property int iconSize: Style.fontSizeM property real maxValue: 100 property real value: 100 property string textValue: "" // override value in textDisplay if set - property color fillColor: Colors.primary + property color fillColor: Colors.mPrimary property string textSuffix: "" property bool pointerCursor: true property bool expandOnValueChange: false @@ -22,7 +22,7 @@ Item { property bool _isFirst: true property bool disableHover: false property bool critical: false - property color criticalColor: Colors.red + property color criticalColor: Colors.mRed readonly property real ratio: value / maxValue property color realColor: critical ? criticalColor : fillColor @@ -32,8 +32,8 @@ Item { signal rightClicked() signal middleClicked() - implicitHeight: parent.height - 5 - implicitWidth: parent.height + (_expand ? textDisplay.width : 0) + implicitHeight: Math.max(iconSize, textLabel.implicitHeight) + 12 + implicitWidth: height + textDisplay.implicitWidth Loader { id: connectionLoader @@ -95,15 +95,14 @@ Item { } RowLayout { - anchors.top: parent.top - anchors.bottom: parent.bottom + anchors.fill: parent spacing: 0 Item { id: progressDisplay - Layout.preferredHeight: parent.height - Layout.preferredWidth: parent.height + Layout.preferredHeight: root.height + Layout.preferredWidth: root.height Canvas { id: progressCircle @@ -140,16 +139,15 @@ Item { } - Text { - id: symbolText + UIcon { + id: symbolIcon anchors.fill: parent - text: symbol - font.family: Fonts.nerd - font.pointSize: symbolSize - color: root.realColor horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter + iconName: root.iconName + iconSize: root.iconSize + color: root.realColor } } @@ -161,17 +159,15 @@ Item { implicitWidth: root._expand ? textLabel.implicitWidth + 10 : 0 clip: true - Text { + UText { id: textLabel anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left anchors.leftMargin: 5 text: (textValue || Math.round(root.value)) + root.textSuffix - font.pointSize: Fonts.small - font.family: Fonts.primary + font.pointSize: Style.fontSizeS color: root.realColor - opacity: root._expand ? 1 : 0 } Behavior on implicitWidth { diff --git a/config/quickshell/.config/quickshell/Noctalia/NScrollView.qml b/config/quickshell/.config/quickshell/Components/UScrollView.qml similarity index 97% rename from config/quickshell/.config/quickshell/Noctalia/NScrollView.qml rename to config/quickshell/.config/quickshell/Components/UScrollView.qml index 699371c..9375465 100644 --- a/config/quickshell/.config/quickshell/Noctalia/NScrollView.qml +++ b/config/quickshell/.config/quickshell/Components/UScrollView.qml @@ -6,10 +6,10 @@ import qs.Constants T.ScrollView { id: root - property color handleColor: Qt.alpha(Color.mTertiary, 0.8) + property color handleColor: Qt.alpha(Colors.mPrimary, 0.8) property color handleHoverColor: handleColor property color handlePressedColor: handleColor - property color trackColor: Color.transparent + property color trackColor: Colors.transparent property real handleWidth: 6 property real handleRadius: Style.radiusM property int verticalPolicy: ScrollBar.AsNeeded diff --git a/config/quickshell/.config/quickshell/Components/USlider.qml b/config/quickshell/.config/quickshell/Components/USlider.qml new file mode 100644 index 0000000..c770cd0 --- /dev/null +++ b/config/quickshell/.config/quickshell/Components/USlider.qml @@ -0,0 +1,275 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Shapes +import qs.Constants + +Slider { + id: root + + property color fillColor: Colors.mPrimary + property var cutoutColor: Colors.mSurface + property bool snapAlways: true + property real heightRatio: 0.7 + property string tooltipText + property string tooltipDirection: "auto" + property bool hovering: false + readonly property color effectiveFillColor: enabled ? fillColor : Colors.mOutline + readonly property real knobDiameter: Math.round((Style.baseWidgetSize * heightRatio) / 2) * 2 + readonly property real trackHeight: Math.round((knobDiameter * 0.4) / 2) * 2 + readonly property real trackRadius: Math.min(Style.radiusL, trackHeight / 2) + readonly property real cutoutExtra: Math.round((Style.baseWidgetSize * 0.1) / 2) * 2 + + padding: cutoutExtra / 2 + snapMode: snapAlways ? Slider.SnapAlways : Slider.SnapOnRelease + implicitHeight: Math.max(trackHeight, knobDiameter) + + background: Item { + id: bgContainer + + readonly property real fillWidth: root.visualPosition * width + + x: root.leftPadding + y: root.topPadding + Math.round((root.availableHeight - root.trackHeight) / 2) + implicitWidth: Style.sliderWidth + implicitHeight: root.trackHeight + width: root.availableWidth + height: root.trackHeight + + // Background track + Shape { + anchors.fill: parent + visible: bgContainer.width > 0 && bgContainer.height > 0 + preferredRendererType: Shape.CurveRenderer + + ShapePath { + id: bgPath + + readonly property real w: bgContainer.width + readonly property real h: bgContainer.height + readonly property real r: root.trackRadius + + strokeColor: Qt.alpha(Colors.mOutline, 0.5) + strokeWidth: Style.borderS + fillColor: Qt.alpha(Colors.mSurface, 0.5) + startX: r + startY: 0 + + PathLine { + x: bgPath.w - bgPath.r + y: 0 + } + + PathArc { + x: bgPath.w + y: bgPath.r + radiusX: bgPath.r + radiusY: bgPath.r + } + + PathLine { + x: bgPath.w + y: bgPath.h - bgPath.r + } + + PathArc { + x: bgPath.w - bgPath.r + y: bgPath.h + radiusX: bgPath.r + radiusY: bgPath.r + } + + PathLine { + x: bgPath.r + y: bgPath.h + } + + PathArc { + x: 0 + y: bgPath.h - bgPath.r + radiusX: bgPath.r + radiusY: bgPath.r + } + + PathLine { + x: 0 + y: bgPath.r + } + + PathArc { + x: bgPath.r + y: 0 + radiusX: bgPath.r + radiusY: bgPath.r + } + + } + + } + + LinearGradient { + id: fillGradient + + x1: 0 + y1: 0 + x2: root.availableWidth + y2: 0 + + GradientStop { + position: 0 + color: Qt.darker(effectiveFillColor, 1.2) + } + + GradientStop { + position: 1 + color: effectiveFillColor + } + + } + + // Active/filled track + Shape { + width: bgContainer.fillWidth + height: bgContainer.height + visible: bgContainer.fillWidth > 0 && bgContainer.height > 0 + preferredRendererType: Shape.CurveRenderer + clip: true + + ShapePath { + id: fillPath + + readonly property real fullWidth: root.availableWidth + readonly property real h: root.trackHeight + readonly property real r: root.trackRadius + + strokeColor: "transparent" + fillGradient: fillGradient + startX: r + startY: 0 + + PathLine { + x: fillPath.fullWidth - fillPath.r + y: 0 + } + + PathArc { + x: fillPath.fullWidth + y: fillPath.r + radiusX: fillPath.r + radiusY: fillPath.r + } + + PathLine { + x: fillPath.fullWidth + y: fillPath.h - fillPath.r + } + + PathArc { + x: fillPath.fullWidth - fillPath.r + y: fillPath.h + radiusX: fillPath.r + radiusY: fillPath.r + } + + PathLine { + x: fillPath.r + y: fillPath.h + } + + PathArc { + x: 0 + y: fillPath.h - fillPath.r + radiusX: fillPath.r + radiusY: fillPath.r + } + + PathLine { + x: 0 + y: fillPath.r + } + + PathArc { + x: fillPath.r + y: 0 + radiusX: fillPath.r + radiusY: fillPath.r + } + + } + + } + + // Circular cutout + Rectangle { + id: knobCutout + + implicitWidth: root.knobDiameter + root.cutoutExtra + implicitHeight: root.knobDiameter + root.cutoutExtra + radius: Math.min(Style.radiusL, width / 2) + color: root.cutoutColor !== undefined ? root.cutoutColor : Colors.mSurface + x: root.visualPosition * (root.availableWidth - root.knobDiameter) - root.cutoutExtra / 2 + anchors.verticalCenter: parent.verticalCenter + } + + } + + handle: Item { + implicitWidth: knobDiameter + implicitHeight: knobDiameter + x: root.leftPadding + root.visualPosition * (root.availableWidth - width) + anchors.verticalCenter: parent.verticalCenter + + Rectangle { + id: knob + + implicitWidth: knobDiameter + implicitHeight: knobDiameter + radius: Math.min(Style.radiusL, width / 2) + color: root.pressed ? Colors.mHover : Colors.mSurface + border.color: effectiveFillColor + border.width: Style.borderL + anchors.centerIn: parent + + Behavior on color { + ColorAnimation { + duration: Style.animationFast + } + + } + + } + + MouseArea { + enabled: true + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + acceptedButtons: Qt.NoButton // Don't accept any mouse buttons - only hover + propagateComposedEvents: true + onEntered: { + root.hovering = true; + if (root.tooltipText) + TooltipService.show(knob, root.tooltipText, root.tooltipDirection); + + } + onExited: { + root.hovering = false; + if (root.tooltipText) + TooltipService.hide(); + + } + } + + // Hide tooltip when slider is pressed (anywhere on the slider) + Connections { + function onPressedChanged() { + if (root.pressed && root.tooltipText) + TooltipService.hide(); + + } + + target: root + } + + } + +} diff --git a/config/quickshell/.config/quickshell/Components/UTabBar.qml b/config/quickshell/.config/quickshell/Components/UTabBar.qml new file mode 100644 index 0000000..3fd4d34 --- /dev/null +++ b/config/quickshell/.config/quickshell/Components/UTabBar.qml @@ -0,0 +1,88 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import qs.Constants + +Rectangle { + id: root + objectName: "NTabBar" + + // Public properties + property int currentIndex: 0 + property real spacing: Style.marginXS + property real margins: 0 + property real tabHeight: Style.baseWidgetSize + property bool distributeEvenly: false + default property alias content: tabRow.children + + onDistributeEvenlyChanged: _applyDistribution() + Component.onCompleted: _applyDistribution() + + function _updateFirstLast() { + // Defensive check for QML initialization timing + if (!tabRow || !tabRow.children) { + return; + } + var kids = tabRow.children; + var len = kids.length; + var firstVisible = -1; + var lastVisible = -1; + for (var i = 0; i < len; i++) { + var child = kids[i]; + // Only consider items that have isFirst/isLast (actual tab buttons, not Repeaters) + if (child.visible && "isFirst" in child) { + if (firstVisible === -1) + firstVisible = i; + lastVisible = i; + } + } + for (var i = 0; i < len; i++) { + var child = kids[i]; + if ("isFirst" in child) + child.isFirst = (i === firstVisible); + if ("isLast" in child) + child.isLast = (i === lastVisible); + } + } + + function _applyDistribution() { + if (!tabRow || !tabRow.children) { + return; + } + if (!distributeEvenly) { + for (var i = 0; i < tabRow.children.length; i++) { + var child = tabRow.children[i]; + child.Layout.fillWidth = true; + } + return; + } + + for (var i = 0; i < tabRow.children.length; i++) { + var child = tabRow.children[i]; + child.Layout.fillWidth = true; + child.Layout.preferredWidth = 1; + } + } + + // Styling + implicitWidth: tabRow.implicitWidth + (margins * 2) + implicitHeight: tabHeight + (margins * 2) + color: Colors.mSurfaceVariant + radius: Style.radiusM + + RowLayout { + id: tabRow + anchors.fill: parent + anchors.margins: margins + spacing: root.spacing + + onChildrenChanged: { + for (var i = 0; i < children.length; i++) { + var child = children[i]; + child.visibleChanged.connect(root._updateFirstLast); + } + root._updateFirstLast(); + root._applyDistribution(); + } + } +} diff --git a/config/quickshell/.config/quickshell/Components/UTabButton.qml b/config/quickshell/.config/quickshell/Components/UTabButton.qml new file mode 100644 index 0000000..1f4dcc9 --- /dev/null +++ b/config/quickshell/.config/quickshell/Components/UTabButton.qml @@ -0,0 +1,108 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import qs.Constants +import qs.Components + +Rectangle { + id: root + + // Public properties + property string text: "" + property string icon: "" + property bool checked: false + property int tabIndex: 0 + property real pointSize: Style.fontSizeM + property bool isFirst: false + property bool isLast: false + + // Internal state + property bool isHovered: false + + signal clicked + + // Sizing + Layout.fillHeight: true + implicitWidth: contentLayout.implicitWidth + Style.marginM * 2 + + topLeftRadius: isFirst ? Style.radiusM : Style.radiusXXXS + bottomLeftRadius: isFirst ? Style.radiusM : Style.radiusXXXS + topRightRadius: isLast ? Style.radiusM : Style.radiusXXXS + bottomRightRadius: isLast ? Style.radiusM : Style.radiusXXXS + + color: root.isHovered ? Colors.mHover : (root.checked ? Colors.mPrimary : Colors.mSurface) + border.color: Colors.mOutline + border.width: Style.borderS + + Behavior on color { + enabled: !Colors.isTransitioning + ColorAnimation { + duration: Style.animationFast + easing.type: Easing.OutCubic + } + } + + // Content + RowLayout { + id: contentLayout + anchors.centerIn: parent + width: Math.min(implicitWidth, parent.width - Style.marginS * 2) + spacing: (root.icon !== "" && root.text !== "") ? Style.marginXS : 0 + + UIcon { + visible: root.icon !== "" + Layout.alignment: Qt.AlignVCenter + iconName: root.icon + iconSize: root.pointSize * 1.2 + color: root.isHovered ? Colors.mOnHover : (root.checked ? Colors.mOnPrimary : Colors.mOnSurface) + + Behavior on color { + enabled: !Colors.isTransitioning + ColorAnimation { + duration: Style.animationFast + easing.type: Easing.OutCubic + } + } + } + + UText { + id: tabText + visible: root.text !== "" + Layout.alignment: Qt.AlignVCenter + Layout.fillWidth: true + text: root.text + pointSize: root.pointSize + font.weight: Style.fontWeightSemiBold + color: root.isHovered ? Colors.mOnHover : (root.checked ? Colors.mOnPrimary : Colors.mOnSurface) + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + + Behavior on color { + enabled: !Colors.isTransitioning + ColorAnimation { + duration: Style.animationFast + easing.type: Easing.OutCubic + } + } + } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onEntered: { + root.isHovered = true; + } + onExited: { + root.isHovered = false; + } + onClicked: { + root.clicked(); + // Update parent NTabBar's currentIndex + if (root.parent && root.parent.parent && root.parent.parent.currentIndex !== undefined) { + root.parent.parent.currentIndex = root.tabIndex; + } + } + } +} diff --git a/config/quickshell/.config/quickshell/Noctalia/NText.qml b/config/quickshell/.config/quickshell/Components/UText.qml similarity index 72% rename from config/quickshell/.config/quickshell/Noctalia/NText.qml rename to config/quickshell/.config/quickshell/Components/UText.qml index 83ec766..7434bc2 100644 --- a/config/quickshell/.config/quickshell/Noctalia/NText.qml +++ b/config/quickshell/.config/quickshell/Components/UText.qml @@ -1,19 +1,17 @@ import QtQuick import QtQuick.Layouts import qs.Constants -import qs.Noctalia Text { id: root property string family: Fonts.primary property real pointSize: Style.fontSizeM - property real fontScale: 1 font.family: root.family font.weight: Style.fontWeightMedium - font.pointSize: root.pointSize * fontScale - color: Color.mOnSurface + font.pointSize: root.pointSize + color: Colors.mOnSurface elide: Text.ElideRight wrapMode: Text.NoWrap verticalAlignment: Text.AlignVCenter diff --git a/config/quickshell/.config/quickshell/Noctalia/NToggle.qml b/config/quickshell/.config/quickshell/Components/UToggle.qml similarity index 55% rename from config/quickshell/.config/quickshell/Noctalia/NToggle.qml rename to config/quickshell/.config/quickshell/Components/UToggle.qml index c1e1c53..57998d9 100644 --- a/config/quickshell/.config/quickshell/Noctalia/NToggle.qml +++ b/config/quickshell/.config/quickshell/Components/UToggle.qml @@ -1,6 +1,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts +import qs.Components import qs.Constants RowLayout { @@ -8,38 +9,54 @@ RowLayout { property string label: "" property string description: "" + property string icon: "" property bool checked: false property bool hovering: false property int baseSize: Math.round(Style.baseWidgetSize * 0.8) + property var defaultValue: undefined + property string settingsPath: "" + readonly property bool isValueChanged: (defaultValue !== undefined) && (checked !== defaultValue) + readonly property string indicatorTooltip: defaultValue !== undefined ? I18n.tr("panels.indicator.default-value", { + "value": typeof defaultValue === "boolean" ? (defaultValue ? "true" : "false") : String(defaultValue) + }) : "" signal toggled(bool checked) signal entered() signal exited() Layout.fillWidth: true + opacity: enabled ? 1 : 0.6 + spacing: Style.marginM - NLabel { + ULabel { + Layout.fillWidth: true label: root.label description: root.description + icon: root.icon + iconColor: root.checked ? Colors.mPrimary : Colors.mOnSurface + visible: root.label !== "" || root.description !== "" + showIndicator: root.isValueChanged + indicatorTooltip: root.indicatorTooltip } Rectangle { id: switcher + Layout.alignment: Qt.AlignVCenter implicitWidth: Math.round(root.baseSize * 0.85) * 2 implicitHeight: Math.round(root.baseSize * 0.5) * 2 - radius: height * 0.5 - color: root.checked ? Color.mPrimary : Color.mSurface - border.color: Color.mOutline - border.width: Math.max(1, Style.borderS) + radius: Math.min(Style.radiusL, height / 2) + color: root.checked ? Colors.mPrimary : Colors.mSurface + border.color: Colors.mOutline + border.width: Style.borderS Rectangle { implicitWidth: Math.round(root.baseSize * 0.4) * 2 implicitHeight: Math.round(root.baseSize * 0.4) * 2 - radius: height * 0.5 - color: root.checked ? Color.mOnPrimary : Color.mPrimary - border.color: root.checked ? Color.mSurface : Color.mSurface - border.width: Math.max(1, Style.borderM) + radius: Math.min(Style.radiusL, height / 2) + color: root.checked ? Colors.mOnPrimary : Colors.mPrimary + border.color: root.checked ? Colors.mSurface : Colors.mSurface + border.width: Style.borderM anchors.verticalCenter: parent.verticalCenter anchors.verticalCenterOffset: 0 x: root.checked ? switcher.width - width - 3 : 3 @@ -55,18 +72,28 @@ RowLayout { } MouseArea { + enabled: root.enabled anchors.fill: parent cursorShape: Qt.PointingHandCursor hoverEnabled: true onEntered: { + if (!enabled) + return ; + hovering = true; root.entered(); } onExited: { + if (!enabled) + return ; + hovering = false; root.exited(); } onClicked: { + if (!enabled) + return ; + root.toggled(!root.checked); } } diff --git a/config/quickshell/.config/quickshell/Constants/Color.qml b/config/quickshell/.config/quickshell/Constants/Color.qml deleted file mode 100644 index 84aac1a..0000000 --- a/config/quickshell/.config/quickshell/Constants/Color.qml +++ /dev/null @@ -1,26 +0,0 @@ -import QtQuick -import Quickshell -import Quickshell.Io -import qs.Constants -pragma Singleton - -Singleton { - id: root - - // Compatibility colors for noctalia modules - readonly property color mPrimary: Colors.primary - readonly property color mOnPrimary: Colors.base - readonly property color mSecondary: Colors.primary - readonly property color mOnSecondary: Colors.base - readonly property color mTertiary: Colors.primary - readonly property color mOnTertiary: Colors.base - readonly property color mError: Colors.red - readonly property color mOnError: Colors.base - readonly property color mSurface: Colors.base - readonly property color mOnSurface: Colors.text - readonly property color mSurfaceVariant: Colors.surface - readonly property color mOnSurfaceVariant: Colors.overlay1 - readonly property color mOutline: Colors.primary - readonly property color mShadow: Colors.crust - readonly property color transparent: "transparent" -} diff --git a/config/quickshell/.config/quickshell/Constants/Colors.qml b/config/quickshell/.config/quickshell/Constants/Colors.qml index 4732210..e43cfe3 100644 --- a/config/quickshell/.config/quickshell/Constants/Colors.qml +++ b/config/quickshell/.config/quickshell/Constants/Colors.qml @@ -1,40 +1,154 @@ import QtQuick import Quickshell -import qs.Services +import Quickshell.Io +import qs.Constants +import qs.Utils pragma Singleton Singleton { id: root - readonly property color primary: SettingsService.primaryColor - readonly property color transparent: "transparent" - readonly property color rosewater: "#f5e0dc" - readonly property color flamingo: "#f2cdcd" - readonly property color pink: "#f5c2e7" - readonly property color mauve: "#cba6f7" - readonly property color red: "#f38ba8" - readonly property color maroon: "#eba0ac" - readonly property color peach: "#fab387" - readonly property color yellow: "#f9e2af" - readonly property color green: "#a6e3a1" - readonly property color teal: "#94e2d5" - readonly property color sky: "#89dceb" - readonly property color sapphire: "#74c7ec" - readonly property color blue: "#89b4fa" - readonly property color lavender: "#b4befe" - readonly property color text: "#cdd6f4" - readonly property color subtext1: "#bac2de" - readonly property color subtext0: "#a6adc8" - readonly property color overlay2: "#9399b2" - readonly property color overlay1: "#7f849c" - readonly property color overlay0: "#6c7086" - readonly property color surface2: "#585b70" - readonly property color surface1: "#45475a" - readonly property color surface0: "#313244" - readonly property color surface: "#292a3c" - readonly property color base: "#1e1e2e" - readonly property color mantle: "#181825" - readonly property color crust: "#11111b" - readonly property color distroColor: "#74c7ec" - readonly property var cavaList: ["#b4befe", "#89b4fa", "#74c7ec", "#89dceb", "#94e2d5", "#a6e3a1", "#f9e2af", "#fab387"] + // Part of material3 color scheme + property color mPrimary: defaultColors.mPrimary + property color mOnPrimary: defaultColors.mOnPrimary + property color mError: defaultColors.mError + property color mOnError: defaultColors.mOnError + property color mSurface: defaultColors.mSurface + property color mOnSurface: defaultColors.mOnSurface + property color mSurfaceVariant: defaultColors.mSurfaceVariant + property color mOnSurfaceVariant: defaultColors.mOnSurfaceVariant + property color mOutline: defaultColors.mOutline + property color mShadow: defaultColors.mShadow + property color mHover: defaultColors.mHover + property color mOnHover: defaultColors.mOnHover + // Supplementary colors + property color mPink: defaultColors.mPink + property color mPurple: defaultColors.mPurple + property color mRed: defaultColors.mRed + property color mOrange: defaultColors.mOrange + property color mYellow: defaultColors.mYellow + property color mGreen: defaultColors.mGreen + property color mCyan: defaultColors.mCyan + property color mSky: defaultColors.mSky + property color mBlue: defaultColors.mBlue + property color mLavender: defaultColors.mLavender + // Special colors + property color distro: "#74c7ec" + property color transparent: "#00000000" + readonly property var cavaList: [mLavender, mBlue, mSky, mCyan, mGreen, mYellow, mOrange, mRed] + + function reloadColors(newColors) { + if (typeof newColors === "string") { + try { + newColors = JSON.parse(newColors); + } catch (e) { + Logger.e("Colors", "Failed to parse colors.json, using default colors. Error:", e); + return ; + } + } else if (typeof newColors !== "object") { + Logger.w("Colors", "Invalid colors data, using default colors. Data:", newColors); + return ; + } + mPrimary = newColors.mPrimary || defaultColors.mPrimary; + mOnPrimary = newColors.mOnPrimary || defaultColors.mOnPrimary; + mError = newColors.mError || defaultColors.mError; + mOnError = newColors.mOnError || defaultColors.mOnError; + mSurface = newColors.mSurface || defaultColors.mSurface; + mOnSurface = newColors.mOnSurface || defaultColors.mOnSurface; + mSurfaceVariant = newColors.mSurfaceVariant || defaultColors.mSurfaceVariant; + mOnSurfaceVariant = newColors.mOnSurfaceVariant || defaultColors.mOnSurfaceVariant; + mOutline = newColors.mOutline || defaultColors.mOutline; + mShadow = newColors.mShadow || defaultColors.mShadow; + mHover = newColors.mHover || defaultColors.mHover; + mOnHover = newColors.mOnHover || defaultColors.mOnHover; + mPink = newColors.mPink || defaultColors.mPink; + mPurple = newColors.mPurple || defaultColors.mPurple; + mRed = newColors.mRed || defaultColors.mRed; + mOrange = newColors.mOrange || defaultColors.mOrange; + mYellow = newColors.mYellow || defaultColors.mYellow; + mGreen = newColors.mGreen || defaultColors.mGreen; + mCyan = newColors.mCyan || defaultColors.mCyan; + mSky = newColors.mSky || defaultColors.mSky; + mBlue = newColors.mBlue || defaultColors.mBlue; + mLavender = newColors.mLavender || defaultColors.mLavender; + } + + function setColor(name, value) { + if (!adapter.colors) + adapter.colors = { + }; + + adapter.colors[name] = value; + colorFile.writeAdapter(); + } + + function unsetColor(name) { + if (!adapter.colors || !(name in adapter.colors)) + return ; + + delete adapter.colors[name]; + colorFile.writeAdapter(); + } + + QtObject { + id: defaultColors + + readonly property color mPrimary: "#89b4fa" + readonly property color mOnPrimary: "#11111b" + readonly property color mError: "#f38ba8" + readonly property color mOnError: "#11111b" + readonly property color mSurface: "#1e1e2e" + readonly property color mOnSurface: "#cdd6f4" + readonly property color mSurfaceVariant: "#313244" + readonly property color mOnSurfaceVariant: "#a6adc8" + readonly property color mOutline: "#585b70" + readonly property color mShadow: "#11111b" + readonly property color mHover: "#45475a" + readonly property color mOnHover: "#cdd6f4" + readonly property color mPink: "#f5c2e7" + readonly property color mPurple: "#cba6f7" + readonly property color mRed: "#f38ba8" + readonly property color mOrange: "#fab387" + readonly property color mYellow: "#f9e2af" + readonly property color mGreen: "#a6e3a1" + readonly property color mCyan: "#94e2d5" + readonly property color mSky: "#74c7ec" + readonly property color mBlue: "#89b4fa" + readonly property color mLavender: "#b4befe" + } + + FileView { + id: colorFile + + path: Paths.configDir + "colors.json" + printErrors: false + watchChanges: true + onFileChanged: reload() + + JsonAdapter { + id: adapter + + property var colors: ({ + }) + } + + } + + Connections { + function onColorsChanged() { + colorReloadTimer.restart(); + } + + target: adapter + } + + Timer { + id: colorReloadTimer + + interval: 50 + running: true + repeat: false + onTriggered: reloadColors(adapter.colors) + } + } diff --git a/config/quickshell/.config/quickshell/Constants/Fonts.qml b/config/quickshell/.config/quickshell/Constants/Fonts.qml index 1499e83..5785a93 100644 --- a/config/quickshell/.config/quickshell/Constants/Fonts.qml +++ b/config/quickshell/.config/quickshell/Constants/Fonts.qml @@ -8,9 +8,6 @@ Singleton { readonly property string primary: "LXGW WenKai" readonly property string nerd: "Meslo LGM Nerd Font Mono" + readonly property string icon: Icons.fontFamily readonly property string sans: "LXGW WenKai" - readonly property int small: Style.fontSizeS - readonly property int medium: Style.fontSizeM - readonly property int large: Style.fontSizeL - readonly property int icon: 14 // for nerd font } diff --git a/config/quickshell/.config/quickshell/Constants/Icons.qml b/config/quickshell/.config/quickshell/Constants/Icons.qml index 9cf2077..00ef209 100644 --- a/config/quickshell/.config/quickshell/Constants/Icons.qml +++ b/config/quickshell/.config/quickshell/Constants/Icons.qml @@ -1,53 +1,19 @@ import QtQuick +import QtQuick.Controls import Quickshell +import qs.Constants import qs.Utils pragma Singleton Singleton { id: root - // Nerd fonts icons - readonly property string distro: "󰣇" - readonly property string tray: "" - readonly property string idleInhibitorActivated: "󰅶" - readonly property string idleInhibitorDeactivated: "󰾪" - readonly property string powerMenu: "󰐥" - readonly property string volumeHigh: "" - readonly property string volumeMedium: "" - readonly property string volumeLow: "" - readonly property string volumeMuted: "󰝟" - readonly property string brightness: "" - readonly property string charging: "" - readonly property string battery100: "" - readonly property string battery75: "" - readonly property string battery50: "" - readonly property string battery25: "" - readonly property string battery00: "" - readonly property string cpu: "󰘚" - readonly property string memory: "󰍛" - readonly property string tempHigh: "" - readonly property string tempMedium: "" - readonly property string tempLow: "" - readonly property string ip: "󰇧" - readonly property string upload: "" - readonly property string download: "" - readonly property string speedSlower: "󰾆" - readonly property string speedFaster: "󰓅" - readonly property string speedReset: "󰾅" - readonly property string reset: "󰑙" - readonly property string lines: "" - readonly property string record: "" - readonly property string wifiOn: "󰖩" - readonly property string wifiOff: "󰖪" - readonly property string bluetoothOn: "" - readonly property string bluetoothOff: "󰂲" - // Tabler icons // Expose the font family name for easy access readonly property string fontFamily: currentFontLoader ? currentFontLoader.name : "" - readonly property string defaultIcon: TablerIcons.defaultIcon - readonly property var icons: TablerIcons.icons - readonly property var aliases: TablerIcons.aliases - readonly property string fontPath: "/Assets/Fonts/tabler/tabler-icons.ttf" + readonly property string defaultIcon: IconsTabler.defaultIcon + readonly property var icons: IconsTabler.icons + readonly property var aliases: IconsTabler.aliases + readonly property string fontPath: "/Assets/Fonts/tabler/noctalia-tabler-icons.ttf" // Current active font loader property FontLoader currentFontLoader: null property int fontVersion: 0 @@ -68,6 +34,7 @@ Singleton { } function loadFontWithCacheBusting() { + Logger.d("Icons", "Loading font with cache busting"); // Destroy old loader first if (currentFontLoader) { currentFontLoader.destroy(); @@ -82,24 +49,29 @@ Singleton { `, root, "dynamicFontLoader_" + fontVersion); // Connect to the new loader's status changes currentFontLoader.statusChanged.connect(function() { - if (currentFontLoader.status === FontLoader.Ready) + if (currentFontLoader.status === FontLoader.Ready) { + Logger.d("Icons", "Font loaded successfully:", currentFontLoader.name, "(version " + fontVersion + ")"); fontReloaded(); - else if (currentFontLoader.status === FontLoader.Error) - Logger.error("Icons", "Font failed to load (version " + fontVersion + ")"); + } else if (currentFontLoader.status === FontLoader.Error) { + Logger.e("Icons", "Font failed to load (version " + fontVersion + ")"); + } }); } function reloadFont() { + Logger.d("Icons", "Forcing font reload..."); fontVersion++; loadFontWithCacheBusting(); } Component.onCompleted: { + Logger.i("Icons", "Service started"); loadFontWithCacheBusting(); } Connections { function onReloadCompleted() { + Logger.d("Icons", "Quickshell reload completed - forcing font reload"); reloadFont(); } diff --git a/config/quickshell/.config/quickshell/Constants/TablerIcons.qml b/config/quickshell/.config/quickshell/Constants/IconsTabler.qml similarity index 97% rename from config/quickshell/.config/quickshell/Constants/TablerIcons.qml rename to config/quickshell/.config/quickshell/Constants/IconsTabler.qml index 39743ad..8b3dfca 100644 --- a/config/quickshell/.config/quickshell/Constants/TablerIcons.qml +++ b/config/quickshell/.config/quickshell/Constants/IconsTabler.qml @@ -33,6 +33,7 @@ Singleton { "media-next": "player-skip-forward-filled", "download-speed": "download", "upload-speed": "upload", + "cpu-intensive": "alert-octagon", "cpu-usage": "brand-speedtest", "cpu-temperature": "flame", "gpu-temperature": "device-desktop", @@ -42,6 +43,7 @@ Singleton { "powersaver": "leaf", "storage": "database", "ethernet": "sitemap", + "ethernet-off": "sitemap-off", "keyboard": "keyboard", "shutdown": "power", "lock": "lock", @@ -49,6 +51,7 @@ Singleton { "logout": "logout", "reboot": "refresh", "suspend": "player-pause", + "hibernate": "zzz", "nightlight-on": "moon", "nightlight-off": "moon-off", "nightlight-forced": "moon-stars", @@ -71,15 +74,19 @@ Singleton { "chevron-down": "chevron-down", "caret-up": "caret-up-filled", "caret-down": "caret-down-filled", + "caret-left": "caret-left-filled", + "caret-right": "caret-right-filled", "star": "star", "star-off": "star-off", "battery-exclamation": "battery-exclamation", "battery-charging": "battery-charging", + "battery-charging-2": "battery-charging-2", "battery-4": "battery-4", "battery-3": "battery-3", "battery-2": "battery-2", "battery-1": "battery-1", "battery": "battery", + "battery-off": "battery-off", "wifi-0": "wifi-0", "wifi-1": "wifi-1", "wifi-2": "wifi-2", @@ -88,10 +95,14 @@ Singleton { "microphone": "microphone", "microphone-mute": "microphone-off", "volume-mute": "volume-off", + "volume-x": "volume-3", "volume-zero": "volume-3", "volume-low": "volume-2", "volume-high": "volume", "weather-sun": "sun", + "weather-moon": "moon", + "weather-moon-stars": "moon-stars", + "weather-cloud-off": "cloud-off", "weather-cloud": "cloud", "weather-cloud-haze": "cloud-fog", "weather-cloud-lightning": "cloud-bolt", @@ -101,24 +112,33 @@ Singleton { "brightness-low": "brightness-down-filled", "brightness-high": "brightness-up-filled", "settings-general": "adjustments-horizontal", - "settings-bar": "capsule-horizontal", + "settings-bar": "crop-16-9", + "settings-user-interface": "layout-board", + "settings-control-center": "adjustments-horizontal", "settings-dock": "layout-bottombar", "settings-launcher": "rocket", "settings-audio": "device-speaker", "settings-display": "device-desktop", - "settings-network": "sitemap", + "settings-network": "circles-relation", "settings-brightness": "brightness-up", "settings-location": "world-pin", "settings-color-scheme": "palette", "settings-wallpaper": "paint", "settings-wallpaper-selector": "library-photo", - "settings-screen-recorder": "video", "settings-hooks": "link", "settings-notifications": "bell", "settings-osd": "picture-in-picture", "settings-about": "info-square-rounded", + "settings-idle": "moon", + "settings-lock-screen": "lock", + "settings-session-menu": "power", + "settings-system-monitor": "activity", "bluetooth": "bluetooth", "bt-device-generic": "bluetooth", + "bt-device-gamepad": "device-gamepad-2", + "bt-device-microphone": "microphone", + "bt-device-headset": "headset", + "bt-device-earbuds": "device-airpods", "bt-device-headphones": "headphones", "bt-device-mouse": "mouse-2", "bt-device-keyboard": "bluetooth", @@ -126,6 +146,12 @@ Singleton { "bt-device-watch": "device-watch", "bt-device-speaker": "device-speaker", "bt-device-tv": "device-tv", + "antenna-bars-1": "antenna-bars-1", + "antenna-bars-2": "antenna-bars-2", + "antenna-bars-3": "antenna-bars-3", + "antenna-bars-4": "antenna-bars-4", + "antenna-bars-5": "antenna-bars-5", + "antenna-bars-off": "antenna-bars-off", "noctalia": "noctalia", "hyprland": "hyprland", "filepicker-folder": "folder", @@ -152,7 +178,10 @@ Singleton { "filepicker-text": "file-text", "filepicker-eye": "eye", "filepicker-eye-off": "eye-off", - "filepicker-folder-current": "checks" + "filepicker-folder-current": "checks", + "plugin": "plug-connected", + "info": "file-description", + "official-plugin": "shield-filled" } // Fonts Codepoints - do not change! @@ -295,8 +324,8 @@ Singleton { "align-left": "\u{ea09}", "align-left-2": "\u{ff00}", "align-right": "\u{ea0a}", - "alpha"//"align-right-2": "\u{feff}", - : "\u{f543}", + //"align-right-2": "\u{feff}", + "alpha": "\u{f543}", "alphabet-arabic": "\u{ff2f}", "alphabet-bangla": "\u{ff2e}", "alphabet-cyrillic": "\u{f1df}", @@ -2084,7 +2113,7 @@ Singleton { "cloud-snow": "\u{ea73}", "cloud-star": "\u{f85b}", "cloud-storm": "\u{ea74}", - "cloud-sun": "\u{ea7a}", + "cloud-sun": "\u{ec6d}", "cloud-up": "\u{f85c}", "cloud-upload": "\u{ea75}", "cloud-x": "\u{f85d}", @@ -3128,8 +3157,8 @@ Singleton { "friends": "\u{eab0}", "friends-off": "\u{f136}", "frustum": "\u{fa9f}", - "frustum-plus"//"frustum-off": "\u{fa9d}", - : "\u{fa9e}", + //"frustum-off": "\u{fa9d}", + "frustum-plus": "\u{fa9e}", "function": "\u{f225}", "function-filled": "\u{fc2b}", "function-off": "\u{f3f0}", @@ -3388,13 +3417,13 @@ Singleton { "hexagon-letter-x": "\u{f479}", "hexagon-letter-x-filled": "\u{fe30}", "hexagon-letter-y": "\u{f47a}", - "hexagon-letter-z"//"hexagon-letter-y-filled": "\u{fe2f}", - : "\u{f47b}", - "hexagon-minus"//"hexagon-letter-z-filled": "\u{fe2e}", - : "\u{fc8f}", + //"hexagon-letter-y-filled": "\u{fe2f}", + "hexagon-letter-z": "\u{f47b}", + //"hexagon-letter-z-filled": "\u{fe2e}", + "hexagon-minus": "\u{fc8f}", "hexagon-minus-2": "\u{fc8e}", - "hexagon-number-0"//"hexagon-minus-filled": "\u{fe2d}", - : "\u{f459}", + //"hexagon-minus-filled": "\u{fe2d}", + "hexagon-number-0": "\u{f459}", "hexagon-number-0-filled": "\u{f74c}", "hexagon-number-1": "\u{f45a}", "hexagon-number-1-filled": "\u{f74d}", @@ -3417,8 +3446,8 @@ Singleton { "hexagon-off": "\u{ee9c}", "hexagon-plus": "\u{fc45}", "hexagon-plus-2": "\u{fc90}", - "hexagonal-prism"//"hexagon-plus-filled": "\u{fe2c}", - : "\u{faa5}", + //"hexagon-plus-filled": "\u{fe2c}", + "hexagonal-prism": "\u{faa5}", "hexagonal-prism-off": "\u{faa3}", "hexagonal-prism-plus": "\u{faa4}", "hexagonal-pyramid": "\u{faa8}", @@ -3448,8 +3477,8 @@ Singleton { "home-eco": "\u{f351}", "home-edit": "\u{f352}", "home-exclamation": "\u{f33c}", - "home-hand"//"home-filled": "\u{fe2b}", - : "\u{f504}", + //"home-filled": "\u{fe2b}", + "home-hand": "\u{f504}", "home-heart": "\u{f353}", "home-infinity": "\u{f505}", "home-link": "\u{f354}", @@ -3567,8 +3596,8 @@ Singleton { "ironing-2-filled": "\u{1006e}", "ironing-3": "\u{f2f6}", "ironing-3-filled": "\u{1006d}", - "ironing-off"//"ironing-filled": "\u{fe2a}", - : "\u{f2f7}", + //"ironing-filled": "\u{fe2a}", + "ironing-off": "\u{f2f7}", "ironing-steam": "\u{f2f9}", "ironing-steam-filled": "\u{1006c}", "ironing-steam-off": "\u{f2f8}", @@ -3578,8 +3607,8 @@ Singleton { "italic": "\u{eb93}", "jacket": "\u{f661}", "jetpack": "\u{f581}", - "jewish-star"//"jetpack-filled": "\u{fe29}", - : "\u{f3ff}", + //"jetpack-filled": "\u{fe29}", + "jewish-star": "\u{f3ff}", "jewish-star-filled": "\u{f67e}", "join-bevel": "\u{ff4c}", "join-round": "\u{ff4b}", @@ -3593,8 +3622,8 @@ Singleton { "kering": "\u{efb8}", "kerning": "\u{efb8}", "key": "\u{eac7}", - "key-off"//"key-filled": "\u{fe28}", - : "\u{f14b}", + //"key-filled": "\u{fe28}", + "key-off": "\u{f14b}", "keyboard": "\u{ebd6}", "keyboard-filled": "\u{100a2}", "keyboard-hide": "\u{ec7e}", @@ -3650,20 +3679,20 @@ Singleton { "layers-union": "\u{eacb}", "layout": "\u{eadb}", "layout-2": "\u{eacc}", - "layout-align-left"//"layout-2-filled": "\u{fe27}", - // "layout-align-bottom": "\u{eacd}", + //"layout-2-filled": "\u{fe27}", + //"layout-align-bottom": "\u{eacd}", //"layout-align-bottom-filled": "\u{fe26}", - // "layout-align-center": "\u{eace}", + //"layout-align-center": "\u{eace}", //"layout-align-center-filled": "\u{fe25}", - : "\u{eacf}", - "layout-align-middle"// "layout-align-left-filled": "\u{fe24}", - : "\u{ead0}", - "layout-align-right"//"layout-align-middle-filled": "\u{fe23}", - : "\u{ead1}", - "layout-align-top"//"layout-align-right-filled": "\u{fe22}", - : "\u{ead2}", - "layout-board"//"layout-align-top-filled": "\u{fe21}", - : "\u{ef95}", + "layout-align-left": "\u{eacf}", + //"layout-align-left-filled": "\u{fe24}", + "layout-align-middle": "\u{ead0}", + //"layout-align-middle-filled": "\u{fe23}", + "layout-align-right": "\u{ead1}", + //"layout-align-right-filled": "\u{fe22}", + "layout-align-top": "\u{ead2}", + //"layout-align-top-filled": "\u{fe21}", + "layout-board": "\u{ef95}", "layout-board-filled": "\u{10182}", "layout-board-split": "\u{ef94}", "layout-board-split-filled": "\u{10183}", @@ -3675,8 +3704,8 @@ Singleton { "layout-bottombar-filled": "\u{fc37}", "layout-bottombar-inactive": "\u{fd45}", "layout-cards": "\u{ec13}", - "layout-collage"// "layout-cards-filled": "\u{fe20}", - : "\u{f389}", + //"layout-cards-filled": "\u{fe20}", + "layout-collage": "\u{f389}", "layout-columns": "\u{ead4}", "layout-dashboard": "\u{f02c}", "layout-dashboard-filled": "\u{fe1f}", @@ -4157,14 +4186,14 @@ Singleton { "microphone": "\u{eaf0}", "microphone-2": "\u{ef2c}", "microphone-2-off": "\u{f40d}", - "microphone-off"//"microphone-filled": "\u{fe0f}", - : "\u{ed16}", + //"microphone-filled": "\u{fe0f}", + "microphone-off": "\u{ed16}", "microscope": "\u{ef64}", "microscope-filled": "\u{10166}", "microscope-off": "\u{f40e}", "microwave": "\u{f248}", - "microwave-off"//"microwave-filled": "\u{fe0e}", - : "\u{f264}", + //"microwave-filled": "\u{fe0e}", + "microwave-off": "\u{f264}", "military-award": "\u{f079}", "military-rank": "\u{efcf}", "military-rank-filled": "\u{ff5e}", @@ -4398,18 +4427,18 @@ Singleton { "number-4-small": "\u{fcf9}", "number-40-small": "\u{fffa}", "number-41-small": "\u{fff9}", - "number-5"//"number-42-small": "\u{fff8}", - // "number-43-small": "\u{fff7}", - // "number-44-small": "\u{fff6}", - // "number-45-small": "\u{fff5}", - // "number-46-small": "\u{fff4}", - // "number-47-small": "\u{fff3}", - // "number-48-small": "\u{fff2}", - // "number-49-small": "\u{fff1}", - : "\u{edf5}", + //"number-42-small": "\u{fff8}", + //"number-43-small": "\u{fff7}", + //"number-44-small": "\u{fff6}", + //"number-45-small": "\u{fff5}", + //"number-46-small": "\u{fff4}", + //"number-47-small": "\u{fff3}", + //"number-48-small": "\u{fff2}", + //"number-49-small": "\u{fff1}", + "number-5": "\u{edf5}", "number-5-small": "\u{fcfa}", - "number-51-small"// "number-50-small": "\u{fff0}", - : "\u{ffef}", + //"number-50-small": "\u{fff0}", + "number-51-small": "\u{ffef}", "number-52-small": "\u{ffee}", "number-53-small": "\u{ffed}", "number-54-small": "\u{ffec}", @@ -4761,6 +4790,7 @@ Singleton { "playstation-triangle": "\u{f2af}", "playstation-x": "\u{f2b0}", "plug": "\u{ebd9}", + "plug-filled": "\u{f6b3}", "plug-connected": "\u{f00a}", "plug-connected-x": "\u{f0a0}", "plug-off": "\u{f180}", @@ -4849,11 +4879,11 @@ Singleton { "quote": "\u{efbe}", "quote-filled": "\u{1009c}", "quote-off": "\u{f188}", - "radar"//"quotes": "\u{fb1e}", - : "\u{f017}", + //"quotes": "\u{fb1e}", + "radar": "\u{f017}", "radar-2": "\u{f016}", - "radar-off"//"radar-filled": "\u{fe0d}", - : "\u{f41f}", + //"radar-filled": "\u{fe0d}", + "radar-off": "\u{f41f}", "radio": "\u{ef2d}", "radio-off": "\u{f420}", "radioactive": "\u{ecc0}", @@ -4913,12 +4943,12 @@ Singleton { "regex-off": "\u{f421}", "registered": "\u{eb14}", "relation-many-to-many": "\u{ed7f}", - "relation-one-to-many"//"relation-many-to-many-filled": "\u{fe0c}", - : "\u{ed80}", - "relation-one-to-one"//"relation-one-to-many-filled": "\u{fe0b}", - : "\u{ed81}", - "reload"//"relation-one-to-one-filled": "\u{fe0a}", - : "\u{f3ae}", + //"relation-many-to-many-filled": "\u{fe0c}", + "relation-one-to-many": "\u{ed80}", + //"relation-one-to-many-filled": "\u{fe0b}", + "relation-one-to-one": "\u{ed81}", + //"relation-one-to-one-filled": "\u{fe0a}", + "reload": "\u{f3ae}", "reorder": "\u{fc15}", "repeat": "\u{eb72}", "repeat-off": "\u{f18e}", @@ -5070,8 +5100,8 @@ Singleton { "search": "\u{eb1c}", "search-off": "\u{f19c}", "section": "\u{eed5}", - "section-sign"//"section-filled": "\u{fe09}", - : "\u{f019}", + //"section-filled": "\u{fe09}", + "section-sign": "\u{f019}", "seeding": "\u{ed51}", "seeding-filled": "\u{10006}", "seeding-off": "\u{f19d}", @@ -5279,8 +5309,8 @@ Singleton { "sort-z-a": "\u{f550}", "sos": "\u{f24a}", "soup": "\u{ef2e}", - "soup-off"//"soup-filled": "\u{fe08}", - : "\u{f42d}", + //"soup-filled": "\u{fe08}", + "soup-off": "\u{f42d}", "source-code": "\u{f4a2}", "space": "\u{ec0c}", "space-off": "\u{f1aa}", @@ -5373,22 +5403,22 @@ Singleton { "square-half": "\u{effb}", "square-key": "\u{f638}", "square-letter-a": "\u{f47c}", - "square-letter-b"//"square-letter-a-filled": "\u{fe07}", - : "\u{f47d}", - "square-letter-c"//"square-letter-b-filled": "\u{fe06}", - : "\u{f47e}", - "square-letter-d"//"square-letter-c-filled": "\u{fe05}", - : "\u{f47f}", - "square-letter-e"//"square-letter-d-filled": "\u{fe04}", - : "\u{f480}", - "square-letter-f"//"square-letter-e-filled": "\u{fe03}", - : "\u{f481}", - "square-letter-g"//"square-letter-f-filled": "\u{fe02}", - : "\u{f482}", - "square-letter-h"//"square-letter-g-filled": "\u{fe01}", - : "\u{f483}", - "square-letter-i"//"square-letter-h-filled": "\u{fe00}", - : "\u{f484}", + //"square-letter-a-filled": "\u{fe07}", + "square-letter-b": "\u{f47d}", + //"square-letter-b-filled": "\u{fe06}", + "square-letter-c": "\u{f47e}", + //"square-letter-c-filled": "\u{fe05}", + "square-letter-d": "\u{f47f}", + //"square-letter-d-filled": "\u{fe04}", + "square-letter-e": "\u{f480}", + //"square-letter-e-filled": "\u{fe03}", + "square-letter-f": "\u{f481}", + //"square-letter-f-filled": "\u{fe02}", + "square-letter-g": "\u{f482}", + //"square-letter-g-filled": "\u{fe01}", + "square-letter-h": "\u{f483}", + //"square-letter-h-filled": "\u{fe00}", + "square-letter-i": "\u{f484}", "square-letter-i-filled": "\u{fdff}", "square-letter-j": "\u{f485}", "square-letter-j-filled": "\u{fdfe}", diff --git a/config/quickshell/.config/quickshell/Constants/Paths.qml b/config/quickshell/.config/quickshell/Constants/Paths.qml new file mode 100644 index 0000000..35cd1e3 --- /dev/null +++ b/config/quickshell/.config/quickshell/Constants/Paths.qml @@ -0,0 +1,11 @@ +import QtQuick +import Quickshell +pragma Singleton + +Singleton { + id: root + + readonly property string cacheDir: Quickshell.shellDir + "/Assets/Cache/" + readonly property string configDir: Quickshell.shellDir + "/Assets/Config/" + readonly property string recordingDir: Quickshell.env("HOME") + "/Videos/recordings/" +} diff --git a/config/quickshell/.config/quickshell/Constants/Style.qml b/config/quickshell/.config/quickshell/Constants/Style.qml index 9580669..d67c36c 100644 --- a/config/quickshell/.config/quickshell/Constants/Style.qml +++ b/config/quickshell/.config/quickshell/Constants/Style.qml @@ -4,10 +4,6 @@ import Quickshell.Io pragma Singleton Singleton { - /* - Preset sizes for font, radii, ? - */ - id: root // Font size @@ -19,6 +15,7 @@ Singleton { readonly property real fontSizeXL: 16 readonly property real fontSizeXXL: 18 readonly property real fontSizeXXXL: 24 + readonly property real fontNerd: 16 // Font weight readonly property int fontWeightRegular: 400 readonly property int fontWeightMedium: 500 @@ -50,19 +47,22 @@ Singleton { readonly property real opacityHeavy: 0.75 readonly property real opacityAlmost: 0.95 readonly property real opacityFull: 1 + // Shadows + readonly property real shadowOpacity: 0.85 + readonly property real shadowBlur: 1 + readonly property int shadowBlurMax: 22 + readonly property real shadowHorizontalOffset: 2 + readonly property real shadowVerticalOffset: 3 // Animation duration (ms) readonly property int animationFast: 150 readonly property int animationNormal: 300 readonly property int animationSlow: 450 readonly property int animationSlowest: 1000 - // Delays - readonly property int tooltipDelay: 300 - readonly property int tooltipDelayLong: 1200 - readonly property int pillDelay: 500 // Settings widgets base size - readonly property real baseWidgetSize: 33 - readonly property real sliderWidth: 200 + readonly property int baseWidgetSize: 33 + readonly property int sliderWidth: 200 // Bar Dimensions - readonly property real barHeight: 45 - readonly property real capsuleHeight: 35 + readonly property int barHeight: 45 + readonly property int sidebarWidth: 360 + readonly property int capsuleHeight: 35 } diff --git a/config/quickshell/.config/quickshell/Modules/Background/Background.qml b/config/quickshell/.config/quickshell/Modules/Background/Background.qml new file mode 100644 index 0000000..1630641 --- /dev/null +++ b/config/quickshell/.config/quickshell/Modules/Background/Background.qml @@ -0,0 +1,167 @@ +import QtQuick +import Quickshell +import Quickshell.Wayland +import qs.Constants +import qs.Services + +Variants { + model: Quickshell.screens + + Item { + property var modelData + + PanelWindow { + screen: modelData + WlrLayershell.namespace: "quickshell-background" + WlrLayershell.layer: WlrLayer.Background + WlrLayershell.exclusionMode: ExclusionMode.Ignore + + anchors { + top: true + bottom: true + left: true + right: true + } + + Rectangle { + anchors.fill: parent + color: Colors.mSurface + + Item { + id: bgManager + + property string activeSource: BackgroundService.previewPath || (BarService.focusMode ? BackgroundService.cachedBlurredPath : BackgroundService.cachedPath) + property bool showFirst: true + + anchors.fill: parent + onActiveSourceChanged: { + showFirst = !showFirst; + if (showFirst) + img1.source = activeSource; + else + img2.source = activeSource; + } + Component.onCompleted: { + if (showFirst) + img1.source = activeSource; + else + img2.source = activeSource; + } + + Image { + id: img1 + + anchors.fill: parent + fillMode: Image.PreserveAspectCrop + opacity: (bgManager.showFirst && status === Image.Ready) ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: Style.animationSlow + } + + } + + } + + Image { + id: img2 + + anchors.fill: parent + fillMode: Image.PreserveAspectCrop + opacity: (!bgManager.showFirst && status === Image.Ready) ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: Style.animationSlow + } + + } + + } + + } + + } + + } + + PanelWindow { + screen: modelData + WlrLayershell.namespace: "quickshell-backdrop" + WlrLayershell.layer: WlrLayer.Background + WlrLayershell.exclusionMode: ExclusionMode.Ignore + + anchors { + top: true + bottom: true + left: true + right: true + } + + Rectangle { + anchors.fill: parent + color: Colors.mSurface + + Item { + id: backdropManager + + property string activeSource: BackgroundService.cachedBlurredPath + property bool showFirst: true + + anchors.fill: parent + onActiveSourceChanged: { + showFirst = !showFirst; + if (showFirst) + backImg1.source = activeSource; + else + backImg2.source = activeSource; + } + Component.onCompleted: { + if (showFirst) + backImg1.source = activeSource; + else + backImg2.source = activeSource; + } + + Image { + id: backImg1 + + anchors.fill: parent + fillMode: Image.PreserveAspectCrop + opacity: (backdropManager.showFirst && status === Image.Ready) ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: Style.animationSlow + } + + } + + } + + Image { + id: backImg2 + + anchors.fill: parent + fillMode: Image.PreserveAspectCrop + opacity: (!backdropManager.showFirst && status === Image.Ready) ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: Style.animationSlow + } + + } + + } + + } + + } + + } + + } + +} diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Bar.qml b/config/quickshell/.config/quickshell/Modules/Bar/Bar.qml index 41431a2..125674e 100644 --- a/config/quickshell/.config/quickshell/Modules/Bar/Bar.qml +++ b/config/quickshell/.config/quickshell/Modules/Bar/Bar.qml @@ -3,12 +3,11 @@ import QtQuick.Controls import QtQuick.Layouts import Quickshell import Quickshell.Io -import Quickshell.Services.UPower import Quickshell.Wayland +import qs.Components import qs.Constants import qs.Modules.Bar.Components -import qs.Modules.Bar.Misc -import qs.Modules.Misc +import qs.Modules.Bar.Modules import qs.Services Variants { @@ -22,6 +21,7 @@ Variants { screen: modelData WlrLayershell.namespace: "quickshell-bar" + WlrLayershell.layer: WlrLayer.Top color: Colors.transparent implicitHeight: Style.barHeight @@ -35,12 +35,11 @@ Variants { id: barBackground anchors.fill: parent - color: Niri.noFocus ? null : Colors.base gradient: Gradient { GradientStop { position: 0 - color: Qt.rgba(Colors.base.r, Colors.base.g, Colors.base.b, Niri.noFocus ? 0.8 : 1) + color: Qt.rgba(Colors.mSurface.r, Colors.mSurface.g, Colors.mSurface.b, BarService.focusMode ? 1 : 0.8) Behavior on color { ColorAnimation { @@ -54,7 +53,7 @@ Variants { GradientStop { position: 1 - color: Qt.rgba(Colors.base.r, Colors.base.g, Colors.base.b, Niri.noFocus ? 0 : 1) + color: Qt.rgba(Colors.mSurface.r, Colors.mSurface.g, Colors.mSurface.b, BarService.focusMode ? 1 : 0) Behavior on color { ColorAnimation { @@ -81,42 +80,22 @@ Variants { leftMargin: 5 } - SymbolButton { - symbol: Icons.distro - buttonColor: Colors.distroColor - onClicked: { - PanelService.getPanel("controlCenterPanel")?.toggle(this) + UIconButton { + textOverride: "󰣇" + fontFamily: Fonts.nerd + baseSize: parent.height - Style.marginXXS * 2 + iconSize: Style.fontNerd + colorFg: Colors.distro + onClicked: () => { + BarService.toggleLeft(); } - onRightClicked: { - Quickshell.execDetached(["rofi", "-show", "drun"]); + onRightClicked: () => { + BarService.toggleRight(); } } - SymbolButton { - symbol: SettingsService.wifiEnabled ? Icons.wifiOn : Icons.wifiOff - buttonColor: Colors.rosewater - onClicked: { - PanelService.getPanel("wifiPanel")?.toggle(this) - } - } - - SymbolButton { - symbol: BluetoothService.enabled ? Icons.bluetoothOn : Icons.bluetoothOff - buttonColor: Colors.blue - onClicked: { - PanelService.getPanel("bluetoothPanel")?.toggle(this) - } - onRightClicked: { - Quickshell.execDetached(["blueman-manager"]); - } - } - - - Item { - width: 5 - } - Separator { + implicitWidth: Style.marginXL } Workspace { @@ -124,28 +103,17 @@ Variants { } Separator { - } - - Item { - width: 10 + implicitWidth: Style.marginXL } CavaBar { } - Item { - width: 10 - } - Separator { - } - - Item { - width: 10 + implicitWidth: Style.marginXL } FocusedWindow { - maxWidth: 400 } } @@ -176,83 +144,97 @@ Variants { rightMargin: 5 } - RowLayout { - id: monitorsLayout - visible: !SettingsService.showLyricsBar + Loader { + sourceComponent: LyricsService.showLyricsBar ? lyricsComponent : monitorsComponent + + Component { + id: monitorsComponent + + RowLayout { + id: monitorsLayout + + height: rightLayout.height + spacing: Style.marginM + Component.onCompleted: { + SystemStatService.registerComponent("BarMonitors"); + } + + NetworkSpeed { + } + + Separator { + } + + RecordIndicator { + } + + Ip { + } + + CpuTemp { + } + + MemUsage { + } + + CpuUsage { + } + + Battery { + } + + Brightness { + screen: modelData + } + + Volume { + } + + } - height: parent.height - NetworkSpeed { } - Separator { + Component { + id: lyricsComponent + + LyricsBar { + } + } - Item { - width: 10 - } - - RecordIndicator { - } - - Ip { - } - - CpuTemp { - } - - MemUsage { - } - - CpuUsage { - } - - Battery { - } - - Brightness { - screen: modelData - } - - Volume { - } - } - - LyricsBar { - id: lyricsBar - visible: SettingsService.showLyricsBar - width: 600 - } - - Item { - width: 5 } Separator { } - Item { - width: 5 - } + RowLayout { + height: rightLayout.height + spacing: Style.marginS - TrayExpander { - screen: modelData - } - - SymbolButton { - symbol: Caffeine.isInhibited ? Icons.idleInhibitorActivated : Icons.idleInhibitorDeactivated - buttonColor: Caffeine.isInhibited ? Colors.peach : Colors.yellow - onClicked: { - Caffeine.manualToggle(); + TrayExpander { + screen: modelData + baseSize: rightLayout.height - Style.marginXXS * 2 } - } - - SymbolButton { - symbol: Icons.powerMenu - buttonColor: Colors.red - onClicked: { - Quickshell.execDetached(["wlogout"]); + UIconButton { + iconName: Caffeine.isInhibited ? "mug-off" : "mug" + colorFg: Caffeine.isInhibited ? Colors.mOrange : Colors.mYellow + baseSize: rightLayout.height - Style.marginXXS * 2 + alwaysHover: Caffeine.isInhibited + onClicked: () => { + Caffeine.manualToggle(); + } } + + UIconButton { + iconName: "power" + colorFg: Colors.mRed + baseSize: rightLayout.height - Style.marginXXS * 2 + onClicked: () => { + BarService.toggleRight(); + } + } + } } diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Components/CpuTemp.qml b/config/quickshell/.config/quickshell/Modules/Bar/Components/CpuTemp.qml deleted file mode 100644 index 63bb532..0000000 --- a/config/quickshell/.config/quickshell/Modules/Bar/Components/CpuTemp.qml +++ /dev/null @@ -1,28 +0,0 @@ -import QtQuick -import Quickshell.Io -import qs.Constants -import qs.Modules.Bar.Misc -import qs.Services - -MonitorItem { - symbol: SystemStatService.cpuTemp > 80 ? Icons.tempHigh : SystemStatService.cpuTemp > 50 ? Icons.tempMedium : Icons.tempLow - fillColor: Colors.yellow - critical: SystemStatService.cpuTemp > 80 - value: Math.round(SystemStatService.cpuTemp) - maxValue: 100 - textSuffix: "°C" - onClicked: { - if (action.running) { - action.signal(15); - return ; - } - action.exec(["wezterm", "start", "--", "btop"]); - } - - Process { - id: action - - running: false - } - -} diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Components/CpuUsage.qml b/config/quickshell/.config/quickshell/Modules/Bar/Components/CpuUsage.qml deleted file mode 100644 index f24d37c..0000000 --- a/config/quickshell/.config/quickshell/Modules/Bar/Components/CpuUsage.qml +++ /dev/null @@ -1,28 +0,0 @@ -import QtQuick -import Quickshell.Io -import qs.Constants -import qs.Modules.Bar.Misc -import qs.Services - -MonitorItem { - symbol: Icons.cpu - fillColor: Colors.teal - critical: SystemStatService.cpuUsage > 90 - value: Math.round(SystemStatService.cpuUsage) - maxValue: 100 - textSuffix: "%" - onClicked: { - if (action.running) { - action.signal(15); - return ; - } - action.exec(["wezterm", "start", "--", "btop"]); - } - - Process { - id: action - - running: false - } - -} diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Components/Separator.qml b/config/quickshell/.config/quickshell/Modules/Bar/Components/Separator.qml index 06571fe..3da8374 100644 --- a/config/quickshell/.config/quickshell/Modules/Bar/Components/Separator.qml +++ b/config/quickshell/.config/quickshell/Modules/Bar/Components/Separator.qml @@ -6,13 +6,14 @@ import qs.Constants Item { id: root - implicitHeight: parent.height + implicitHeight: Style.barHeight - Style.marginL * 2 + implicitWidth: Style.marginM Rectangle { anchors.centerIn: parent width: 1.5 - height: parent.height * 0.32 - color: Colors.text + height: parent.height + color: Colors.mOnSurface } } diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Misc/SystemTray.qml b/config/quickshell/.config/quickshell/Modules/Bar/Components/SystemTray.qml similarity index 95% rename from config/quickshell/.config/quickshell/Modules/Bar/Misc/SystemTray.qml rename to config/quickshell/.config/quickshell/Modules/Bar/Components/SystemTray.qml index 8174d65..83ab26d 100644 --- a/config/quickshell/.config/quickshell/Modules/Bar/Misc/SystemTray.qml +++ b/config/quickshell/.config/quickshell/Modules/Bar/Components/SystemTray.qml @@ -5,7 +5,7 @@ import QtQuick.Controls import Quickshell import Quickshell.Services.SystemTray import Quickshell.Widgets -import qs.Modules.Bar.Misc +import qs.Modules.Bar.Components import qs.Constants import qs.Services import qs.Utils @@ -107,7 +107,7 @@ Rectangle { trayMenu.item.menu = modelData.menu trayMenu.item.showAt(parent, menuX, menuY) } else { - Logger.log("Tray", "No menu available for", modelData.id, "or trayMenu not set") + Logger.d("Tray", "No menu available for", modelData.id, "or trayMenu not set") } } } @@ -150,7 +150,7 @@ Rectangle { Loader { id: trayMenu Component.onCompleted: { - setSource("../Misc/TrayMenu.qml", { + setSource("./TrayMenu.qml", { "screen": root.screen }) } diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Components/Time.qml b/config/quickshell/.config/quickshell/Modules/Bar/Components/Time.qml deleted file mode 100644 index b9ea3a6..0000000 --- a/config/quickshell/.config/quickshell/Modules/Bar/Components/Time.qml +++ /dev/null @@ -1,22 +0,0 @@ -import QtQuick -import qs.Constants -import qs.Services - -Text { - text: TimeService.time + " | " + TimeService.dateString - font.pointSize: Fonts.medium - font.family: Fonts.primary - color: Colors.primary - - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - acceptedButtons: Qt.LeftButton | Qt.RightButton - onClicked: (mouse) => { - if (mouse.button === Qt.LeftButton) - PanelService.getPanel("calendarPanel")?.toggle(this) - else if (mouse.button === Qt.RightButton) - PanelService.getPanel("notificationHistoryPanel")?.toggle(this) - } - } -} diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Misc/TrayMenu.qml b/config/quickshell/.config/quickshell/Modules/Bar/Components/TrayMenu.qml similarity index 93% rename from config/quickshell/.config/quickshell/Modules/Bar/Misc/TrayMenu.qml rename to config/quickshell/.config/quickshell/Modules/Bar/Components/TrayMenu.qml index adcec85..ea723b1 100644 --- a/config/quickshell/.config/quickshell/Modules/Bar/Misc/TrayMenu.qml +++ b/config/quickshell/.config/quickshell/Modules/Bar/Components/TrayMenu.qml @@ -5,7 +5,7 @@ import Quickshell import Quickshell.Widgets import qs.Constants import qs.Utils -import qs.Noctalia +import qs.Components PopupWindow { id: root @@ -86,8 +86,8 @@ PopupWindow { Rectangle { anchors.fill: parent - color: Colors.base - border.color: Colors.primary + color: Colors.mSurface + border.color: Colors.mPrimary border.width: 2 radius: Style.radiusM } @@ -126,7 +126,7 @@ PopupWindow { color: Colors.transparent property var subMenu: null - NDivider { + UDivider { anchors.centerIn: parent width: parent.width - (Style.marginM * 2) visible: modelData?.isSeparator ?? false @@ -134,7 +134,7 @@ PopupWindow { Rectangle { anchors.fill: parent - color: mouseArea.containsMouse ? Colors.primary : Colors.transparent + color: mouseArea.containsMouse ? Colors.mPrimary : Colors.transparent radius: Style.radiusS visible: !(modelData?.isSeparator ?? false) @@ -144,10 +144,10 @@ PopupWindow { anchors.rightMargin: Style.marginM spacing: Style.marginS - NText { + UText { id: text Layout.fillWidth: true - color: (modelData?.enabled ?? true) ? (mouseArea.containsMouse ? Color.mOnTertiary : Color.mOnSurface) : Color.mOnSurfaceVariant + color: (modelData?.enabled ?? true) ? (mouseArea.containsMouse ? Colors.mOnPrimary : Colors.mOnSurface) : Colors.mOnSurfaceVariant text: modelData?.text !== "" ? modelData?.text.replace(/[\n\r]+/g, ' ') : "..." pointSize: Style.fontSizeS verticalAlignment: Text.AlignVCenter @@ -162,12 +162,12 @@ PopupWindow { visible: (modelData?.icon ?? "") !== "" } - NIcon { - icon: modelData?.hasChildren ? "menu" : "" - pointSize: Style.fontSizeS + UIcon { + iconName: modelData?.hasChildren ? "menu" : "" + iconSize: Style.fontSizeS verticalAlignment: Text.AlignVCenter visible: modelData?.hasChildren ?? false - color: (mouseArea.containsMouse ? Color.mOnTertiary : Color.mOnSurface) + color: (mouseArea.containsMouse ? Colors.mOnPrimary : Colors.mOnSurface) } } diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Misc/SymbolButton.qml b/config/quickshell/.config/quickshell/Modules/Bar/Misc/SymbolButton.qml deleted file mode 100644 index 6ce7171..0000000 --- a/config/quickshell/.config/quickshell/Modules/Bar/Misc/SymbolButton.qml +++ /dev/null @@ -1,64 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import qs.Constants - -Item { - id: root - - required property string symbol - property color buttonColor: Colors.distroColor - readonly property alias hovered: mouseArea.containsMouse - property real iconSize: Fonts.icon - property real radius: Style.radiusS - property bool disabledHover: false - - signal clicked() - signal rightClicked() - - implicitHeight: parent.height - implicitWidth: parent.height - - MouseArea { - id: mouseArea - - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: !disabledHover - acceptedButtons: Qt.LeftButton | Qt.RightButton - onClicked: (mouse) => { - if (mouse.button === Qt.RightButton) - root.rightClicked(); - else if (mouse.button === Qt.LeftButton) - root.clicked(); - } - } - - Text { - anchors.fill: parent - text: symbol - font.family: Fonts.nerd - font.pointSize: iconSize - font.bold: false - color: buttonColor - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - } - - Rectangle { - anchors.fill: parent - color: parent.hovered ? buttonColor : Colors.transparent - opacity: 0.3 - radius: root.radius - - Behavior on color { - ColorAnimation { - duration: Style.animationNormal - easing.type: Easing.InOutCubic - } - - } - - } - -} diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Components/Battery.qml b/config/quickshell/.config/quickshell/Modules/Bar/Modules/Battery.qml similarity index 67% rename from config/quickshell/.config/quickshell/Modules/Bar/Components/Battery.qml rename to config/quickshell/.config/quickshell/Modules/Bar/Modules/Battery.qml index e38414a..e05d95e 100644 --- a/config/quickshell/.config/quickshell/Modules/Bar/Components/Battery.qml +++ b/config/quickshell/.config/quickshell/Modules/Bar/Modules/Battery.qml @@ -1,20 +1,19 @@ import QtQuick import Quickshell.Services.UPower +import qs.Components import qs.Constants -import qs.Modules.Bar.Misc -import qs.Services -MonitorItem { +UProgressExpand { readonly property var battery: UPower.displayDevice readonly property bool isReady: (battery && battery.ready && battery.isLaptopBattery && battery.isPresent) readonly property real percent: (isReady ? (battery.percentage * 100) : 0) readonly property bool charging: (isReady ? battery.state === UPowerDeviceState.Charging : false) property int lowBatteryThreshold: 20 - symbol: { - return charging ? Icons.charging : percent >= 80 ? Icons.battery100 : percent >= 60 ? Icons.battery75 : percent >= 40 ? Icons.battery50 : percent >= 20 ? Icons.battery25 : Icons.battery00; + iconName: { + return charging ? "battery-charging" : percent >= 80 ? "battery-4" : percent >= 60 ? "battery-3" : percent >= 40 ? "battery-2" : percent >= 20 ? "battery-1" : "battery-0"; } - fillColor: Colors.sapphire + fillColor: Colors.mSky value: percent critical: isReady && !charging && percent <= lowBatteryThreshold maxValue: 100 diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Components/Brightness.qml b/config/quickshell/.config/quickshell/Modules/Bar/Modules/Brightness.qml similarity index 87% rename from config/quickshell/.config/quickshell/Modules/Bar/Components/Brightness.qml rename to config/quickshell/.config/quickshell/Modules/Bar/Modules/Brightness.qml index 902a5bd..d8efeee 100644 --- a/config/quickshell/.config/quickshell/Modules/Bar/Components/Brightness.qml +++ b/config/quickshell/.config/quickshell/Modules/Bar/Modules/Brightness.qml @@ -1,18 +1,18 @@ import QtQuick import Quickshell +import qs.Components import qs.Constants -import qs.Modules.Bar.Misc import qs.Services -MonitorItem { +UProgressExpand { property ShellScreen screen: null function getMonitor() { return BrightnessService.getMonitorForScreen(screen) || null; } - symbol: Icons.brightness - fillColor: Colors.blue + iconName: "sun-filled" + fillColor: Colors.mBlue value: { const monitor = getMonitor(); return monitor ? Math.round(monitor.brightness * 100) : "N/A"; diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Components/CavaBar.qml b/config/quickshell/.config/quickshell/Modules/Bar/Modules/CavaBar.qml similarity index 90% rename from config/quickshell/.config/quickshell/Modules/Bar/Components/CavaBar.qml rename to config/quickshell/.config/quickshell/Modules/Bar/Modules/CavaBar.qml index 2d2525b..07dc620 100644 --- a/config/quickshell/.config/quickshell/Modules/Bar/Components/CavaBar.qml +++ b/config/quickshell/.config/quickshell/Modules/Bar/Modules/CavaBar.qml @@ -2,7 +2,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts import qs.Constants -import qs.Modules.Bar.Misc +import qs.Modules.Bar.Services import qs.Services import qs.Utils @@ -14,7 +14,7 @@ Item { property int mode: 0 implicitWidth: root.barWidth * CavaBarService.count + root.barSpacing * (CavaBarService.count - 1) - implicitHeight: parent.height - 10 + implicitHeight: Style.barHeight - Style.marginS * 2 RowLayout { anchors.fill: parent @@ -53,9 +53,7 @@ Item { acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton onClicked: (mouse) => { if (mouse.button === Qt.LeftButton) { - MusicManager.playPause(); - } else if (mouse.button === Qt.RightButton) { - SettingsService.showLyricsBar = !SettingsService.showLyricsBar; + MediaService.playPause(); } else if (mouse.button === Qt.MiddleButton) { mode = (mode + 1) % 3; if (mode === 0) { @@ -71,13 +69,15 @@ Item { CavaBarService.forceEnable = false; CavaBarService.forceDisable = true; } + } else if (mouse.button === Qt.RightButton) { + LyricsService.toggleLyricsBar(); } } onWheel: function(wheel) { if (wheel.angleDelta.y > 0) - MusicManager.previous(); + MediaService.previous(); else if (wheel.angleDelta.y < 0) - MusicManager.next(); + MediaService.next(); } } diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Modules/CpuTemp.qml b/config/quickshell/.config/quickshell/Modules/Bar/Modules/CpuTemp.qml new file mode 100644 index 0000000..3414132 --- /dev/null +++ b/config/quickshell/.config/quickshell/Modules/Bar/Modules/CpuTemp.qml @@ -0,0 +1,17 @@ +import QtQuick +import qs.Components +import qs.Constants +import qs.Modules.Bar.Services +import qs.Services + +UProgressExpand { + iconName: "temperature" + fillColor: Colors.mYellow + critical: SystemStatService.cpuTemp > 80 + value: Math.round(SystemStatService.cpuTemp) + maxValue: 100 + textSuffix: "°C" + onClicked: { + MonitorProcess.toggle(); + } +} diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Modules/CpuUsage.qml b/config/quickshell/.config/quickshell/Modules/Bar/Modules/CpuUsage.qml new file mode 100644 index 0000000..fdb3d29 --- /dev/null +++ b/config/quickshell/.config/quickshell/Modules/Bar/Modules/CpuUsage.qml @@ -0,0 +1,20 @@ +import QtQuick +import Quickshell.Io +import qs.Components +import qs.Constants +import qs.Modules.Bar.Services +import qs.Services + +UProgressExpand { + // Quickshell.execDetached(["wezterm", "start", "--", "btop"]); + + iconName: "cpu" + fillColor: Colors.mCyan + critical: SystemStatService.cpuUsage > 90 + value: Math.round(SystemStatService.cpuUsage) + maxValue: 100 + textSuffix: "%" + onClicked: { + MonitorProcess.toggle(); + } +} diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Components/FocusedWindow.qml b/config/quickshell/.config/quickshell/Modules/Bar/Modules/FocusedWindow.qml similarity index 87% rename from config/quickshell/.config/quickshell/Modules/Bar/Components/FocusedWindow.qml rename to config/quickshell/.config/quickshell/Modules/Bar/Modules/FocusedWindow.qml index 2dcfbcc..cae6c6a 100644 --- a/config/quickshell/.config/quickshell/Modules/Bar/Components/FocusedWindow.qml +++ b/config/quickshell/.config/quickshell/Modules/Bar/Modules/FocusedWindow.qml @@ -3,6 +3,7 @@ import QtQuick.Controls import QtQuick.Layouts import Quickshell import Quickshell.Widgets +import qs.Components import qs.Constants import qs.Services import qs.Utils @@ -10,7 +11,7 @@ import qs.Utils Item { id: root - property real maxWidth: 250 + property real maxWidth: 320 property string fallbackIcon: "application-x-executable" function getAppIcon(appId) { @@ -23,24 +24,25 @@ Item { return iconResult; } catch (iconError) { - Logger.warn("FocusedWindow", "Error getting icon from CompositorService: " + iconError); + Logger.w("FocusedWindow", "Error getting icon from CompositorService: " + iconError); } } return ThemeIcons.iconFromName(root.fallbackIcon); } catch (e) { - Logger.warn("FocusedWindow", "Error in getAppIcon:", e); + Logger.w("FocusedWindow", "Error in getAppIcon:", e); return ThemeIcons.iconFromName(root.fallbackIcon); } } - implicitHeight: parent.height + implicitWidth: layout.implicitWidth + implicitHeight: Math.max(windowIcon.implicitHeight, windowTitle.implicitHeight) RowLayout { id: layout anchors.fill: parent spacing: 10 - visible: Niri.focusedWindowId !== -1 + visible: Niri.hasFocusedWindow Item { // Layout.alignment: Qt.AlignVCenter @@ -79,24 +81,20 @@ Item { height: parent.height anchors.verticalCenter: parent.verticalCenter - Text { + UText { id: windowTitle text: Niri.focusedWindowTitle anchors.verticalCenter: parent.verticalCenter - font.pointSize: Fonts.medium - font.family: Fonts.primary - color: Colors.primary + color: Colors.mPrimary } - Text { + UText { text: Niri.focusedWindowTitle anchors.verticalCenter: parent.verticalCenter anchors.left: windowTitle.right anchors.leftMargin: titleContainer.scrollSpacing - font.pointSize: Fonts.medium - font.family: Fonts.primary - color: Colors.primary + color: Colors.mPrimary visible: titleContainer.shouldScroll } diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Components/Ip.qml b/config/quickshell/.config/quickshell/Modules/Bar/Modules/Ip.qml similarity index 87% rename from config/quickshell/.config/quickshell/Modules/Bar/Components/Ip.qml rename to config/quickshell/.config/quickshell/Modules/Bar/Modules/Ip.qml index 016e0db..33bae41 100644 --- a/config/quickshell/.config/quickshell/Modules/Bar/Components/Ip.qml +++ b/config/quickshell/.config/quickshell/Modules/Bar/Modules/Ip.qml @@ -3,15 +3,14 @@ import QtQuick.Layouts import Quickshell import qs.Constants import qs.Services -import qs.Modules.Bar.Misc +import qs.Components -MonitorItem { - symbol: Icons.ip - fillColor: Colors.peach +UProgressExpand { + iconName: "world" + fillColor: Colors.mOrange value: 100 maxValue: 100 textValue: displayText - symbolSize: 18 property int displayIndex: 0 readonly property list displayTexts: [IpService.countryCode, IpService.ip, IpService.alias] diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Components/LyricsBar.qml b/config/quickshell/.config/quickshell/Modules/Bar/Modules/LyricsBar.qml similarity index 51% rename from config/quickshell/.config/quickshell/Modules/Bar/Components/LyricsBar.qml rename to config/quickshell/.config/quickshell/Modules/Bar/Modules/LyricsBar.qml index def33a3..3893de6 100644 --- a/config/quickshell/.config/quickshell/Modules/Bar/Components/LyricsBar.qml +++ b/config/quickshell/.config/quickshell/Modules/Bar/Modules/LyricsBar.qml @@ -1,84 +1,76 @@ import QtQuick import QtQuick.Layouts import Quickshell +import qs.Components import qs.Constants -import qs.Noctalia import qs.Services Rectangle { - implicitHeight: parent.height radius: Style.radiusS - color: Colors.base - border.color: Colors.primary + color: Colors.mSurface + border.color: Colors.mPrimary border.width: Style.borderS - - Connections { - function onShowLyricsBarChanged() { - visible = SettingsService.showLyricsBar; - if (visible) - LyricsService.startSyncing(); - else - LyricsService.stopSyncing(); - } - - target: SettingsService + Component.onCompleted: { + LyricsService.startSyncing(); } + Component.onDestruction: { + LyricsService.stopSyncing(); + } + implicitHeight: Style.barHeight - Style.marginXS * 2 + implicitWidth: 600 RowLayout { anchors.fill: parent anchors.leftMargin: Style.marginM anchors.rightMargin: Style.marginM - spacing: Style.marginS + spacing: Style.marginXS Item { implicitWidth: parent.width - slowerButton.implicitWidth * 3 - parent.spacing * 3 - parent.anchors.leftMargin - parent.anchors.rightMargin Layout.fillHeight: true clip: true - NText { + UText { text: LyricsService.lyrics[LyricsService.currentIndex] || "" family: Fonts.sans - pointSize: Style.fontSizeS + pointSize: Style.fontSizeM maximumLineCount: 1 anchors.verticalCenter: parent.verticalCenter } } - NIconButton { + UIconButton { id: slowerButton - baseSize: 24 - colorBg: Color.transparent - colorBgHover: Colors.blue - colorFg: Colors.blue - icon: "rotate-2" + colorFg: Colors.mBlue + iconName: "rotate-2" + baseSize: parent.height - Style.marginXS * 2 + iconSize: Style.fontSizeM onClicked: { LyricsService.increaseOffset(); } } - NIconButton { + UIconButton { id: playPauseButton - baseSize: 24 - colorBg: Color.transparent - colorBgHover: Colors.yellow - colorFg: Colors.yellow - icon: "rotate-clockwise-2" + colorFg: Colors.mYellow + iconName: "rotate-clockwise-2" + baseSize: parent.height - Style.marginXS * 2 + iconSize: Style.fontSizeM onClicked: { LyricsService.decreaseOffset(); } } - NIconButton { + UIconButton { id: nextButton - baseSize: 24 - colorBg: Color.transparent - colorBgHover: Colors.green - colorFg: Colors.green - icon: "rotate-clockwise" + colorFg: Colors.mGreen + iconName: "rotate-clockwise" + baseSize: parent.height - Style.marginXS * 2 + iconSize: Style.fontSizeM onClicked: { LyricsService.resetOffset(); } diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Components/MemUsage.qml b/config/quickshell/.config/quickshell/Modules/Bar/Modules/MemUsage.qml similarity index 56% rename from config/quickshell/.config/quickshell/Modules/Bar/Components/MemUsage.qml rename to config/quickshell/.config/quickshell/Modules/Bar/Modules/MemUsage.qml index 6fd9459..40b9aca 100644 --- a/config/quickshell/.config/quickshell/Modules/Bar/Components/MemUsage.qml +++ b/config/quickshell/.config/quickshell/Modules/Bar/Modules/MemUsage.qml @@ -1,34 +1,23 @@ import QtQuick -import Quickshell.Io +import qs.Components import qs.Constants -import qs.Modules.Bar.Misc +import qs.Modules.Bar.Services import qs.Services -MonitorItem { +UProgressExpand { property bool _showPercent: false - symbol: Icons.memory - fillColor: Colors.green + iconName: "database" + fillColor: Colors.mGreen critical: SystemStatService.memPercent > 90 value: Math.round(SystemStatService.memPercent) maxValue: 100 textValue: _showPercent ? SystemStatService.memPercent : SystemStatService.memGb textSuffix: _showPercent ? "%" : "GB" onClicked: { - if (action.running) { - action.signal(15); - return ; - } - action.exec(["wezterm", "start", "--", "btop"]); + MonitorProcess.toggle(); } onRightClicked: { _showPercent = !_showPercent; } - - Process { - id: action - - running: false - } - } diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Components/NetworkSpeed.qml b/config/quickshell/.config/quickshell/Modules/Bar/Modules/NetworkSpeed.qml similarity index 61% rename from config/quickshell/.config/quickshell/Modules/Bar/Components/NetworkSpeed.qml rename to config/quickshell/.config/quickshell/Modules/Bar/Modules/NetworkSpeed.qml index 20e0442..61acd3e 100644 --- a/config/quickshell/.config/quickshell/Modules/Bar/Components/NetworkSpeed.qml +++ b/config/quickshell/.config/quickshell/Modules/Bar/Modules/NetworkSpeed.qml @@ -2,6 +2,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts import Quickshell +import qs.Components import qs.Constants import qs.Services @@ -16,35 +17,30 @@ Item { anchors.bottom: parent.bottom spacing: 5 - Text { - text: Icons.download - font.pointSize: Fonts.icon - 3 - color: Colors.primary - Layout.leftMargin: 10 + UIcon { + iconName: "arrow-big-down-line-filled" } Text { text: SystemStatService.formatSpeed(SystemStatService.rxSpeed) - font.pointSize: Fonts.medium + font.pointSize: Style.fontSizeM font.family: Fonts.primary - color: Colors.primary + color: Colors.mPrimary } Item { width: 5 } - Text { - text: Icons.upload - font.pointSize: Fonts.icon - 3 - color: Colors.primary + UIcon { + iconName: "arrow-big-up-line-filled" } Text { text: SystemStatService.formatSpeed(SystemStatService.txSpeed) - font.pointSize: Fonts.medium + font.pointSize: Style.fontSizeM font.family: Fonts.primary - color: Colors.primary + color: Colors.mPrimary } } diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Components/RecordIndicator.qml b/config/quickshell/.config/quickshell/Modules/Bar/Modules/RecordIndicator.qml similarity index 68% rename from config/quickshell/.config/quickshell/Modules/Bar/Components/RecordIndicator.qml rename to config/quickshell/.config/quickshell/Modules/Bar/Modules/RecordIndicator.qml index 639b0c6..6bb5b5d 100644 --- a/config/quickshell/.config/quickshell/Modules/Bar/Components/RecordIndicator.qml +++ b/config/quickshell/.config/quickshell/Modules/Bar/Modules/RecordIndicator.qml @@ -1,18 +1,20 @@ import QtQuick import QtQuick.Layouts import Quickshell +import qs.Components import qs.Constants import qs.Services Item { id: root - property color fillColor: Colors.red - property color _actualColor: Colors.red + property color fillColor: Colors.mRed + property color _actualColor: Colors.mRed + property bool _expand: mouseArea.containsMouse visible: RecordService.isRecording - implicitHeight: parent.height - implicitWidth: layout.width + 10 + implicitHeight: Math.max(symbolIcon.implicitHeight, textLabel.implicitHeight) + implicitWidth: height + expander.implicitWidth SequentialAnimation { id: blinkAnimation @@ -45,34 +47,36 @@ Item { anchors.bottom: parent.bottom spacing: 0 - Text { - text: Icons.record - font.pointSize: 18 - color: _actualColor + UIcon { + id: symbolIcon + + iconName: "capture-filled" + iconSize: Style.fontSizeM + 12 + color: root._actualColor + Layout.preferredWidth: parent.height + Layout.preferredHeight: parent.height } Item { id: expander - implicitWidth: mouseArea.containsMouse ? ipText.implicitWidth + 10 : 0 implicitHeight: parent.height + implicitWidth: root._expand ? textLabel.implicitWidth + 10 : 0 clip: true - Text { - id: ipText + UText { + id: textLabel - text: RecordService.recordingDisplay - font.pointSize: Fonts.medium - font.family: Fonts.primary - color: fillColor anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left anchors.leftMargin: 5 + text: RecordService.recordingDisplay || "Recording" + color: root.fillColor } Behavior on implicitWidth { NumberAnimation { - duration: Style.animationFast + duration: Style.animationNormal easing.type: Easing.InOutCubic } diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Modules/Time.qml b/config/quickshell/.config/quickshell/Modules/Bar/Modules/Time.qml new file mode 100644 index 0000000..784da9b --- /dev/null +++ b/config/quickshell/.config/quickshell/Modules/Bar/Modules/Time.qml @@ -0,0 +1,10 @@ +import QtQuick +import qs.Constants +import qs.Services + +Text { + text: TimeService.time + " | " + TimeService.dateString + font.pointSize: Style.fontSizeM + font.family: Fonts.primary + color: Colors.mPrimary +} diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Components/TrayExpander.qml b/config/quickshell/.config/quickshell/Modules/Bar/Modules/TrayExpander.qml similarity index 70% rename from config/quickshell/.config/quickshell/Modules/Bar/Components/TrayExpander.qml rename to config/quickshell/.config/quickshell/Modules/Bar/Modules/TrayExpander.qml index 206d456..9d03ee2 100644 --- a/config/quickshell/.config/quickshell/Modules/Bar/Components/TrayExpander.qml +++ b/config/quickshell/.config/quickshell/Modules/Bar/Modules/TrayExpander.qml @@ -2,16 +2,18 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts import Quickshell +import qs.Components import qs.Constants -import qs.Modules.Bar.Misc +import qs.Modules.Bar.Components Item { id: root property ShellScreen screen + property int baseSize: Style.baseWidgetSize - implicitHeight: parent.height - implicitWidth: layout.implicitWidth + implicitWidth: baseSize + trayContainer.implicitWidth + implicitHeight: layout.implicitHeight RowLayout { id: layout @@ -20,9 +22,10 @@ Item { anchors.bottom: parent.bottom spacing: 0 - SymbolButton { - symbol: Icons.tray - buttonColor: Colors.green + UIconButton { + iconName: "layout-sidebar-right-expand-filled" + colorFg: Colors.mGreen + baseSize: root.baseSize disabledHover: true } @@ -41,7 +44,7 @@ Item { Behavior on implicitWidth { NumberAnimation { - duration: 200 + duration: Style.animationNormal easing.type: Easing.InOutCubic } diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Components/Volume.qml b/config/quickshell/.config/quickshell/Modules/Bar/Modules/Volume.qml similarity index 62% rename from config/quickshell/.config/quickshell/Modules/Bar/Components/Volume.qml rename to config/quickshell/.config/quickshell/Modules/Bar/Modules/Volume.qml index fa3400c..cc1da9f 100644 --- a/config/quickshell/.config/quickshell/Modules/Bar/Components/Volume.qml +++ b/config/quickshell/.config/quickshell/Modules/Bar/Modules/Volume.qml @@ -1,12 +1,12 @@ import QtQuick import Quickshell +import qs.Components import qs.Constants -import qs.Modules.Bar.Misc import qs.Services -MonitorItem { - symbol: AudioService.muted ? Icons.volumeMuted : (AudioService.volume >= 0.5 ? Icons.volumeHigh : (AudioService.volume >= 0.2 ? Icons.volumeMedium : Icons.volumeLow)) - fillColor: Colors.lavender +UProgressExpand { + iconName: AudioService.muted ? "volume-3" : (AudioService.volume >= 0.5 ? "volume" : (AudioService.volume >= 0.2 ? "volume-2" : "volume-2")) + fillColor: Colors.mLavender value: Math.round(AudioService.volume * 100) maxValue: 100 textSuffix: "%" @@ -18,7 +18,7 @@ MonitorItem { AudioService.decreaseVolume(); } onClicked: { - AudioService.toggleMute(); + AudioService.setOutputMuted(!AudioService.muted); } onRightClicked: { Quickshell.execDetached(["sh", "-c", "pkill -x -n pwvucontrol || pwvucontrol"]); diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Components/Workspace.qml b/config/quickshell/.config/quickshell/Modules/Bar/Modules/Workspace.qml similarity index 53% rename from config/quickshell/.config/quickshell/Modules/Bar/Components/Workspace.qml rename to config/quickshell/.config/quickshell/Modules/Bar/Modules/Workspace.qml index d2b056f..e1be775 100644 --- a/config/quickshell/.config/quickshell/Modules/Bar/Components/Workspace.qml +++ b/config/quickshell/.config/quickshell/Modules/Bar/Modules/Workspace.qml @@ -1,4 +1,3 @@ -import Qt5Compat.GraphicalEffects import QtQuick import QtQuick.Controls import QtQuick.Layouts @@ -12,78 +11,59 @@ Item { id: root required property ShellScreen screen - property bool hovered: false property ListModel localWorkspaces property real masterProgress: 0 property bool effectsActive: false - property color effectColor: Colors.primary + property color effectColor: Colors.mPrimary property int horizontalPadding: 16 property int spacingBetweenPills: 8 - property bool isDestroying: false - - signal workspaceChanged(int workspaceId, color primaryColor) function triggerUnifiedWave() { - effectColor = Colors.primary; + effectColor = Colors.mPrimary; masterAnimation.restart(); } - function updateWorkspaceFocus() { - for (let i = 0; i < localWorkspaces.count; i++) { - const ws = localWorkspaces.get(i); - if (ws.isFocused === true) { - root.triggerUnifiedWave(); - root.workspaceChanged(ws.id, Colors.primary); - break; + function syncWorkspaces() { + let j = 0; + let focusChanged = false; + for (let i = 0; i < Niri.workspaces.count; i++) { + const ws = Niri.workspaces.get(i); + if (ws.output.toLowerCase() === screen.name.toLowerCase()) { + if (j < localWorkspaces.count) { + const existing = localWorkspaces.get(j); + if (ws.isFocused && !existing.isFocused) + focusChanged = true; + + localWorkspaces.setProperty(j, "id", ws.id); + localWorkspaces.setProperty(j, "idx", ws.idx); + localWorkspaces.setProperty(j, "isFocused", ws.isFocused); + localWorkspaces.setProperty(j, "isActive", ws.isActive); + localWorkspaces.setProperty(j, "isUrgent", ws.isUrgent); + localWorkspaces.setProperty(j, "isOccupied", ws.isOccupied); + } else { + localWorkspaces.append(ws); + if (ws.isFocused) + focusChanged = true; + + } + j++; } } + while (localWorkspaces.count > j)localWorkspaces.remove(localWorkspaces.count - 1) + if (focusChanged) + triggerUnifiedWave(); + } - implicitWidth: { - let total = 0; - for (let i = 0; i < localWorkspaces.count; i++) { - const ws = localWorkspaces.get(i); - if (ws.isFocused) - total += 44; - else if (ws.isActive) - total += 28; - else - total += 16; - } - total += Math.max(localWorkspaces.count - 1, 0) * spacingBetweenPills; - total += horizontalPadding * 2; - return total; - } - height: parent.height - Component.onCompleted: { - localWorkspaces.clear(); - for (let i = 0; i < WorkspaceManager.workspaces.count; i++) { - const ws = WorkspaceManager.workspaces.get(i); - if (ws.output.toLowerCase() === screen.name.toLowerCase()) - localWorkspaces.append(ws); - - } - workspaceRepeater.model = localWorkspaces; - updateWorkspaceFocus(); - } - Component.onDestruction: { - root.isDestroying = true; - } + implicitWidth: pillRow.implicitWidth + horizontalPadding * 2 + Component.onCompleted: syncWorkspaces() Connections { - function onWorkspacesChanged() { - localWorkspaces.clear(); - for (let i = 0; i < WorkspaceManager.workspaces.count; i++) { - const ws = WorkspaceManager.workspaces.get(i); - if (ws.output.toLowerCase() === screen.name.toLowerCase()) - localWorkspaces.append(ws); - - } - workspaceRepeater.model = localWorkspaces; - updateWorkspaceFocus(); + function onWorkspaceChanged() { + syncWorkspaces(); } - target: WorkspaceManager + target: Niri } SequentialAnimation { @@ -118,35 +98,11 @@ Item { } - Rectangle { - id: workspaceBackground - - width: parent.width - 15 - height: 26 - anchors.horizontalCenter: parent.horizontalCenter - anchors.verticalCenter: parent.verticalCenter - radius: 12 - color: Colors.transparent - layer.enabled: true - - layer.effect: DropShadow { - color: "black" - radius: 12 - samples: 24 - verticalOffset: 0 - horizontalOffset: 0 - opacity: 0.1 - } - - } - Row { id: pillRow spacing: spacingBetweenPills - anchors.verticalCenter: workspaceBackground.verticalCenter - width: root.width - horizontalPadding * 2 - x: horizontalPadding + anchors.centerIn: parent Repeater { id: workspaceRepeater @@ -172,23 +128,18 @@ Item { id: workspacePill anchors.fill: parent - radius: { - if (model.isFocused) - return 12; - else - return 6; - } + radius: height / 2 color: { if (model.isFocused) - return Colors.primary; + return Colors.mPrimary; if (model.isActive) - return Colors.overlay2; + return Colors.mOnSurfaceVariant; if (model.isUrgent) return Theme.error; - return Colors.surface2; + return Colors.mSurfaceVariant; } scale: model.isFocused ? 1 : 0.9 z: 0 @@ -199,28 +150,11 @@ Item { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: { - WorkspaceManager.switchToWorkspace(model.idx); + Niri.switchToWorkspace(model); } z: 20 hoverEnabled: true } - // Material 3-inspired smooth animation for width, height, scale, color, opacity, and radius - - Behavior on width { - NumberAnimation { - duration: 350 - easing.type: Easing.OutBack - } - - } - - Behavior on height { - NumberAnimation { - duration: 350 - easing.type: Easing.OutBack - } - - } Behavior on scale { NumberAnimation { @@ -246,14 +180,6 @@ Item { } - Behavior on radius { - NumberAnimation { - duration: 350 - easing.type: Easing.OutBack - } - - } - } Rectangle { @@ -262,8 +188,8 @@ Item { anchors.centerIn: workspacePillContainer width: workspacePillContainer.width + 18 * root.masterProgress height: workspacePillContainer.height + 18 * root.masterProgress - radius: width / 2 - color: "transparent" + radius: height / 2 + color: root.effectColor border.color: root.effectColor border.width: 2 + 6 * (1 - root.masterProgress) opacity: root.effectsActive && model.isFocused ? (1 - root.masterProgress) * 0.7 : 0 diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Misc/CavaBarService.qml b/config/quickshell/.config/quickshell/Modules/Bar/Services/CavaBarService.qml similarity index 93% rename from config/quickshell/.config/quickshell/Modules/Bar/Misc/CavaBarService.qml rename to config/quickshell/.config/quickshell/Modules/Bar/Services/CavaBarService.qml index c128cd4..2ed693e 100644 --- a/config/quickshell/.config/quickshell/Modules/Bar/Misc/CavaBarService.qml +++ b/config/quickshell/.config/quickshell/Modules/Bar/Services/CavaBarService.qml @@ -6,7 +6,7 @@ pragma Singleton Singleton { id: root - property int count: 6 + property int count: 7 property bool forceEnable: false property bool forceDisable: false property alias values: cavaProcess.values diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Services/MonitorProcess.qml b/config/quickshell/.config/quickshell/Modules/Bar/Services/MonitorProcess.qml new file mode 100644 index 0000000..2f0dff7 --- /dev/null +++ b/config/quickshell/.config/quickshell/Modules/Bar/Services/MonitorProcess.qml @@ -0,0 +1,24 @@ +import QtQuick +import Quickshell +import Quickshell.Io +pragma Singleton + +Singleton { + id: root + + function toggle() { + if (process.running) { + process.signal(15); + return ; + } + process.running = true; + } + + Process { + id: process + + running: false + command: ["wezterm", "start", "--", "btop"] + } + +} diff --git a/config/quickshell/.config/quickshell/Modules/Misc/Corner.qml b/config/quickshell/.config/quickshell/Modules/Misc/Corner.qml index 501615a..917a46a 100644 --- a/config/quickshell/.config/quickshell/Modules/Misc/Corner.qml +++ b/config/quickshell/.config/quickshell/Modules/Misc/Corner.qml @@ -11,7 +11,7 @@ Shape { property int concaveHeight: 60 * size property int offsetX: -20 property int offsetY: -20 - property color fillColor: Colors.base + property color fillColor: Colors.mSurface property int arcRadius: 20 * size property var modelData: null // Position flags derived from position string diff --git a/config/quickshell/.config/quickshell/Modules/Misc/Corners.qml b/config/quickshell/.config/quickshell/Modules/Misc/Corners.qml index 98c59a3..a134600 100644 --- a/config/quickshell/.config/quickshell/Modules/Misc/Corners.qml +++ b/config/quickshell/.config/quickshell/Modules/Misc/Corners.qml @@ -10,12 +10,11 @@ import qs.Services Scope { id: rootScope - property var shell property string namespace: "quickshell-corners" property int topMargin: 45 property int cornerHeight: 20 property real cornerSize: 1 - property real opacity: Niri.noFocus ? 0 : 1 + property real opacity: BarService.focusMode ? 1 : 0 Item { id: cornersRootItem @@ -26,7 +25,15 @@ Scope { model: Quickshell.screens Item { + id: screenItem + property var modelData + // property int leftOffset: BarService.leftOffset(modelData) + // property int rightOffset: BarService.rightOffset(modelData) + readonly property var leftBar: BarService.getLeftSidebar(modelData.name) + readonly property var rightBar: BarService.getRightSidebar(modelData.name) + property int leftOffset: leftBar?.isOpen ? leftBar.barWidth : 0 + property int rightOffset: rightBar?.isOpen ? rightBar.barWidth : 0 PanelWindow { id: fakeBar @@ -45,7 +52,7 @@ Scope { Rectangle { anchors.fill: parent - color: Colors.base + color: Colors.mSurface opacity: rootScope.opacity } @@ -59,9 +66,10 @@ Scope { color: "transparent" screen: modelData margins.top: topMargin + margins.left: screenItem.leftOffset WlrLayershell.exclusionMode: ExclusionMode.Ignore visible: true - WlrLayershell.layer: WlrLayer.Background + WlrLayershell.layer: WlrLayer.Top aboveWindows: false WlrLayershell.namespace: namespace implicitHeight: cornerHeight @@ -87,9 +95,10 @@ Scope { color: "transparent" screen: modelData margins.top: topMargin + margins.right: screenItem.rightOffset WlrLayershell.exclusionMode: ExclusionMode.Ignore visible: true - WlrLayershell.layer: WlrLayer.Background + WlrLayershell.layer: WlrLayer.Top aboveWindows: false WlrLayershell.namespace: namespace implicitHeight: cornerHeight @@ -114,9 +123,10 @@ Scope { anchors.left: true color: "transparent" screen: modelData + margins.left: screenItem.leftOffset WlrLayershell.exclusionMode: ExclusionMode.Ignore visible: true - WlrLayershell.layer: WlrLayer.Background + WlrLayershell.layer: WlrLayer.Top aboveWindows: false WlrLayershell.namespace: namespace implicitHeight: cornerHeight @@ -141,9 +151,10 @@ Scope { anchors.right: true color: "transparent" screen: modelData + margins.right: screenItem.rightOffset WlrLayershell.exclusionMode: ExclusionMode.Ignore visible: true - WlrLayershell.layer: WlrLayer.Background + WlrLayershell.layer: WlrLayer.Top aboveWindows: false WlrLayershell.namespace: namespace implicitHeight: cornerHeight @@ -161,6 +172,22 @@ Scope { } + Behavior on leftOffset { + NumberAnimation { + duration: Style.animationSlow + easing.type: Easing.InOutCubic + } + + } + + Behavior on rightOffset { + NumberAnimation { + duration: Style.animationSlow + easing.type: Easing.InOutCubic + } + + } + } } @@ -169,7 +196,7 @@ Scope { Behavior on opacity { NumberAnimation { - duration: 1000 + duration: Style.animationSlowest easing.type: Easing.InOutCubic } diff --git a/config/quickshell/.config/quickshell/Modules/Misc/Notification.qml b/config/quickshell/.config/quickshell/Modules/Misc/Notification.qml index 606bb59..8b6c945 100644 --- a/config/quickshell/.config/quickshell/Modules/Misc/Notification.qml +++ b/config/quickshell/.config/quickshell/Modules/Misc/Notification.qml @@ -1,439 +1,806 @@ import QtQuick +import QtQuick.Effects import QtQuick.Layouts import Quickshell import Quickshell.Services.Notifications import Quickshell.Wayland import Quickshell.Widgets -import qs.Constants -import qs.Noctalia -import qs.Services import qs.Utils +import qs.Services +import qs.Components +import qs.Constants // Simple notification popup - displays multiple notifications Variants { - // Force removal without animation as fallback - - // If no notification display activated in settings, then show them all - model: Quickshell.screens - - delegate: Loader { - id: root - - required property ShellScreen modelData - property real scaling: 1 - // Access the notification model from the service - property ListModel notificationModel: NotificationService.activeList - - // Loader is active when there are notifications - active: notificationModel.count > 0 || delayTimer.running - - // Keep loader active briefly after last notification to allow animations to complete - Timer { - id: delayTimer - - interval: Style.animationSlow + 200 // Animation duration + buffer - repeat: false - } - - // Start delay timer when last notification is removed - Connections { - function onCountChanged() { - if (notificationModel.count === 0 && root.active) - delayTimer.restart(); - - } - - target: notificationModel - } - - sourceComponent: PanelWindow { - readonly property string location: "top_right" - readonly property bool isTop: (location === "top") || (location.length >= 3 && location.substring(0, 3) === "top") - readonly property bool isBottom: (location === "bottom") || (location.length >= 6 && location.substring(0, 6) === "bottom") - readonly property bool isLeft: location.indexOf("_left") >= 0 - readonly property bool isRight: location.indexOf("_right") >= 0 - readonly property bool isCentered: (location === "top" || location === "bottom") - // Store connection for cleanup - property var animateConnection: null - - screen: modelData - WlrLayershell.namespace: "noctalia-notifications" - WlrLayershell.layer: WlrLayer.Overlay - color: Color.transparent - // Anchor selection based on location (window edges) - anchors.top: isTop - anchors.bottom: isBottom - anchors.left: isLeft - anchors.right: isRight - // Margins depending on bar position and chosen location - margins.top: Style.barHeight + Style.marginM - margins.bottom: 0 - margins.left: 0 - margins.right: Style.marginM - implicitWidth: 360 - implicitHeight: notificationStack.implicitHeight - WlrLayershell.exclusionMode: ExclusionMode.Ignore - // Connect to animation signal from service - Component.onCompleted: { - animateConnection = NotificationService.animateAndRemove.connect(function(notificationId) { - // Find the delegate by notification ID - var delegate = null; - if (notificationStack && notificationStack.children && notificationStack.children.length > 0) { - for (var i = 0; i < notificationStack.children.length; i++) { - var child = notificationStack.children[i]; - if (child && child.notificationId === notificationId) { - delegate = child; - break; - } - } - } - if (delegate && delegate.animateOut) - delegate.animateOut(); - else - NotificationService.dismissActiveNotification(notificationId); - }); - } - // Disconnect when destroyed to prevent memory leaks - Component.onDestruction: { - if (animateConnection) { - NotificationService.animateAndRemove.disconnect(animateConnection); - animateConnection = null; - } - } - - // Main notification container - ColumnLayout { - id: notificationStack - - // Anchor the stack inside the window based on chosen location - anchors.top: parent.isTop ? parent.top : undefined - anchors.bottom: parent.isBottom ? parent.bottom : undefined - anchors.left: parent.isLeft ? parent.left : undefined - anchors.right: parent.isRight ? parent.right : undefined - anchors.horizontalCenter: parent.isCentered ? parent.horizontalCenter : undefined - spacing: Style.marginS - width: 360 - visible: true - - // Multiple notifications display - Repeater { - model: notificationModel - - delegate: Rectangle { - id: card - - // Store the notification ID and data for reference - property string notificationId: model.id - property var notificationData: model - // Animation properties - property real scaleValue: 0.8 - property real opacityValue: 0 - property bool isRemoving: false - - // Animate out when being removed - function animateOut() { - if (isRemoving) - return ; - - // Prevent multiple animations - isRemoving = true; - scaleValue = 0.8; - opacityValue = 0; - } - - Layout.preferredWidth: 360 - Layout.preferredHeight: notificationLayout.implicitHeight + (Style.marginL * 2) - Layout.maximumHeight: Layout.preferredHeight - radius: Style.radiusL - border.color: Colors.overlay0 - border.width: Math.max(1, Style.borderS) - color: Color.mSurface - // Scale and fade-in animation - scale: scaleValue - opacity: opacityValue - // Animate in when the item is created - Component.onCompleted: { - scaleValue = 1; - opacityValue = 1; - } - // Check if this notification is being removed - onIsRemovingChanged: { - if (isRemoving) - removalTimer.start(); - - } - - // Optimized progress bar container - Rectangle { - id: progressBarContainer - - // Pre-calculate available width for the progress bar - readonly property real availableWidth: parent.width - (2 * parent.radius) - - anchors.top: parent.top - anchors.left: parent.left - anchors.right: parent.right - height: 2 - color: Color.transparent - - // Actual progress bar - centered and symmetric - Rectangle { - id: progressBar - - height: parent.height - // Center the bar and make it shrink symmetrically - x: parent.parent.radius + (parent.availableWidth * (1 - model.progress)) / 2 - width: parent.availableWidth * model.progress - color: { - if (model.urgency === NotificationUrgency.Critical || model.urgency === 2) - return Colors.red; - else if (model.urgency === NotificationUrgency.Low || model.urgency === 0) - return Colors.green; - else - return Colors.primary; - } - antialiasing: true - - // Smooth progress animation - Behavior on width { - enabled: !card.isRemoving // Disable during removal animation - - NumberAnimation { - duration: 100 // Quick but smooth - easing.type: Easing.Linear - } - - } - - Behavior on x { - enabled: !card.isRemoving - - NumberAnimation { - duration: 100 - easing.type: Easing.Linear - } - - } - - } - - } - - // Right-click to dismiss - MouseArea { - anchors.fill: parent - acceptedButtons: Qt.RightButton - onClicked: (mouse) => { - if (mouse.button === Qt.RightButton) - animateOut(); - - } - } - - // Timer for delayed removal after animation - Timer { - id: removalTimer - - interval: Style.animationSlow - repeat: false - onTriggered: { - NotificationService.dismissActiveNotification(notificationId); - } - } - - ColumnLayout { - id: notificationLayout - - anchors.fill: parent - anchors.margins: Style.marginM - anchors.rightMargin: (Style.marginM + 32) // Leave space for close button - spacing: Style.marginM - - // Main content section - RowLayout { - Layout.fillWidth: true - spacing: Style.marginM - - ColumnLayout { - // For real-time notification always show the original image - // as the cached version is most likely still processing. - NImageCircled { - Layout.preferredWidth: 40 - Layout.preferredHeight: 40 - Layout.alignment: Qt.AlignTop - Layout.topMargin: 30 - imagePath: model.originalImage || "" - borderColor: Color.transparent - borderWidth: 0 - fallbackIcon: "bell" - fallbackIconSize: 24 - } - - Item { - Layout.fillHeight: true - } - - } - - // Text content - ColumnLayout { - Layout.fillWidth: true - spacing: Style.marginS - - // Header section with app name and timestamp - RowLayout { - Layout.fillWidth: true - spacing: Style.marginS - - Rectangle { - Layout.preferredWidth: 6 - Layout.preferredHeight: 6 - radius: Style.radiusXS - color: { - if (model.urgency === NotificationUrgency.Critical || model.urgency === 2) - return Color.mError; - else if (model.urgency === NotificationUrgency.Low || model.urgency === 0) - return Color.mOnSurface; - else - return Color.mPrimary; - } - Layout.alignment: Qt.AlignVCenter - } - - NText { - text: `${model.appName || I18n.tr("system.unknown-app")} · ${Time.formatRelativeTime(model.timestamp)}` - color: Color.mSecondary - pointSize: Style.fontSizeXS - family: Fonts.sans - } - - Item { - Layout.fillWidth: true - } - - } - - NText { - text: model.summary || I18n.tr("general.no-summary") - pointSize: Style.fontSizeL - font.weight: Style.fontWeightMedium - color: Color.mOnSurface - textFormat: Text.PlainText - wrapMode: Text.WrapAtWordBoundaryOrAnywhere - Layout.fillWidth: true - maximumLineCount: 3 - elide: Text.ElideRight - family: Fonts.sans - visible: text.length > 0 - } - - NText { - text: model.body || "" - pointSize: Style.fontSizeM - color: Color.mOnSurface - textFormat: Text.PlainText - wrapMode: Text.WrapAtWordBoundaryOrAnywhere - Layout.fillWidth: true - maximumLineCount: 5 - elide: Text.ElideRight - family: Fonts.sans - visible: text.length > 0 - } - - // Notification actions - Flow { - // Store the notification ID for access in button delegates - property string parentNotificationId: notificationId - // Parse actions from JSON string - property var parsedActions: { - try { - return model.actionsJson ? JSON.parse(model.actionsJson) : []; - } catch (e) { - return []; - } - } - - Layout.fillWidth: true - spacing: Style.marginS - Layout.topMargin: Style.marginM - flow: Flow.LeftToRight - layoutDirection: Qt.LeftToRight - visible: parsedActions.length > 0 - - Repeater { - model: parent.parsedActions - - delegate: NButton { - property var actionData: modelData - - text: { - var actionText = actionData.text || "Open"; - // If text contains comma, take the part after the comma (the display text) - if (actionText.includes(",")) - return actionText.split(",")[1] || actionText; - - return actionText; - } - fontFamily: Fonts.sans - fontSize: Style.fontSizeS - backgroundColor: Color.mPrimary - textColor: hovered ? Color.mOnTertiary : Color.mOnPrimary - hoverColor: Color.mTertiary - outlined: false - implicitHeight: 24 - onClicked: { - NotificationService.invokeAction(parent.parentNotificationId, actionData.identifier); - } - } - - } - - } - - } - - } - - } - - // Close button positioned absolutely - NIconButton { - icon: "close" - baseSize: Style.baseWidgetSize * 0.6 - anchors.top: parent.top - anchors.topMargin: Style.marginM - anchors.right: parent.right - anchors.rightMargin: Style.marginM - onClicked: { - animateOut(); - } - } - - // Animation behaviors - Behavior on scale { - NumberAnimation { - duration: Style.animationNormal - easing.type: Easing.OutExpo - } - - } - - Behavior on opacity { - NumberAnimation { - duration: Style.animationNormal - easing.type: Easing.OutQuad - } - - } - - } - - } - - } - - } - + // If no notification display activated in settings, then show them all + model: Quickshell.screens + + + delegate: Loader { + id: root + + // Migrate all settings value to constants + readonly property bool overlayLayer: true + readonly property string notificationPosition: "top_right" + readonly property bool barFloating: true + readonly property string barPosition: "top" + readonly property int barMarginVertical: 4 + readonly property int barMarginHorizontal: 4 + readonly property bool notificationIsFramed: false + readonly property int frameThickness: 8 + readonly property bool notificationCompact: false + readonly property double uiScaleRatio: 1 + readonly property bool animationsDisabled: false + readonly property bool clearDismissed: true + readonly property real backgroundOpacity: 1.0 + + required property ShellScreen modelData + + property ListModel notificationModel: NotificationService.activeList + + // Always create window (but with 0x0 dimensions when no notifications) + active: notificationModel.count > 0 || delayTimer.running + + // Keep loader active briefly after last notification to allow animations to complete + Timer { + id: delayTimer + interval: Style.animationSlow + 200 + repeat: false } + Connections { + target: notificationModel + function onCountChanged() { + if (notificationModel.count === 0 && root.active) { + delayTimer.restart(); + } + } + } + + sourceComponent: PanelWindow { + id: notifWindow + screen: modelData + + WlrLayershell.namespace: "noctalia-notifications-" + (screen?.name || "unknown") + WlrLayershell.layer: (root.overlayLayer) ? WlrLayer.Overlay : WlrLayer.Top + WlrLayershell.exclusionMode: ExclusionMode.Ignore + + color: "transparent" + + // Make shadow area click-through, only notification content is clickable + mask: Region { + x: 0 + y: 0 + width: notifWindow.width + height: notifWindow.height + intersection: Intersection.Xor + + Region { + // The clickable content area is inset by shadowPadding from all edges + x: notifWindow.shadowPadding + y: notifWindow.shadowPadding + width: notifWindow.notifWidth + height: Math.max(0, notifWindow.height - notifWindow.shadowPadding * 2) + intersection: Intersection.Subtract + } + } + + // Parse location setting + readonly property string location: root.notificationPosition || "top_right" + readonly property bool isTop: location.startsWith("top") + readonly property bool isBottom: location.startsWith("bottom") + readonly property bool isLeft: location.endsWith("_left") + readonly property bool isRight: location.endsWith("_right") + readonly property bool isCentered: location === "top" || location === "bottom" + + readonly property string barPos: root.barPosition + readonly property bool isFloating: root.barFloating + readonly property real barHeight: Style.barHeight + + readonly property bool isFramed: root.notificationIsFramed + readonly property real frameThickness: root.frameThickness + + readonly property bool isCompact: root.notificationCompact + readonly property int notifWidth: Math.round((isCompact ? 320 : 440) * root.uiScaleRatio) + readonly property int shadowPadding: Style.shadowBlurMax + Style.marginL + + // Calculate bar and frame offsets for each edge separately + readonly property int barOffsetTop: { + if (barPos !== "top") + return isFramed ? frameThickness : 0; + const floatMarginV = isFloating ? Math.ceil(root.barMarginVertical) : 0; + return barHeight + floatMarginV; + } + + readonly property int barOffsetBottom: { + if (barPos !== "bottom") + return isFramed ? frameThickness : 0; + const floatMarginV = isFloating ? Math.ceil(root.barMarginVertical) : 0; + return barHeight + floatMarginV; + } + + readonly property int barOffsetLeft: { + if (barPos !== "left") + return isFramed ? frameThickness : 0; + const floatMarginH = isFloating ? Math.ceil(root.barMarginHorizontal) : 0; + return barHeight + floatMarginH; + } + + readonly property int barOffsetRight: { + if (barPos !== "right") + return isFramed ? frameThickness : 0; + const floatMarginH = isFloating ? Math.ceil(root.barMarginHorizontal) : 0; + return barHeight + floatMarginH; + } + + // Anchoring + anchors.top: isTop + anchors.bottom: isBottom + anchors.left: isLeft + anchors.right: isRight + + // Margins for PanelWindow - only apply bar offset for the specific edge where the bar is + margins.top: isTop ? barOffsetTop - shadowPadding + Style.marginM : 0 + margins.bottom: isBottom ? barOffsetBottom - shadowPadding : 0 + margins.left: isLeft ? barOffsetLeft - shadowPadding + Style.marginM : 0 + margins.right: isRight ? barOffsetRight - shadowPadding + Style.marginM : 0 + + implicitWidth: notifWidth + shadowPadding * 2 + implicitHeight: notificationStack.implicitHeight + Style.marginL + + property var animateConnection: null + + Component.onCompleted: { + animateConnection = function (notificationId) { + var delegate = null; + if (notificationRepeater) { + for (var i = 0; i < notificationRepeater.count; i++) { + var item = notificationRepeater.itemAt(i); + if (item?.notificationId === notificationId) { + delegate = item; + break; + } + } + } + + try { + if (delegate && typeof delegate.animateOut === "function" && !delegate.isRemoving) { + delegate.animateOut(); + } + } catch (e) { + // Service fallback if delegate is already invalid + NotificationService.dismissActiveNotification(notificationId); + } + }; + + NotificationService.animateAndRemove.connect(animateConnection); + } + + Component.onDestruction: { + if (animateConnection) { + NotificationService.animateAndRemove.disconnect(animateConnection); + animateConnection = null; + } + } + + ColumnLayout { + id: notificationStack + + anchors { + top: parent.isTop ? parent.top : undefined + bottom: parent.isBottom ? parent.bottom : undefined + left: parent.isLeft ? parent.left : undefined + right: parent.isRight ? parent.right : undefined + horizontalCenter: parent.isCentered ? parent.horizontalCenter : undefined + } + + spacing: -notifWindow.shadowPadding * 2 + Style.marginM + + Behavior on implicitHeight { + enabled: !root.animationDisabled + SpringAnimation { + spring: 2.0 + damping: 0.4 + epsilon: 0.01 + mass: 0.8 + } + } + + Repeater { + id: notificationRepeater + model: notificationModel + + delegate: Item { + id: card + + property string notificationId: model.id + property var notificationData: model + property bool isHovered: false + property bool isRemoving: false + + readonly property int animationDelay: index * 100 + readonly property int slideDistance: 300 + + Layout.preferredWidth: notifWidth + notifWindow.shadowPadding * 2 + Layout.preferredHeight: (notifWindow.isCompact ? compactContent.implicitHeight : notificationContent.implicitHeight) + Style.marginM * 2 + notifWindow.shadowPadding * 2 + Layout.maximumHeight: Layout.preferredHeight + + // Animation properties + property real scaleValue: 0.8 + property real opacityValue: 0.0 + property real slideOffset: 0 + property real swipeOffset: 0 + property real swipeOffsetY: 0 + property real pressGlobalX: 0 + property real pressGlobalY: 0 + property bool isSwiping: false + property bool suppressClick: false + readonly property bool useVerticalSwipe: notifWindow.location === "bottom" || notifWindow.location === "top" + readonly property real swipeStartThreshold: Math.round(18 * root.uiScaleRatio) + readonly property real swipeDismissThreshold: Math.max(110, cardBackground.width * 0.32) + readonly property real verticalSwipeDismissThreshold: Math.max(70, cardBackground.height * 0.35) + + scale: scaleValue + opacity: opacityValue + transform: Translate { + x: card.swipeOffset + y: card.slideOffset + card.swipeOffsetY + } + + readonly property real slideInOffset: notifWindow.isTop ? -slideDistance : slideDistance + readonly property real slideOutOffset: slideInOffset + + function clampSwipeDelta(deltaX) { + if (notifWindow.isRight) + return Math.max(0, deltaX); + if (notifWindow.isLeft) + return Math.min(0, deltaX); + return deltaX; + } + + function clampVerticalSwipeDelta(deltaY) { + if (notifWindow.isBottom) + return Math.max(0, deltaY); + if (notifWindow.isTop) + return Math.min(0, deltaY); + return deltaY; + } + + // Animation setup + function triggerEntryAnimation() { + animInDelayTimer.stop(); + removalTimer.stop(); + resumeTimer.stop(); + isRemoving = false; + isHovered = false; + isSwiping = false; + swipeOffset = 0; + swipeOffsetY = 0; + if (root.animationDisabled) { + slideOffset = 0; + scaleValue = 1.0; + opacityValue = 1.0; + return; + } + + slideOffset = slideInOffset; + scaleValue = 0.8; + opacityValue = 0.0; + animInDelayTimer.interval = animationDelay; + animInDelayTimer.start(); + } + + Component.onCompleted: triggerEntryAnimation() + + onNotificationIdChanged: triggerEntryAnimation() + + Timer { + id: animInDelayTimer + interval: 0 + repeat: false + onTriggered: { + if (card.isRemoving) + return; + slideOffset = 0; + scaleValue = 1.0; + opacityValue = 1.0; + } + } + + function animateOut() { + if (isRemoving) + return; + animInDelayTimer.stop(); + resumeTimer.stop(); + isRemoving = true; + isSwiping = false; + swipeOffset = 0; + swipeOffsetY = 0; + if (!root.animationDisabled) { + slideOffset = slideOutOffset; + scaleValue = 0.8; + opacityValue = 0.0; + } + } + + function dismissBySwipe() { + if (isRemoving) + return; + animInDelayTimer.stop(); + resumeTimer.stop(); + isRemoving = true; + isSwiping = false; + if (!root.animationDisabled) { + if (useVerticalSwipe) { + swipeOffset = 0; + swipeOffsetY = swipeOffsetY >= 0 ? cardBackground.height + Style.marginXL : -cardBackground.height - Style.marginXL; + } else { + swipeOffset = swipeOffset >= 0 ? cardBackground.width + Style.marginXL : -cardBackground.width - Style.marginXL; + swipeOffsetY = 0; + } + scaleValue = 0.8; + opacityValue = 0.0; + } else { + swipeOffset = 0; + swipeOffsetY = 0; + } + } + + function runAction(actionId, isDismissed) { + if (!isDismissed) { + NotificationService.focusSenderWindow(model.appName); + NotificationService.invokeActionAndSuppressClose(notificationId, actionId); + } else if (root.clearDismissed) { + NotificationService.removeFromHistory(notificationId); + } + card.animateOut(); + } + + Timer { + id: removalTimer + interval: Style.animationSlow + repeat: false + onTriggered: { + NotificationService.dismissActiveNotification(notificationId); + } + } + + onIsRemovingChanged: { + if (isRemoving) { + removalTimer.start(); + } + } + + Behavior on scale { + enabled: !root.animationDisabled + SpringAnimation { + spring: 3 + damping: 0.4 + epsilon: 0.01 + mass: 0.8 + } + } + + Behavior on opacity { + enabled: !root.animationDisabled + NumberAnimation { + duration: Style.animationNormal + easing.type: Easing.OutCubic + } + } + + Behavior on slideOffset { + enabled: !root.animationDisabled + SpringAnimation { + spring: 2.5 + damping: 0.3 + epsilon: 0.01 + mass: 0.6 + } + } + + Behavior on swipeOffset { + enabled: !root.animationDisabled && !card.isSwiping + NumberAnimation { + duration: Style.animationFast + easing.type: Easing.OutCubic + } + } + + Behavior on swipeOffsetY { + enabled: !root.animationDisabled && !card.isSwiping + NumberAnimation { + duration: Style.animationFast + easing.type: Easing.OutCubic + } + } + + // Sub item with the right dimensions, really usefull for the + // HoverHandler: card items are overlapping because of the + // negative spacing of notificationStack. + Item { + id: displayedCard + + anchors.fill: parent + anchors.margins: notifWindow.shadowPadding + + HoverHandler { + onHoveredChanged: { + isHovered = hovered; + if (isHovered) { + resumeTimer.stop(); + NotificationService.pauseTimeout(notificationId); + } else { + resumeTimer.start(); + } + } + } + + Timer { + id: resumeTimer + interval: 50 + repeat: false + onTriggered: { + if (!isHovered) { + NotificationService.resumeTimeout(notificationId); + } + } + } + + // Right-click to dismiss + MouseArea { + id: cardDragArea + anchors.fill: cardBackground + acceptedButtons: Qt.LeftButton | Qt.RightButton + hoverEnabled: true + onPressed: mouse => { + if (mouse.button === Qt.LeftButton) { + const globalPoint = cardDragArea.mapToGlobal(mouse.x, mouse.y); + card.pressGlobalX = globalPoint.x; + card.pressGlobalY = globalPoint.y; + card.isSwiping = false; + card.suppressClick = false; + } + } + onPositionChanged: mouse => { + if (!(mouse.buttons & Qt.LeftButton) || card.isRemoving) + return; + const globalPoint = cardDragArea.mapToGlobal(mouse.x, mouse.y); + const rawDeltaX = globalPoint.x - card.pressGlobalX; + const rawDeltaY = globalPoint.y - card.pressGlobalY; + const deltaX = card.clampSwipeDelta(rawDeltaX); + const deltaY = card.clampVerticalSwipeDelta(rawDeltaY); + if (!card.isSwiping) { + if (card.useVerticalSwipe) { + if (Math.abs(deltaY) < card.swipeStartThreshold) + return; + card.isSwiping = true; + } else { + if (Math.abs(deltaX) < card.swipeStartThreshold) + return; + card.isSwiping = true; + } + } + if (card.useVerticalSwipe) { + card.swipeOffset = 0; + card.swipeOffsetY = deltaY; + } else { + card.swipeOffset = deltaX; + card.swipeOffsetY = 0; + } + } + onReleased: mouse => { + if (mouse.button === Qt.RightButton) { + card.animateOut(); + if (root.clearDismissed) { + NotificationService.removeFromHistory(notificationId); + } + return; + } + + if (mouse.button !== Qt.LeftButton) + return; + + if (card.isSwiping) { + const dismissDistance = card.useVerticalSwipe ? Math.abs(card.swipeOffsetY) : Math.abs(card.swipeOffset); + const threshold = card.useVerticalSwipe ? card.verticalSwipeDismissThreshold : card.swipeDismissThreshold; + if (dismissDistance >= threshold) { + card.dismissBySwipe(); + if (root.clearDismissed) { + NotificationService.removeFromHistory(notificationId); + } + } else { + card.swipeOffset = 0; + card.swipeOffsetY = 0; + } + card.suppressClick = true; + card.isSwiping = false; + return; + } + + if (card.suppressClick) + return; + + var actions = model.actionsJson ? JSON.parse(model.actionsJson) : []; + var hasDefault = actions.some(function (a) { + return a.identifier === "default"; + }); + if (hasDefault) { + card.runAction("default", false); + } else { + NotificationService.focusSenderWindow(model.appName); + card.animateOut(); + } + } + onCanceled: { + card.isSwiping = false; + card.swipeOffset = 0; + card.swipeOffsetY = 0; + } + } + + // Background with border + Rectangle { + id: cardBackground + anchors.fill: parent + radius: Style.radiusL + border.color: Qt.alpha(Colors.mOutline, root.backgroundOpacity || 1.0) + border.width: Style.borderS + color: Qt.alpha(Colors.mSurface, root.backgroundOpacity || 1.0) + + // Progress bar + Rectangle { + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + height: 2 + color: "transparent" + + Rectangle { + id: progressBar + readonly property real progressWidth: cardBackground.width - (2 * cardBackground.radius) + height: parent.height + x: cardBackground.radius + (progressWidth * (1 - model.progress)) / 2 + width: progressWidth * model.progress + + color: { + var baseColor = model.urgency === 2 ? Colors.mError : model.urgency === 0 ? Colors.mOnSurface : Colors.mPrimary; + return Qt.alpha(baseColor, root.backgroundOpacity || 1.0); + } + + antialiasing: true + + Behavior on width { + enabled: !card.isRemoving + NumberAnimation { + duration: 100 + easing.type: Easing.Linear + } + } + + Behavior on x { + enabled: !card.isRemoving + NumberAnimation { + duration: 100 + easing.type: Easing.Linear + } + } + } + } + } + + UDropShadow { + anchors.fill: cardBackground + source: cardBackground + autoPaddingEnabled: true + } + + // Content + ColumnLayout { + id: notificationContent + visible: !notifWindow.isCompact + anchors.fill: cardBackground + anchors.margins: Style.marginM + spacing: Style.marginM + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginL + Layout.leftMargin: Style.marginM + Layout.rightMargin: Style.marginM + Layout.topMargin: Style.marginM + Layout.bottomMargin: Style.marginM + + UImageRounded { + Layout.preferredWidth: Math.round(40 * root.uiScaleRatio) + Layout.preferredHeight: Math.round(40 * root.uiScaleRatio) + Layout.alignment: Qt.AlignVCenter + radius: Math.min(Style.radiusL, Layout.preferredWidth / 2) + imagePath: model.originalImage || "" + borderColor: "transparent" + borderWidth: 0 + fallbackIcon: "bell" + fallbackIconSize: 24 + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginS + + // Header with urgency indicator + RowLayout { + Layout.fillWidth: true + spacing: Style.marginS + + Rectangle { + Layout.preferredWidth: 6 + Layout.preferredHeight: 6 + Layout.alignment: Qt.AlignVCenter + radius: Style.radiusXS + color: model.urgency === 2 ? Colors.mError : model.urgency === 0 ? Colors.mOnSurface : Colors.mPrimary + } + + UText { + text: model.appName || "Unknown App" + pointSize: Style.fontSizeXS + font.weight: Style.fontWeightBold + color: Colors.mPrimary + } + + UText { + textFormat: Text.PlainText + text: " " + Time.formatRelativeTime(model.timestamp) + pointSize: Style.fontSizeXXS + color: Colors.mOnSurfaceVariant + Layout.alignment: Qt.AlignBottom + } + + Item { + Layout.fillWidth: true + } + } + + UText { + text: model.summary || "No summary" + pointSize: Style.fontSizeM + font.weight: Style.fontWeightMedium + color: Colors.mOnSurface + textFormat: Text.StyledText + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + maximumLineCount: 3 + elide: Text.ElideRight + visible: text.length > 0 + Layout.fillWidth: true + Layout.rightMargin: Style.marginM + } + + UText { + text: model.body || "" + pointSize: Style.fontSizeM + color: Colors.mOnSurface + textFormat: Text.StyledText + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + + maximumLineCount: 5 + elide: Text.ElideRight + visible: text.length > 0 + Layout.fillWidth: true + Layout.rightMargin: Style.marginXL + } + + // Actions + Flow { + Layout.fillWidth: true + spacing: Style.marginS + Layout.topMargin: Style.marginM + flow: Flow.LeftToRight + + property string parentNotificationId: notificationId + property var parsedActions: { + try { + return model.actionsJson ? JSON.parse(model.actionsJson) : []; + } catch (e) { + return []; + } + } + visible: parsedActions.length > 0 + + Repeater { + model: parent.parsedActions + + delegate: UButton { + property var actionData: modelData + + text: { + var actionText = actionData.text || "Open"; + if (actionText.includes(",")) { + return actionText.split(",")[1] || actionText; + } + return actionText; + } + fontSize: Style.fontSizeS + backgroundColor: Colors.mPrimary + textColor: hovered ? Colors.mOnHover : Colors.mOnPrimary + hoverColor: Colors.mHover + outlined: false + implicitHeight: 24 + onClicked: { + card.runAction(actionData.identifier, false); + } + } + } + } + } + } + } + + // Close button + UIconButton { + visible: !notifWindow.isCompact + iconName: "close" + baseSize: Style.baseWidgetSize * 0.6 + anchors.top: cardBackground.top + anchors.topMargin: Style.marginXL + anchors.right: cardBackground.right + anchors.rightMargin: Style.marginXL + + onClicked: { + card.runAction("", true); + } + } + + // Compact content + RowLayout { + id: compactContent + visible: notifWindow.isCompact + anchors.fill: cardBackground + anchors.margins: Style.marginM + spacing: Style.marginS + + UImageRounded { + Layout.preferredWidth: Math.round(24 * root.uiScaleRatio) + Layout.preferredHeight: Math.round(24 * root.uiScaleRatio) + Layout.alignment: Qt.AlignVCenter + radius: Style.radiusXS + imagePath: model.originalImage || "" + borderColor: "transparent" + borderWidth: 0 + fallbackIcon: "bell" + fallbackIconSize: 16 + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginXS + + UText { + text: "No summary" + pointSize: Style.fontSizeM + font.weight: Style.fontWeightMedium + color: Colors.mOnSurface + textFormat: Text.StyledText + maximumLineCount: 1 + elide: Text.ElideRight + Layout.fillWidth: true + } + + UText { + visible: model.body && model.body.length > 0 + Layout.fillWidth: true + text: model.body || "" + pointSize: Style.fontSizeS + color: Colors.mOnSurfaceVariant + textFormat: Text.StyledText + wrapMode: Text.Wrap + maximumLineCount: 2 + elide: Text.ElideRight + } + } + } + } + } + } + } + } + } } diff --git a/config/quickshell/.config/quickshell/Modules/Panel/BluetoothPanel.qml b/config/quickshell/.config/quickshell/Modules/Panel/BluetoothPanel.qml deleted file mode 100644 index 39722b3..0000000 --- a/config/quickshell/.config/quickshell/Modules/Panel/BluetoothPanel.qml +++ /dev/null @@ -1,254 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import Quickshell -import Quickshell.Bluetooth -import Quickshell.Wayland -import qs.Constants -import qs.Modules.Panel.Misc -import qs.Noctalia -import qs.Services - -NPanel { - id: root - - preferredWidth: 380 - preferredHeight: 500 - - panelContent: Rectangle { - color: Color.transparent - - ColumnLayout { - anchors.fill: parent - anchors.margins: Style.marginL - spacing: Style.marginM - - // HEADER - RowLayout { - Layout.fillWidth: true - spacing: Style.marginM - - NIcon { - icon: "bluetooth" - pointSize: Style.fontSizeXXL - color: Color.mPrimary - } - - NText { - text: "Bluetooth" - pointSize: Style.fontSizeL - font.weight: Style.fontWeightBold - color: Color.mOnSurface - Layout.fillWidth: true - } - - NToggle { - id: bluetoothSwitch - - checked: BluetoothService.enabled - onToggled: (checked) => { - return BluetoothService.setBluetoothEnabled(checked); - } - baseSize: Style.baseWidgetSize * 0.65 - } - - NIconButton { - enabled: BluetoothService.enabled - icon: BluetoothService.adapter && BluetoothService.adapter.discovering ? "stop" : "refresh" - baseSize: Style.baseWidgetSize * 0.8 - onClicked: { - if (BluetoothService.adapter) - BluetoothService.adapter.discovering = !BluetoothService.adapter.discovering; - - } - colorFg: Colors.green - colorBg: Color.transparent - colorFgHover: Colors.base - colorBgHover: Colors.green - } - - NIconButton { - icon: "close" - baseSize: Style.baseWidgetSize * 0.8 - onClicked: { - root.close(); - } - colorFg: Colors.red - colorBg: Color.transparent - colorFgHover: Colors.base - colorBgHover: Colors.red - } - - } - - NDivider { - Layout.fillWidth: true - } - - Rectangle { - visible: !(BluetoothService.adapter && BluetoothService.adapter.enabled) - Layout.fillWidth: true - Layout.fillHeight: true - color: Color.transparent - - // Center the content within this rectangle - ColumnLayout { - anchors.centerIn: parent - spacing: Style.marginM - - NIcon { - icon: "bluetooth-off" - pointSize: 64 - color: Color.mOnSurfaceVariant - Layout.alignment: Qt.AlignHCenter - } - - NText { - text: "Bluetooth is turned off" - pointSize: Style.fontSizeL - color: Color.mOnSurfaceVariant - Layout.alignment: Qt.AlignHCenter - } - - NText { - text: "Enable Bluetooth" - pointSize: Style.fontSizeS - color: Color.mOnSurfaceVariant - Layout.alignment: Qt.AlignHCenter - } - - } - - } - - NScrollView { - visible: BluetoothService.adapter && BluetoothService.adapter.enabled - Layout.fillWidth: true - Layout.fillHeight: true - horizontalPolicy: ScrollBar.AlwaysOff - verticalPolicy: ScrollBar.AsNeeded - clip: true - contentWidth: availableWidth - - ColumnLayout { - width: parent.width - spacing: Style.marginM - - // Connected devices - BluetoothDevicesList { - property var items: { - if (!BluetoothService.adapter || !Bluetooth.devices) - return []; - - var filtered = Bluetooth.devices.values.filter((dev) => { - return dev && !dev.blocked && dev.connected; - }); - return BluetoothService.sortDevices(filtered); - } - - label: "Connected Devices" - model: items - visible: items.length > 0 - Layout.fillWidth: true - } - - // Known devices - BluetoothDevicesList { - property var items: { - if (!BluetoothService.adapter || !Bluetooth.devices) - return []; - - var filtered = Bluetooth.devices.values.filter((dev) => { - return dev && !dev.blocked && !dev.connected && (dev.paired || dev.trusted); - }); - return BluetoothService.sortDevices(filtered); - } - - label: "Known Devices" - tooltipText: "Connect/Disconnect Devices" - model: items - visible: items.length > 0 - Layout.fillWidth: true - } - - // Available devices - BluetoothDevicesList { - property var items: { - if (!BluetoothService.adapter || !Bluetooth.devices) - return []; - - var filtered = Bluetooth.devices.values.filter((dev) => { - return dev && !dev.blocked && !dev.paired && !dev.trusted; - }); - return BluetoothService.sortDevices(filtered); - } - - label: "Available Devices" - model: items - visible: items.length > 0 - Layout.fillWidth: true - } - - // Fallback - No devices, scanning - ColumnLayout { - Layout.alignment: Qt.AlignHCenter - spacing: Style.marginM - visible: { - if (!BluetoothService.adapter || !BluetoothService.adapter.discovering || !Bluetooth.devices) - return false; - - var availableCount = Bluetooth.devices.values.filter((dev) => { - return dev && !dev.paired && !dev.pairing && !dev.blocked && (dev.signalStrength === undefined || dev.signalStrength > 0); - }).length; - return (availableCount === 0); - } - - RowLayout { - Layout.alignment: Qt.AlignHCenter - spacing: Style.marginXS - - NIcon { - icon: "refresh" - pointSize: Style.fontSizeXXL * 1.5 - color: Color.mPrimary - - RotationAnimation on rotation { - running: true - loops: Animation.Infinite - from: 0 - to: 360 - duration: Style.animationSlow * 4 - } - - } - - NText { - text: "Scanning..." - pointSize: Style.fontSizeL - color: Color.mOnSurface - } - - } - - NText { - text: "Pairing Mode" - pointSize: Style.fontSizeM - color: Color.mOnSurfaceVariant - Layout.alignment: Qt.AlignHCenter - } - - } - - Item { - Layout.fillHeight: true - } - - } - - } - - } - - } - -} diff --git a/config/quickshell/.config/quickshell/Modules/Panel/CalendarPanel.qml b/config/quickshell/.config/quickshell/Modules/Panel/CalendarPanel.qml deleted file mode 100644 index d34e69b..0000000 --- a/config/quickshell/.config/quickshell/Modules/Panel/CalendarPanel.qml +++ /dev/null @@ -1,526 +0,0 @@ -import Qt5Compat.GraphicalEffects -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import Quickshell -import Quickshell.Wayland -import qs.Constants -import qs.Noctalia -import qs.Services -import qs.Utils - -NPanel { - id: root - - preferredWidth: 400 - preferredHeight: 520 - - panelContent: ColumnLayout { - id: content - - readonly property int firstDayOfWeek: Qt.locale().firstDayOfWeek - property bool isCurrentMonth: checkIsCurrentMonth() - readonly property bool weatherReady: (LocationService.data.weather !== null) - - function checkIsCurrentMonth() { - return (Time.date.getMonth() === grid.month) && (Time.date.getFullYear() === grid.year); - } - - function getISOWeekNumber(date) { - const target = new Date(date.getTime()); - target.setHours(0, 0, 0, 0); - const dayOfWeek = target.getDay() || 7; - target.setDate(target.getDate() + 4 - dayOfWeek); - const yearStart = new Date(target.getFullYear(), 0, 1); - const weekNumber = Math.ceil(((target - yearStart) / 8.64e+07 + 1) / 7); - return weekNumber; - } - - anchors.fill: parent - anchors.margins: Style.marginL - spacing: Style.marginM - - Connections { - function onDateChanged() { - isCurrentMonth = checkIsCurrentMonth(); - } - - target: Time - } - - // Combined blue banner with date/time and weather summary - NBox { - Layout.fillWidth: true - Layout.preferredHeight: blueColumn.implicitHeight + Style.marginM * 2 - - ColumnLayout { - id: blueColumn - - anchors.fill: parent - anchors.margins: Style.marginM - spacing: 0 - - // Combined layout for weather icon, date, and weather text - RowLayout { - Layout.fillWidth: true - Layout.preferredHeight: 60 - spacing: Style.marginS - - // Weather icon and temperature - ColumnLayout { - Layout.alignment: Qt.AlignVCenter - spacing: Style.marginXXS - - NIcon { - Layout.alignment: Qt.AlignHCenter - icon: weatherReady ? LocationService.weatherSymbolFromCode(LocationService.data.weather.current_weather.weathercode) : "cloud" - pointSize: Style.fontSizeXXL - color: Colors.text - } - - NText { - Layout.alignment: Qt.AlignHCenter - text: { - if (!weatherReady) - return ""; - - var temp = LocationService.data.weather.current_weather.temperature; - var suffix = "C"; - temp = Math.round(temp); - return `${temp}°${suffix}`; - } - pointSize: Style.fontSizeM - font.weight: Style.fontWeightBold - color: Colors.text - } - - } - - // Today day number - NText { - visible: content.isCurrentMonth - Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft - text: Time.date.getDate() - pointSize: Style.fontSizeXXXL * 1.5 - font.weight: Style.fontWeightBold - color: Colors.text - } - - Item { - visible: !content.isCurrentMonth - } - - // Month, year, location - ColumnLayout { - Layout.fillWidth: false - Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft - spacing: -Style.marginXS - - RowLayout { - spacing: 0 - - NText { - text: Qt.locale().monthName(grid.month, Locale.LongFormat).toUpperCase() - pointSize: Style.fontSizeXL * 1.2 - font.weight: Style.fontWeightBold - color: Colors.text - Layout.alignment: Qt.AlignBaseline - Layout.maximumWidth: 150 - elide: Text.ElideRight - } - - NText { - text: ` ${grid.year}` - pointSize: Style.fontSizeL - font.weight: Style.fontWeightBold - color: Qt.alpha(Colors.text, 0.7) - Layout.alignment: Qt.AlignBaseline - } - - } - - RowLayout { - spacing: 0 - - NText { - text: { - if (!weatherReady) - return "Weather unavailable"; - - const chunks = LocationService.data.name.split(","); - return chunks[0]; - } - pointSize: Style.fontSizeM - font.weight: Style.fontWeightMedium - color: Colors.text - Layout.maximumWidth: 150 - elide: Text.ElideRight - } - - NText { - text: weatherReady ? ` (${LocationService.data.weather.timezone_abbreviation})` : "" - pointSize: Style.fontSizeXS - font.weight: Style.fontWeightMedium - color: Qt.alpha(Colors.text, 0.7) - } - - } - - } - - // Spacer between date and clock - Item { - Layout.fillWidth: true - } - - // Digital clock with circular progress - Item { - width: Style.fontSizeXXXL * 1.9 - height: Style.fontSizeXXXL * 1.9 - Layout.alignment: Qt.AlignVCenter - - // Seconds circular progress - Canvas { - id: secondsProgress - - property real progress: Time.date.getSeconds() / 60 - - anchors.fill: parent - onProgressChanged: requestPaint() - onPaint: { - var ctx = getContext("2d"); - var centerX = width / 2; - var centerY = height / 2; - var radius = Math.min(width, height) / 2 - 3; - ctx.reset(); - // Background circle - ctx.beginPath(); - ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI); - ctx.lineWidth = 2.5; - ctx.strokeStyle = Qt.alpha(Colors.text, 0.15); - ctx.stroke(); - // Progress arc - ctx.beginPath(); - ctx.arc(centerX, centerY, radius, -Math.PI / 2, -Math.PI / 2 + progress * 2 * Math.PI); - ctx.lineWidth = 2.5; - ctx.strokeStyle = Colors.text; - ctx.lineCap = "round"; - ctx.stroke(); - } - - Connections { - function onDateChanged() { - secondsProgress.progress = Time.date.getSeconds() / 60; - } - - target: Time - } - - } - - // Digital clock - ColumnLayout { - anchors.centerIn: parent - spacing: -Style.marginXXS - - NText { - text: { - var t = Qt.locale().toString(new Date(), "HH"); - return t.split(" ")[0]; - } - pointSize: Style.fontSizeXS - font.weight: Style.fontWeightBold - color: Colors.text - family: Fonts.sans - Layout.alignment: Qt.AlignHCenter - } - - NText { - text: Qt.formatTime(Time.date, "mm") - pointSize: Style.fontSizeXXS - font.weight: Style.fontWeightBold - color: Colors.text - family: Fonts.sans - Layout.alignment: Qt.AlignHCenter - } - - } - - } - - } - - } - - } - - // 6-day forecast (outside blue banner) - RowLayout { - visible: weatherReady - Layout.fillWidth: true - Layout.alignment: Qt.AlignHCenter - spacing: Style.marginL - - Repeater { - model: weatherReady ? Math.min(6, LocationService.data.weather.daily.time.length) : 0 - - delegate: ColumnLayout { - Layout.preferredWidth: 0 - Layout.fillWidth: true - Layout.alignment: Qt.AlignHCenter - spacing: Style.marginS - - NText { - text: { - var weatherDate = new Date(LocationService.data.weather.daily.time[index].replace(/-/g, "/")); - return Qt.locale().toString(weatherDate, "ddd"); - } - color: Color.mOnSurfaceVariant - pointSize: Style.fontSizeM - font.weight: Style.fontWeightMedium - Layout.alignment: Qt.AlignHCenter - } - - NIcon { - Layout.alignment: Qt.AlignVCenter | Qt.AlignHCenter - icon: LocationService.weatherSymbolFromCode(LocationService.data.weather.daily.weathercode[index]) - pointSize: Style.fontSizeXXL * 1.5 - color: LocationService.weatherColorFromCode(LocationService.data.weather.daily.weathercode[index]) - } - - NText { - Layout.alignment: Qt.AlignHCenter - text: { - var max = LocationService.data.weather.daily.temperature_2m_max[index]; - var min = LocationService.data.weather.daily.temperature_2m_min[index]; - max = Math.round(max); - min = Math.round(min); - return `${max}°/${min}°`; - } - pointSize: Style.fontSizeXS - color: Colors.text - font.weight: Style.fontWeightMedium - } - - } - - } - - } - - // Loading indicator for weather - RowLayout { - visible: !weatherReady - Layout.fillWidth: true - Layout.alignment: Qt.AlignHCenter - - NBusyIndicator { - } - - } - - // Spacer - Item { - } - - // Navigation and divider - RowLayout { - Layout.fillWidth: true - spacing: Style.marginS - - NDivider { - Layout.fillWidth: true - } - - NIconButton { - icon: "chevron-left" - colorBg: Color.transparent - colorBorder: Color.transparent - colorBorderHover: Color.transparent - onClicked: { - let newDate = new Date(grid.year, grid.month - 1, 1); - grid.year = newDate.getFullYear(); - grid.month = newDate.getMonth(); - content.isCurrentMonth = content.checkIsCurrentMonth(); - } - } - - NIconButton { - icon: "calendar" - colorBg: Color.transparent - colorBorder: Color.transparent - colorBorderHover: Color.transparent - onClicked: { - grid.month = Time.date.getMonth(); - grid.year = Time.date.getFullYear(); - content.isCurrentMonth = true; - } - } - - NIconButton { - icon: "chevron-right" - colorBg: Color.transparent - colorBorder: Color.transparent - colorBorderHover: Color.transparent - onClicked: { - let newDate = new Date(grid.year, grid.month + 1, 1); - grid.year = newDate.getFullYear(); - grid.month = newDate.getMonth(); - content.isCurrentMonth = content.checkIsCurrentMonth(); - } - } - - } - - // Names of days of the week - RowLayout { - Layout.fillWidth: true - spacing: 0 - - Item { - Layout.preferredWidth: visible ? Style.baseWidgetSize * 0.7 : 0 - } - - GridLayout { - Layout.fillWidth: true - columns: 7 - rows: 1 - columnSpacing: 0 - rowSpacing: 0 - - Repeater { - model: 7 - - Item { - Layout.fillWidth: true - Layout.preferredHeight: Style.baseWidgetSize * 0.6 - - NText { - anchors.centerIn: parent - text: { - let dayIndex = (content.firstDayOfWeek + index) % 7; - const dayNames = ["S", "M", "T", "W", "T", "F", "S"]; - return dayNames[dayIndex]; - } - color: Color.mPrimary - pointSize: Style.fontSizeS - font.weight: Style.fontWeightBold - horizontalAlignment: Text.AlignHCenter - } - - } - - } - - } - - } - - // Grid with weeks and days - RowLayout { - Layout.fillWidth: true - Layout.fillHeight: true - spacing: 0 - - // Column of week numbers - ColumnLayout { - Layout.preferredWidth: visible ? Style.baseWidgetSize * 0.7 : 0 - Layout.fillHeight: true - spacing: 0 - - Repeater { - model: 6 - - Item { - Layout.fillWidth: true - Layout.fillHeight: true - - NText { - anchors.centerIn: parent - color: Color.mOutline - pointSize: Style.fontSizeXXS - font.weight: Style.fontWeightMedium - text: { - let firstOfMonth = new Date(grid.year, grid.month, 1); - let firstDayOfWeek = content.firstDayOfWeek; - let firstOfMonthDayOfWeek = firstOfMonth.getDay(); - let daysBeforeFirst = (firstOfMonthDayOfWeek - firstDayOfWeek + 7) % 7; - if (daysBeforeFirst === 0) - daysBeforeFirst = 7; - - let gridStartDate = new Date(grid.year, grid.month, 1 - daysBeforeFirst); - let rowStartDate = new Date(gridStartDate); - rowStartDate.setDate(gridStartDate.getDate() + (index * 7)); - let thursday = new Date(rowStartDate); - if (firstDayOfWeek === 0) { - thursday.setDate(rowStartDate.getDate() + 4); - } else if (firstDayOfWeek === 1) { - thursday.setDate(rowStartDate.getDate() + 3); - } else { - let daysToThursday = (4 - firstDayOfWeek + 7) % 7; - thursday.setDate(rowStartDate.getDate() + daysToThursday); - } - return `${getISOWeekNumber(thursday)}`; - } - } - - } - - } - - } - - // Days Grid - MonthGrid { - id: grid - - Layout.fillWidth: true - Layout.fillHeight: true - spacing: Style.marginXXS - month: Time.date.getMonth() - year: Time.date.getFullYear() - locale: Qt.locale() - - delegate: Item { - Rectangle { - width: Style.baseWidgetSize * 0.9 - height: Style.baseWidgetSize * 0.9 - anchors.centerIn: parent - radius: Style.radiusM - color: model.today ? Color.mSecondary : Color.transparent - - NText { - anchors.centerIn: parent - text: model.day - color: { - if (model.today) - return Color.mOnSecondary; - - if (model.month === grid.month) - return Color.mOnSurface; - - return Color.mOnSurfaceVariant; - } - opacity: model.month === grid.month ? 1 : 0.4 - pointSize: Style.fontSizeM - font.weight: model.today ? Style.fontWeightBold : Style.fontWeightMedium - } - - Behavior on color { - ColorAnimation { - duration: Style.animationFast - } - - } - - } - - } - - } - - } - - } - -} diff --git a/config/quickshell/.config/quickshell/Modules/Panel/Cards/LyricsControl.qml b/config/quickshell/.config/quickshell/Modules/Panel/Cards/LyricsControl.qml deleted file mode 100644 index 056d9c9..0000000 --- a/config/quickshell/.config/quickshell/Modules/Panel/Cards/LyricsControl.qml +++ /dev/null @@ -1,96 +0,0 @@ -import QtQuick -import QtQuick.Layouts -import Quickshell -import qs.Constants -import qs.Modules.Bar.Misc -import qs.Noctalia -import qs.Services - -GridLayout { - id: buttonsGrid - - columns: 2 - columnSpacing: 10 - rowSpacing: 10 - Layout.margins: 10 - - NIconButton { - id: slowerButton - - baseSize: 32 - colorBg: Color.transparent - colorBgHover: Colors.blue - colorFg: Colors.blue - icon: "arrow-bar-up" - onClicked: { - LyricsService.increaseOffset(); - } - } - - NIconButton { - id: playPauseButton - - baseSize: 32 - colorBg: Color.transparent - colorBgHover: Colors.yellow - colorFg: Colors.yellow - icon: "arrow-bar-down" - onClicked: { - LyricsService.decreaseOffset(); - } - } - - NIconButton { - id: nextButton - - baseSize: 32 - colorBg: Color.transparent - colorBgHover: Colors.green - colorFg: Colors.green - icon: "rotate-clockwise" - onClicked: { - LyricsService.resetOffset(); - } - } - - NIconButton { - id: fasterButton - - baseSize: 32 - colorBg: Color.transparent - colorBgHover: Colors.red - colorFg: Colors.red - icon: "trash" - onClicked: { - LyricsService.clearCache(); - } - } - - NIconButton { - id: barLyricsButton - - baseSize: 32 - colorBg: SettingsService.showLyricsBar ? Colors.peach : Color.transparent - colorBgHover: Colors.peach - colorFg: SettingsService.showLyricsBar ? Colors.base : Colors.peach - icon: "app-window" - onClicked: { - SettingsService.showLyricsBar = !SettingsService.showLyricsBar; - } - } - - NIconButton { - id: textButton - - baseSize: 32 - colorBg: Color.transparent - colorBgHover: Colors.subtext1 - colorFg: Colors.subtext1 - icon: "align-box-left-bottom" - onClicked: { - LyricsService.showLyricsText(); - controlCenterPanel.close(); - } - } - -} diff --git a/config/quickshell/.config/quickshell/Modules/Panel/Cards/MediaCard.qml b/config/quickshell/.config/quickshell/Modules/Panel/Cards/MediaCard.qml deleted file mode 100644 index 9ef375d..0000000 --- a/config/quickshell/.config/quickshell/Modules/Panel/Cards/MediaCard.qml +++ /dev/null @@ -1,458 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Effects -import QtQuick.Layouts -import Quickshell -import qs.Constants -import qs.Noctalia -import qs.Services -import qs.Utils - -NBox { - id: root - - // Background artwork that covers everything - Item { - anchors.fill: parent - clip: true - - NImageRounded { - id: bgArtImage - - anchors.fill: parent - imagePath: MusicManager.trackArtUrl - imageRadius: Style.radiusM - visible: MusicManager.trackArtUrl !== "" - } - - // Dark overlay for readability - Rectangle { - anchors.fill: parent - color: Color.mSurfaceVariant - opacity: 0.85 - radius: Style.radiusM - } - - // Border - Rectangle { - anchors.fill: parent - color: Color.transparent - radius: Style.radiusM - } - - } - - // Background visualizer on top of the artwork - Item { - id: visualizerContainer - - anchors.fill: parent - layer.enabled: true - - Item { - anchors.fill: parent - - Cava { - id: cava - - count: 32 - } - - Repeater { - model: cava.values - - Rectangle { - anchors.bottom: parent.bottom - width: (parent.width - (cava.count - 1) * Style.marginXS) / cava.count - height: modelData * parent.height - x: index * (width + Style.marginXS) - color: Color.mPrimary - radius: width / 2 - opacity: 0.25 - } - - } - - } - - layer.effect: MultiEffect { - maskEnabled: true - maskThresholdMin: 0.5 - maskSpreadAtMin: 0 - - maskSource: ShaderEffectSource { - - sourceItem: Rectangle { - width: root.width - height: root.height - radius: Style.radiusM - color: "white" - } - - } - - } - - } - - // Player selector - positioned at the very top - Rectangle { - id: playerSelectorButton - - property var currentPlayer: MusicManager.getAvailablePlayers()[MusicManager.selectedPlayerIndex] - - anchors.top: parent.top - anchors.left: parent.left - anchors.right: parent.right - anchors.topMargin: Style.marginXS - anchors.leftMargin: Style.marginM - anchors.rightMargin: Style.marginM - height: Style.barHeight - visible: MusicManager.getAvailablePlayers().length > 1 - radius: Style.radiusM - color: Color.transparent - Component.onCompleted: { - MusicManager.selectedPlayerIndex = -1; - } - Component.onDestruction: { - MusicManager.selectedPlayerIndex = -1; - } - - RowLayout { - anchors.fill: parent - spacing: Style.marginS - - NIcon { - icon: "caret-down" - pointSize: Style.fontSizeXXL - color: Color.mOnSurfaceVariant - } - - NText { - text: playerSelectorButton.currentPlayer ? playerSelectorButton.currentPlayer.identity : "" - pointSize: Style.fontSizeXS - color: Color.mOnSurfaceVariant - Layout.fillWidth: true - } - - } - - MouseArea { - id: playerSelectorMouseArea - - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - var menuItems = []; - var players = MusicManager.getAvailablePlayers(); - for (var i = 0; i < players.length; i++) { - menuItems.push({ - "label": players[i].identity, - "action": i.toString(), - "icon": "disc", - "enabled": true, - "visible": true - }); - } - playerContextMenu.model = menuItems; - playerContextMenu.openAtItem(playerSelectorButton, playerSelectorButton.width - playerContextMenu.width, playerSelectorButton.height); - } - } - - NContextMenu { - id: playerContextMenu - - parent: root - width: 200 - onTriggered: function(action) { - var index = parseInt(action); - if (!isNaN(index)) { - MusicManager.selectedPlayerIndex = index; - MusicManager.updateCurrentPlayer(); - } - } - } - - } - - ColumnLayout { - anchors.fill: parent - anchors.margins: Style.marginM - - // No media player detected - ColumnLayout { - id: fallback - - visible: !main.visible - spacing: Style.marginS - - Item { - Layout.fillWidth: true - Layout.fillHeight: true - } - - Item { - Layout.fillWidth: true - Layout.fillHeight: true - - ColumnLayout { - anchors.centerIn: parent - spacing: Style.marginL - - Item { - Layout.alignment: Qt.AlignHCenter - Layout.preferredWidth: Style.fontSizeXXXL * 4 - Layout.preferredHeight: Style.fontSizeXXXL * 4 - - // Pulsating audio circles (background) - Repeater { - model: 3 - - Rectangle { - anchors.centerIn: parent - width: parent.width * (1 + index * 0.2) - height: width - radius: width / 2 - color: "transparent" - border.color: Color.mOnSurfaceVariant - border.width: 2 - opacity: 0 - - SequentialAnimation on opacity { - running: true - loops: Animation.Infinite - - PauseAnimation { - duration: index * 600 - } - - NumberAnimation { - from: 1 - to: 0 - duration: 2000 - easing.type: Easing.OutQuad - } - - } - - SequentialAnimation on scale { - running: true - loops: Animation.Infinite - - PauseAnimation { - duration: index * 600 - } - - NumberAnimation { - from: 0.5 - to: 1.2 - duration: 2000 - easing.type: Easing.OutQuad - } - - } - - } - - } - - // Spinning disc - NIcon { - anchors.centerIn: parent - icon: "disc" - pointSize: Style.fontSizeXXXL * 3 - color: Color.mOnSurfaceVariant - - RotationAnimator on rotation { - from: 0 - to: 360 - duration: 8000 - loops: Animation.Infinite - running: true - } - - } - - } - - // Descriptive text - ColumnLayout { - Layout.alignment: Qt.AlignHCenter - spacing: Style.marginXS - } - - } - - } - - Item { - Layout.fillWidth: true - Layout.fillHeight: true - } - - } - - // MediaPlayer Main Content - ColumnLayout { - id: main - - visible: MusicManager.currentPlayer && MusicManager.canPlay - spacing: Style.marginS - - // Spacer to push content down - Item { - Layout.preferredHeight: Style.marginM - } - - // Metadata at the bottom left - ColumnLayout { - Layout.fillWidth: true - Layout.alignment: Qt.AlignLeft - spacing: Style.marginXS - - NText { - visible: MusicManager.trackTitle !== "" - text: MusicManager.trackTitle - pointSize: Style.fontSizeM - font.weight: Style.fontWeightBold - elide: Text.ElideRight - wrapMode: Text.Wrap - Layout.fillWidth: true - maximumLineCount: 1 - } - - NText { - visible: MusicManager.trackArtist !== "" - text: MusicManager.trackArtist - color: Color.mPrimary - pointSize: Style.fontSizeS - elide: Text.ElideRight - Layout.fillWidth: true - maximumLineCount: 1 - } - - NText { - visible: MusicManager.trackAlbum !== "" - text: MusicManager.trackAlbum - color: Color.mOnSurfaceVariant - pointSize: Style.fontSizeM - elide: Text.ElideRight - Layout.fillWidth: true - maximumLineCount: 1 - } - - } - - // Progress slider - Item { - id: progressWrapper - - property real localSeekRatio: -1 - property real lastSentSeekRatio: -1 - property real seekEpsilon: 0.01 - property real progressRatio: { - if (!MusicManager.currentPlayer || MusicManager.trackLength <= 0) - return 0; - - const r = MusicManager.currentPosition / MusicManager.trackLength; - if (isNaN(r) || !isFinite(r)) - return 0; - - return Math.max(0, Math.min(1, r)); - } - property real effectiveRatio: (MusicManager.isSeeking && localSeekRatio >= 0) ? Math.max(0, Math.min(1, localSeekRatio)) : progressRatio - - visible: (MusicManager.currentPlayer && MusicManager.trackLength > 0) - Layout.fillWidth: true - height: Style.baseWidgetSize * 0.5 - - Timer { - id: seekDebounce - - interval: 75 - repeat: false - onTriggered: { - if (MusicManager.isSeeking && progressWrapper.localSeekRatio >= 0) { - const next = Math.max(0, Math.min(1, progressWrapper.localSeekRatio)); - if (progressWrapper.lastSentSeekRatio < 0 || Math.abs(next - progressWrapper.lastSentSeekRatio) >= progressWrapper.seekEpsilon) { - MusicManager.seekByRatio(next); - progressWrapper.lastSentSeekRatio = next; - } - } - } - } - - NSlider { - id: progressSlider - - anchors.fill: parent - from: 0 - to: 1 - stepSize: 0 - snapAlways: false - enabled: MusicManager.trackLength > 0 && MusicManager.canSeek - heightRatio: 0.65 - onMoved: { - progressWrapper.localSeekRatio = value; - seekDebounce.restart(); - } - onPressedChanged: { - if (pressed) { - MusicManager.isSeeking = true; - progressWrapper.localSeekRatio = value; - MusicManager.seekByRatio(value); - progressWrapper.lastSentSeekRatio = value; - } else { - seekDebounce.stop(); - MusicManager.seekByRatio(value); - MusicManager.isSeeking = false; - progressWrapper.localSeekRatio = -1; - progressWrapper.lastSentSeekRatio = -1; - } - } - } - - Binding { - target: progressSlider - property: "value" - value: progressWrapper.progressRatio - when: !MusicManager.isSeeking - } - - } - - // Media controls - RowLayout { - spacing: Style.marginS - Layout.fillWidth: true - Layout.alignment: Qt.AlignHCenter - - NIconButton { - icon: "media-prev" - visible: MusicManager.canGoPrevious - onClicked: MusicManager.canGoPrevious ? MusicManager.previous() : { - } - } - - NIconButton { - icon: MusicManager.isPlaying ? "media-pause" : "media-play" - visible: (MusicManager.canPlay || MusicManager.canPause) - onClicked: (MusicManager.canPlay || MusicManager.canPause) ? MusicManager.playPause() : { - } - } - - NIconButton { - icon: "media-next" - visible: MusicManager.canGoNext - onClicked: MusicManager.canGoNext ? MusicManager.next() : { - } - } - - } - - } - - } - -} diff --git a/config/quickshell/.config/quickshell/Modules/Panel/Cards/SystemMonitorCard.qml b/config/quickshell/.config/quickshell/Modules/Panel/Cards/SystemMonitorCard.qml deleted file mode 100644 index 0ed7ea2..0000000 --- a/config/quickshell/.config/quickshell/Modules/Panel/Cards/SystemMonitorCard.qml +++ /dev/null @@ -1,95 +0,0 @@ -import QtQuick -import QtQuick.Layouts -import Quickshell -import qs.Constants -import qs.Modules.Panel.Misc -import qs.Noctalia -import qs.Services -import qs.Utils - -ColumnLayout { - id: root - - spacing: 0 - - RowLayout { - id: sunsetControlRow - - Layout.fillWidth: true - - NIconButton { - id: barLyricsButton - - implicitHeight: 32 - implicitWidth: 32 - colorBg: SunsetService.isRunning ? Colors.flamingo : Color.transparent - colorBgHover: Colors.flamingo - colorFg: SunsetService.isRunning ? Colors.base : Colors.flamingo - icon: "sunset-2" - onClicked: SunsetService.toggleSunset() - } - - NText { - Layout.fillWidth: true - Layout.alignment: Qt.AlignVCenter - horizontalAlignment: Text.AlignHCenter - text: SunsetService.isRunning ? "Temp: " + SunsetService.temperature + " K" : "Sunset Off" - } - - } - - NBox { - id: monitors - - compact: true - Layout.fillWidth: true - Layout.fillHeight: true - - ColumnLayout { - id: content - - anchors.fill: parent - anchors.margins: Style.marginS - spacing: Style.marginS - - MonitorSlider { - icon: "cpu-usage" - value: SystemStatService.cpuUsage - from: 0 - to: 100 - colorFill: Colors.teal - Layout.fillWidth: true - } - - MonitorSlider { - icon: "memory" - value: SystemStatService.memPercent - from: 0 - to: 100 - colorFill: Colors.green - Layout.fillWidth: true - } - - MonitorSlider { - icon: "cpu-temperature" - value: SystemStatService.cpuTemp - from: 0 - to: 100 - colorFill: Colors.yellow - Layout.fillWidth: true - } - - MonitorSlider { - icon: "storage" - value: SystemStatService.diskPercent - from: 0 - to: 100 - colorFill: Colors.peach - Layout.fillWidth: true - } - - } - - } - -} diff --git a/config/quickshell/.config/quickshell/Modules/Panel/Cards/TopLeftCard.qml b/config/quickshell/.config/quickshell/Modules/Panel/Cards/TopLeftCard.qml deleted file mode 100644 index cbfd7bb..0000000 --- a/config/quickshell/.config/quickshell/Modules/Panel/Cards/TopLeftCard.qml +++ /dev/null @@ -1,175 +0,0 @@ -import QtQuick -import QtQuick.Effects -import QtQuick.Layouts -import Quickshell -import Quickshell.Io -import Quickshell.Services.UPower -import Quickshell.Widgets -import qs.Constants -import qs.Noctalia -import qs.Services -import qs.Utils - -ColumnLayout { - id: root - - readonly property bool hasPP: PowerProfileService.available - - spacing: Style.marginM - - NBox { - id: whoamiBox - - property string uptimeText: "--" - property string hostname: "--" - - function updateSystemInfo() { - uptimeProcess.running = true; - hostnameProcess.running = true; - } - - Layout.fillWidth: true - Layout.fillHeight: true - - RowLayout { - id: content - - spacing: root.spacing - anchors.fill: parent - anchors.margins: root.spacing - - NImageCircled { - width: Style.baseWidgetSize * 1.5 - height: Style.baseWidgetSize * 1.5 - imagePath: Quickshell.shellDir + "/Assets/Images/Avatar.jpg" - fallbackIcon: "person" - borderColor: Color.mPrimary - borderWidth: Math.max(1, Style.borderM) - Layout.alignment: Qt.AlignVCenter - Layout.topMargin: Style.marginXS - } - - ColumnLayout { - Layout.fillWidth: true - spacing: Style.marginXXS - - NText { - text: `${Quickshell.env("USER") || "user"} @ ${whoamiBox.hostname}` - font.weight: Style.fontWeightBold - font.pointSize: Style.fontSizeL - font.capitalization: Font.Capitalize - } - - NText { - text: "Uptime: " + whoamiBox.uptimeText - font.pointSize: Style.fontSizeM - color: Color.mOnSurfaceVariant - } - - } - - Item { - Layout.fillWidth: true - } - - } - - // ---------------------------------- - // Uptime - Timer { - interval: 60000 - repeat: true - running: true - onTriggered: uptimeProcess.running = true - } - - Process { - id: uptimeProcess - - command: ["cat", "/proc/uptime"] - running: true - - stdout: StdioCollector { - onStreamFinished: { - var uptimeSeconds = parseFloat(this.text.trim().split(' ')[0]); - whoamiBox.uptimeText = Time.formatVagueHumanReadableDuration(uptimeSeconds); - uptimeProcess.running = false; - } - } - - } - - Process { - id: hostnameProcess - - command: ["cat", "/etc/hostname"] - running: true - - stdout: StdioCollector { - onStreamFinished: { - whoamiBox.hostname = this.text.trim(); - hostnameProcess.running = false; - } - } - - } - - } - - RowLayout { - id: utilitiesRow - - Layout.fillWidth: true - - // Performance - NIconButton { - implicitHeight: 32 - implicitWidth: 32 - icon: PowerProfileService.getIcon(PowerProfile.Performance) - enabled: hasPP - opacity: enabled ? Style.opacityFull : Style.opacityMedium - colorBgHover: Colors.red - colorBg: (enabled && PowerProfileService.profile === PowerProfile.Performance) ? Colors.red : Color.transparent - colorFg: (enabled && PowerProfileService.profile === PowerProfile.Performance) ? Color.mOnPrimary : Colors.red - onClicked: PowerProfileService.setProfile(PowerProfile.Performance) - } - - // Balanced - NIconButton { - implicitHeight: 32 - implicitWidth: 32 - icon: PowerProfileService.getIcon(PowerProfile.Balanced) - enabled: hasPP - opacity: enabled ? Style.opacityFull : Style.opacityMedium - colorBgHover: Colors.blue - colorBg: (enabled && PowerProfileService.profile === PowerProfile.Balanced) ? Colors.blue : Color.transparent - colorFg: (enabled && PowerProfileService.profile === PowerProfile.Balanced) ? Color.mOnPrimary : Colors.blue - onClicked: PowerProfileService.setProfile(PowerProfile.Balanced) - } - - // Eco - NIconButton { - implicitHeight: 32 - implicitWidth: 32 - icon: PowerProfileService.getIcon(PowerProfile.PowerSaver) - enabled: hasPP - opacity: enabled ? Style.opacityFull : Style.opacityMedium - colorBgHover: Colors.green - colorBg: (enabled && PowerProfileService.profile === PowerProfile.PowerSaver) ? Colors.green : Color.transparent - colorFg: (enabled && PowerProfileService.profile === PowerProfile.PowerSaver) ? Color.mOnPrimary : Colors.green - onClicked: PowerProfileService.setProfile(PowerProfile.PowerSaver) - } - - Item { - Layout.fillWidth: true - } - - // Lyrics Offset - NText { - text: `Lyrics Offset: ${LyricsService.offset >= 0 ? '+' : ''}${LyricsService.offset} ms` - Layout.alignment: Qt.AlignVCenter - } - - } - -} diff --git a/config/quickshell/.config/quickshell/Modules/Panel/ControlCenterPanel.qml b/config/quickshell/.config/quickshell/Modules/Panel/ControlCenterPanel.qml deleted file mode 100644 index af6150a..0000000 --- a/config/quickshell/.config/quickshell/Modules/Panel/ControlCenterPanel.qml +++ /dev/null @@ -1,87 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import Quickshell -import Quickshell.Services.Pipewire -import qs.Constants -import qs.Modules.Panel.Cards -import qs.Noctalia -import qs.Services -import qs.Utils - -NPanel { - id: root - - // Positioning - readonly property string controlCenterPosition: "top_left" - property real topCardHeight: 120 - property real middleCardHeight: 100 - property real bottomCardHeight: 200 - - preferredWidth: 480 - preferredHeight: topCardHeight + middleCardHeight + bottomCardHeight + Style.marginL * 4 - panelKeyboardFocus: false - panelAnchorHorizontalCenter: controlCenterPosition !== "close_to_bar_button" && controlCenterPosition.endsWith("_center") - panelAnchorVerticalCenter: false - panelAnchorLeft: controlCenterPosition !== "close_to_bar_button" && controlCenterPosition.endsWith("_left") - panelAnchorRight: controlCenterPosition !== "close_to_bar_button" && controlCenterPosition.endsWith("_right") - panelAnchorBottom: controlCenterPosition !== "close_to_bar_button" && controlCenterPosition.startsWith("bottom_") - panelAnchorTop: controlCenterPosition !== "close_to_bar_button" && controlCenterPosition.startsWith("top_") - - panelContent: Item { - id: content - - property real cardSpacing: Style.marginL - - // Layout content - ColumnLayout { - id: layout - - anchors.fill: parent - anchors.margins: content.cardSpacing - spacing: content.cardSpacing - - // Top Card: profile + utilities - RowLayout { - Layout.fillWidth: true - Layout.preferredHeight: topCardHeight - - TopLeftCard { - Layout.fillWidth: true - Layout.maximumHeight: topCardHeight - } - - LyricsControl { - Layout.preferredHeight: topCardHeight - } - - } - - LyricsCard { - Layout.fillWidth: true - Layout.preferredHeight: middleCardHeight - } - - // Media + stats column - RowLayout { - Layout.fillWidth: true - Layout.preferredHeight: bottomCardHeight - spacing: content.cardSpacing - - SystemMonitorCard { - Layout.fillWidth: true - Layout.preferredHeight: bottomCardHeight - } - - MediaCard { - Layout.preferredWidth: 270 - Layout.preferredHeight: bottomCardHeight - } - - } - - } - - } - -} diff --git a/config/quickshell/.config/quickshell/Modules/Panel/Misc/MonitorSlider.qml b/config/quickshell/.config/quickshell/Modules/Panel/Misc/MonitorSlider.qml deleted file mode 100644 index 778d113..0000000 --- a/config/quickshell/.config/quickshell/Modules/Panel/Misc/MonitorSlider.qml +++ /dev/null @@ -1,54 +0,0 @@ -import QtQuick -import QtQuick.Layouts -import Quickshell -import qs.Constants -import qs.Noctalia - -Item { - id: root - - property string icon: "volume-high" - property real value: 50 - property real from: 0 - property real to: 100 - property color colorFill: Colors.primary - property color colorRest: Colors.surface0 - - implicitHeight: layout.implicitHeight - - RowLayout { - id: layout - - anchors.fill: parent - spacing: Style.marginS - - NIcon { - id: iconItem - - icon: root.icon - color: root.colorFill - } - - Rectangle { - id: whole - - Layout.fillWidth: true - color: root.colorRest - radius: height / 2 - height: Style.baseWidgetSize * 0.3 - - Rectangle { - id: fill - - width: Math.max(0, Math.min(whole.width, (root.value - root.from) / (root.to - root.from) * whole.width)) - height: parent.height - color: root.colorFill - radius: height / 2 - anchors.left: parent.left - } - - } - - } - -} diff --git a/config/quickshell/.config/quickshell/Modules/Panel/NotificationHistoryPanel.qml b/config/quickshell/.config/quickshell/Modules/Panel/NotificationHistoryPanel.qml deleted file mode 100644 index 903aa0b..0000000 --- a/config/quickshell/.config/quickshell/Modules/Panel/NotificationHistoryPanel.qml +++ /dev/null @@ -1,341 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import Quickshell -import Quickshell.Services.Notifications -import Quickshell.Wayland -import qs.Constants -import qs.Noctalia -import qs.Services -import qs.Utils - -// Notification History panel -NPanel { - id: root - - preferredWidth: 380 - preferredHeight: 480 - - panelContent: Rectangle { - id: notificationRect - - color: Color.transparent - - ColumnLayout { - anchors.fill: parent - anchors.margins: Style.marginL - spacing: Style.marginM - - // Header section - RowLayout { - Layout.fillWidth: true - spacing: Style.marginM - - NIcon { - icon: "bell" - pointSize: Style.fontSizeXXL - color: Color.mPrimary - } - - NText { - text: "Notifications" - pointSize: Style.fontSizeL - font.weight: Style.fontWeightBold - color: Color.mOnSurface - Layout.fillWidth: true - } - - NIconButton { - icon: SettingsService.notifications.doNotDisturb ? "bell-off" : "bell" - baseSize: Style.baseWidgetSize * 0.8 - onClicked: SettingsService.notifications.doNotDisturb = !SettingsService.notifications.doNotDisturb - colorFg: SettingsService.notifications.doNotDisturb ? Colors.base : Colors.green - colorBg: SettingsService.notifications.doNotDisturb ? Colors.green : Color.transparent - colorFgHover: Colors.base - colorBgHover: Colors.green - } - - NIconButton { - icon: "trash" - baseSize: Style.baseWidgetSize * 0.8 - onClicked: { - NotificationService.clearHistory(); - // Close panel as there is nothing more to see. - root.close(); - } - colorFg: Colors.red - colorBg: Color.transparent - colorFgHover: Colors.base - colorBgHover: Colors.red - } - - NIconButton { - icon: "close" - baseSize: Style.baseWidgetSize * 0.8 - onClicked: root.close() - colorFg: Colors.blue - colorBg: Color.transparent - colorFgHover: Colors.base - colorBgHover: Colors.blue - } - - } - - NDivider { - Layout.fillWidth: true - } - - // Empty state when no notifications - ColumnLayout { - Layout.fillWidth: true - Layout.fillHeight: true - Layout.alignment: Qt.AlignHCenter - visible: NotificationService.historyList.count === 0 - spacing: Style.marginL - - Item { - Layout.fillHeight: true - } - - NIcon { - icon: "bell-off" - pointSize: 64 - color: Color.mOnSurfaceVariant - Layout.alignment: Qt.AlignHCenter - } - - NText { - text: "No Notifications" - pointSize: Style.fontSizeL - color: Color.mOnSurfaceVariant - Layout.alignment: Qt.AlignHCenter - } - - Item { - Layout.fillHeight: true - } - - } - - // Notification list - NListView { - id: notificationList - - // Track which notification is expanded - property string expandedId: "" - - Layout.fillWidth: true - Layout.fillHeight: true - horizontalPolicy: ScrollBar.AlwaysOff - verticalPolicy: ScrollBar.AsNeeded - model: NotificationService.historyList - spacing: Style.marginM - clip: true - boundsBehavior: Flickable.StopAtBounds - visible: NotificationService.historyList.count > 0 - - delegate: NBox { - property string notificationId: model.id - property bool isExpanded: notificationList.expandedId === notificationId - - width: notificationList.width - height: notificationLayout.implicitHeight + (Style.marginM * 2) - - // Click to expand/collapse - MouseArea { - anchors.fill: parent - // Don't capture clicks on the delete button - anchors.rightMargin: 48 - enabled: (summaryText.truncated || bodyText.truncated) - onClicked: { - if (notificationList.expandedId === notificationId) - notificationList.expandedId = ""; - else - notificationList.expandedId = notificationId; - } - cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor - } - - RowLayout { - id: notificationLayout - - anchors.fill: parent - anchors.margins: Style.marginM - spacing: Style.marginM - - ColumnLayout { - NImageCircled { - Layout.preferredWidth: 40 - Layout.preferredHeight: 40 - Layout.alignment: Qt.AlignTop - Layout.topMargin: 20 - imagePath: model.cachedImage || model.originalImage || "" - borderColor: Color.transparent - borderWidth: 0 - fallbackIcon: "bell" - fallbackIconSize: 24 - } - - Item { - Layout.fillHeight: true - } - - } - - // Notification content column - ColumnLayout { - Layout.fillWidth: true - Layout.alignment: Qt.AlignTop - spacing: Style.marginXS - Layout.rightMargin: -(Style.marginM + Style.baseWidgetSize * 0.6) - - // Header row with app name and timestamp - RowLayout { - Layout.fillWidth: true - spacing: Style.marginS - - // Urgency indicator - Rectangle { - Layout.preferredWidth: 6 - Layout.preferredHeight: 6 - Layout.alignment: Qt.AlignVCenter - radius: 3 - visible: model.urgency !== 1 - color: { - if (model.urgency === 2) - return Color.mError; - else if (model.urgency === 0) - return Color.mOnSurfaceVariant; - else - return Color.transparent; - } - } - - NText { - text: model.appName || "Unknown App" - pointSize: Style.fontSizeXS - color: Color.mSecondary - family: Fonts.sans - } - - NText { - text: Time.formatRelativeTime(model.timestamp) - pointSize: Style.fontSizeXS - color: Color.mSecondary - family: Fonts.sans - } - - Item { - Layout.fillWidth: true - } - - } - - // Summary - NText { - id: summaryText - - text: model.summary || "No Summary" - pointSize: Style.fontSizeM - font.weight: Font.Medium - color: Color.mOnSurface - textFormat: Text.PlainText - wrapMode: Text.Wrap - Layout.fillWidth: true - maximumLineCount: isExpanded ? 999 : 2 - family: Fonts.sans - elide: Text.ElideRight - } - - // Body - NText { - id: bodyText - - text: model.body || "" - pointSize: Style.fontSizeS - color: Color.mOnSurfaceVariant - textFormat: Text.PlainText - wrapMode: Text.Wrap - Layout.fillWidth: true - maximumLineCount: isExpanded ? 999 : 3 - elide: Text.ElideRight - family: Fonts.sans - visible: text.length > 0 - } - - // Spacer for expand indicator - Item { - Layout.fillWidth: true - Layout.preferredHeight: (!isExpanded && (summaryText.truncated || bodyText.truncated)) ? (Style.marginS) : 0 - } - - // Expand indicator - RowLayout { - Layout.fillWidth: true - visible: !isExpanded && (summaryText.truncated || bodyText.truncated) - spacing: Style.marginXS - - Item { - Layout.fillWidth: true - } - - NText { - text: "Click to expand" - pointSize: Style.fontSizeXS - color: Color.mPrimary - family: Fonts.sans - font.weight: Font.Medium - } - - NIcon { - icon: "chevron-down" - pointSize: Style.fontSizeS - color: Color.mPrimary - } - - } - - } - - // Delete button - NIconButton { - icon: "trash" - baseSize: Style.baseWidgetSize * 0.7 - Layout.alignment: Qt.AlignTop - onClicked: { - // Remove from history using the service API - NotificationService.removeFromHistory(notificationId); - } - colorFg: Colors.red - colorBg: Color.transparent - colorFgHover: Colors.base - colorBgHover: Colors.red - } - - } - - Behavior on height { - NumberAnimation { - duration: Style.animationNormal - easing.type: Easing.InOutQuad - } - - } - - // Smooth color transition on hover - Behavior on color { - ColorAnimation { - duration: Style.animationFast - } - - } - - } - - } - - } - - } - -} diff --git a/config/quickshell/.config/quickshell/Modules/Panel/WiFiPanel.qml b/config/quickshell/.config/quickshell/Modules/Panel/WiFiPanel.qml deleted file mode 100644 index ebf7a8e..0000000 --- a/config/quickshell/.config/quickshell/Modules/Panel/WiFiPanel.qml +++ /dev/null @@ -1,633 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import Quickshell -import Quickshell.Wayland -import qs.Constants -import qs.Noctalia -import qs.Services -import qs.Utils - -NPanel { - id: root - - property string passwordSsid: "" - property string passwordInput: "" - property string expandedSsid: "" - - preferredWidth: 400 - preferredHeight: 500 - onOpened: NetworkService.scan() - - panelContent: Rectangle { - color: Color.transparent - - ColumnLayout { - anchors.fill: parent - anchors.margins: Style.marginL - spacing: Style.marginM - - // Header - RowLayout { - Layout.fillWidth: true - spacing: Style.marginM - - NIcon { - icon: SettingsService.wifiEnabled ? "wifi" : "wifi-off" - pointSize: Style.fontSizeXXL - color: SettingsService.wifiEnabled ? Color.mPrimary : Color.mOnSurfaceVariant - } - - NText { - text: "WiFi" - pointSize: Style.fontSizeL - font.weight: Style.fontWeightBold - color: Color.mOnSurface - Layout.fillWidth: true - } - - NToggle { - id: wifiSwitch - - checked: SettingsService.wifiEnabled - onToggled: (checked) => { - return NetworkService.setWifiEnabled(checked); - } - baseSize: Style.baseWidgetSize * 0.65 - } - - NIconButton { - icon: "refresh" - baseSize: Style.baseWidgetSize * 0.8 - enabled: SettingsService.wifiEnabled && !NetworkService.scanning - onClicked: NetworkService.scan() - colorFg: Colors.green - colorBg: Color.transparent - colorFgHover: Colors.base - colorBgHover: Colors.green - } - - NIconButton { - icon: "close" - baseSize: Style.baseWidgetSize * 0.8 - onClicked: root.close() - colorFg: Colors.red - colorBg: Color.transparent - colorFgHover: Colors.base - colorBgHover: Colors.red - } - - } - - NDivider { - Layout.fillWidth: true - } - - // Error message - Rectangle { - visible: NetworkService.lastError.length > 0 - Layout.fillWidth: true - Layout.preferredHeight: errorRow.implicitHeight + (Style.marginM * 2) - color: Qt.rgba(Color.mError.r, Color.mError.g, Color.mError.b, 0.1) - radius: Style.radiusS - border.width: Math.max(1, Style.borderS) - border.color: Color.mError - - RowLayout { - id: errorRow - - anchors.fill: parent - anchors.margins: Style.marginM - spacing: Style.marginS - - NIcon { - icon: "warning" - pointSize: Style.fontSizeL - color: Color.mError - } - - NText { - text: NetworkService.lastError - color: Color.mError - pointSize: Style.fontSizeS - wrapMode: Text.Wrap - Layout.fillWidth: true - } - - NIconButton { - icon: "close" - baseSize: Style.baseWidgetSize * 0.6 - onClicked: NetworkService.lastError = "" - } - - } - - } - - // Main content area - Rectangle { - Layout.fillWidth: true - Layout.fillHeight: true - color: Color.transparent - - // WiFi disabled state - ColumnLayout { - visible: !SettingsService.wifiEnabled - anchors.fill: parent - spacing: Style.marginM - - Item { - Layout.fillHeight: true - } - - NIcon { - icon: "wifi-off" - pointSize: 64 - color: Color.mOnSurfaceVariant - Layout.alignment: Qt.AlignHCenter - } - - NText { - text: "Wi-Fi Disabled" - pointSize: Style.fontSizeL - color: Color.mOnSurfaceVariant - Layout.alignment: Qt.AlignHCenter - } - - NText { - text: "Please enable Wi-Fi to connect to a network." - pointSize: Style.fontSizeS - color: Color.mOnSurfaceVariant - Layout.alignment: Qt.AlignHCenter - } - - Item { - Layout.fillHeight: true - } - - } - - // Scanning state - ColumnLayout { - visible: SettingsService.wifiEnabled && NetworkService.scanning && Object.keys(NetworkService.networks).length === 0 - anchors.fill: parent - spacing: Style.marginL - - Item { - Layout.fillHeight: true - } - - NBusyIndicator { - running: true - color: Color.mPrimary - size: Style.baseWidgetSize - Layout.alignment: Qt.AlignHCenter - } - - NText { - text: "Searching for networks..." - pointSize: Style.fontSizeM - color: Color.mOnSurfaceVariant - Layout.alignment: Qt.AlignHCenter - } - - Item { - Layout.fillHeight: true - } - - } - - // Networks list container - NScrollView { - visible: SettingsService.wifiEnabled && (!NetworkService.scanning || Object.keys(NetworkService.networks).length > 0) - anchors.fill: parent - horizontalPolicy: ScrollBar.AlwaysOff - verticalPolicy: ScrollBar.AsNeeded - clip: true - - ColumnLayout { - width: parent.width - spacing: Style.marginM - - // Network list - Repeater { - model: { - if (!SettingsService.wifiEnabled) - return []; - - const nets = Object.values(NetworkService.networks); - return nets.sort((a, b) => { - if (a.connected !== b.connected) - return b.connected - a.connected; - - return b.signal - a.signal; - }); - } - - NBox { - Layout.fillWidth: true - implicitHeight: netColumn.implicitHeight + (Style.marginM * 2) - // Add opacity for operations in progress - opacity: (NetworkService.disconnectingFrom === modelData.ssid || NetworkService.forgettingNetwork === modelData.ssid) ? 0.6 : 1 - - ColumnLayout { - id: netColumn - - width: parent.width - (Style.marginM * 2) - x: Style.marginM - y: Style.marginM - spacing: Style.marginS - - // Main row - RowLayout { - Layout.fillWidth: true - spacing: Style.marginS - - NIcon { - icon: NetworkService.signalIcon(modelData.signal) - pointSize: Style.fontSizeXXL - color: modelData.connected ? Color.mPrimary : Color.mOnSurface - } - - ColumnLayout { - Layout.fillWidth: true - spacing: 2 - - NText { - text: modelData.ssid - pointSize: Style.fontSizeM - font.weight: modelData.connected ? Style.fontWeightBold : Style.fontWeightMedium - color: Color.mOnSurface - elide: Text.ElideRight - Layout.fillWidth: true - } - - RowLayout { - spacing: Style.marginXS - - NText { - text: `${modelData.signal}%` - pointSize: Style.fontSizeXXS - color: Color.mOnSurfaceVariant - } - - NText { - text: "•" - pointSize: Style.fontSizeXXS - color: Color.mOnSurfaceVariant - } - - NText { - text: NetworkService.isSecured(modelData.security) ? modelData.security : "Open" - pointSize: Style.fontSizeXXS - color: Color.mOnSurfaceVariant - } - - Item { - Layout.preferredWidth: Style.marginXXS - } - - // Update the status badges area (around line 237) - Rectangle { - visible: modelData.connected && NetworkService.disconnectingFrom !== modelData.ssid - color: Color.mPrimary - radius: height * 0.5 - width: connectedText.implicitWidth + (Style.marginS * 2) - height: connectedText.implicitHeight + (Style.marginXXS * 2) - - NText { - id: connectedText - - anchors.centerIn: parent - text: "Connected" - pointSize: Style.fontSizeXXS - color: Color.mOnPrimary - } - - } - - Rectangle { - visible: NetworkService.disconnectingFrom === modelData.ssid - color: Color.mError - radius: height * 0.5 - width: disconnectingText.implicitWidth + (Style.marginS * 2) - height: disconnectingText.implicitHeight + (Style.marginXXS * 2) - - NText { - id: disconnectingText - - anchors.centerIn: parent - text: "disconnecting" - pointSize: Style.fontSizeXXS - color: Color.mOnPrimary - } - - } - - Rectangle { - visible: NetworkService.forgettingNetwork === modelData.ssid - color: Color.mError - radius: height * 0.5 - width: forgettingText.implicitWidth + (Style.marginS * 2) - height: forgettingText.implicitHeight + (Style.marginXXS * 2) - - NText { - id: forgettingText - - anchors.centerIn: parent - text: "forgetting" - pointSize: Style.fontSizeXXS - color: Color.mOnPrimary - } - - } - - Rectangle { - visible: modelData.cached && !modelData.connected && NetworkService.forgettingNetwork !== modelData.ssid && NetworkService.disconnectingFrom !== modelData.ssid - color: Color.transparent - border.color: Color.mOutline - border.width: Math.max(1, Style.borderS) - radius: height * 0.5 - width: savedText.implicitWidth + (Style.marginS * 2) - height: savedText.implicitHeight + (Style.marginXXS * 2) - - NText { - id: savedText - - anchors.centerIn: parent - text: "saved" - pointSize: Style.fontSizeXXS - color: Color.mOnSurfaceVariant - } - - } - - } - - } - - // Action area - RowLayout { - spacing: Style.marginS - - NBusyIndicator { - visible: NetworkService.connectingTo === modelData.ssid || NetworkService.disconnectingFrom === modelData.ssid || NetworkService.forgettingNetwork === modelData.ssid - running: visible - color: Color.mPrimary - size: Style.baseWidgetSize * 0.5 - } - - NIconButton { - visible: (modelData.existing || modelData.cached) && !modelData.connected && NetworkService.connectingTo !== modelData.ssid && NetworkService.forgettingNetwork !== modelData.ssid && NetworkService.disconnectingFrom !== modelData.ssid - icon: "trash" - baseSize: Style.baseWidgetSize * 0.8 - onClicked: expandedSsid = expandedSsid === modelData.ssid ? "" : modelData.ssid - } - - NButton { - visible: !modelData.connected && NetworkService.connectingTo !== modelData.ssid && passwordSsid !== modelData.ssid && NetworkService.forgettingNetwork !== modelData.ssid && NetworkService.disconnectingFrom !== modelData.ssid - text: { - if (modelData.existing || modelData.cached) - return "Connect"; - - if (!NetworkService.isSecured(modelData.security)) - return "Connect"; - - return "Enter Password"; - } - outlined: !hovered - fontSize: Style.fontSizeXS - enabled: !NetworkService.connecting - onClicked: { - if (modelData.existing || modelData.cached || !NetworkService.isSecured(modelData.security)) { - NetworkService.connect(modelData.ssid); - } else { - passwordSsid = modelData.ssid; - passwordInput = ""; - expandedSsid = ""; - } - } - } - - NButton { - visible: modelData.connected && NetworkService.disconnectingFrom !== modelData.ssid - text: "Disconnect" - outlined: !hovered - fontSize: Style.fontSizeXS - backgroundColor: Color.mError - onClicked: NetworkService.disconnect(modelData.ssid) - } - - } - - } - - // Password input - Rectangle { - visible: passwordSsid === modelData.ssid && NetworkService.disconnectingFrom !== modelData.ssid && NetworkService.forgettingNetwork !== modelData.ssid - Layout.fillWidth: true - height: passwordRow.implicitHeight + Style.marginS * 2 - color: Color.mSurfaceVariant - border.color: Color.mOutline - border.width: Math.max(1, Style.borderS) - radius: Style.radiusS - - RowLayout { - id: passwordRow - - anchors.fill: parent - anchors.margins: Style.marginS - spacing: Style.marginM - - Rectangle { - Layout.fillWidth: true - Layout.fillHeight: true - radius: Style.radiusXS - color: Color.mSurface - border.color: pwdInput.activeFocus ? Color.mSecondary : Color.mOutline - border.width: Math.max(1, Style.borderS) - - TextInput { - id: pwdInput - - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - anchors.margins: Style.marginS - text: passwordInput - font.family: Fonts.sans - font.pointSize: Style.fontSizeS - color: Color.mOnSurface - echoMode: TextInput.Password - selectByMouse: true - focus: visible - passwordCharacter: "●" - onTextChanged: passwordInput = text - onVisibleChanged: { - if (visible) - forceActiveFocus(); - - } - onAccepted: { - if (text && !NetworkService.connecting) { - NetworkService.connect(passwordSsid, text); - passwordSsid = ""; - passwordInput = ""; - } - } - - NText { - visible: parent.text.length === 0 - anchors.verticalCenter: parent.verticalCenter - text: "Enter Password" - color: Color.mOnSurfaceVariant - pointSize: Style.fontSizeS - } - - } - - } - - NButton { - text: "Connect" - fontSize: Style.fontSizeXXS - enabled: passwordInput.length > 0 && !NetworkService.connecting - outlined: true - onClicked: { - NetworkService.connect(passwordSsid, passwordInput); - passwordSsid = ""; - passwordInput = ""; - } - } - - NIconButton { - icon: "close" - baseSize: Style.baseWidgetSize * 0.8 - onClicked: { - passwordSsid = ""; - passwordInput = ""; - } - } - - } - - } - - // Forget network - Rectangle { - visible: expandedSsid === modelData.ssid && NetworkService.disconnectingFrom !== modelData.ssid && NetworkService.forgettingNetwork !== modelData.ssid - Layout.fillWidth: true - height: forgetRow.implicitHeight + Style.marginS * 2 - color: Color.mSurfaceVariant - radius: Style.radiusS - border.width: Math.max(1, Style.borderS) - border.color: Color.mOutline - - RowLayout { - id: forgetRow - - anchors.fill: parent - anchors.margins: Style.marginS - spacing: Style.marginM - - RowLayout { - NIcon { - icon: "trash" - pointSize: Style.fontSizeL - color: Color.mError - } - - NText { - text: "Forget this network?" - pointSize: Style.fontSizeS - color: Color.mError - Layout.fillWidth: true - } - - } - - NButton { - id: forgetButton - - text: "Forget" - fontSize: Style.fontSizeXXS - backgroundColor: Color.mError - outlined: forgetButton.hovered ? false : true - onClicked: { - NetworkService.forget(modelData.ssid); - expandedSsid = ""; - } - } - - NIconButton { - icon: "close" - baseSize: Style.baseWidgetSize * 0.8 - onClicked: expandedSsid = "" - } - - } - - } - - } - - // Smooth opacity animation - Behavior on opacity { - NumberAnimation { - duration: Style.animationNormal - } - - } - - } - - } - - } - - } - - // Empty state when no networks - ColumnLayout { - visible: SettingsService.wifiEnabled && !NetworkService.scanning && Object.keys(NetworkService.networks).length === 0 - anchors.fill: parent - spacing: Style.marginL - - Item { - Layout.fillHeight: true - } - - NIcon { - icon: "search" - pointSize: 64 - color: Color.mOnSurfaceVariant - Layout.alignment: Qt.AlignHCenter - } - - NText { - text: "No networks found" - pointSize: Style.fontSizeL - color: Color.mOnSurfaceVariant - Layout.alignment: Qt.AlignHCenter - } - - NButton { - text: "Scan Again" - icon: "refresh" - Layout.alignment: Qt.AlignHCenter - onClicked: NetworkService.scan() - } - - Item { - Layout.fillHeight: true - } - - } - - } - - } - - } - -} diff --git a/config/quickshell/.config/quickshell/Modules/Sidebar/Components/SidebarWrap.qml b/config/quickshell/.config/quickshell/Modules/Sidebar/Components/SidebarWrap.qml new file mode 100644 index 0000000..bfc932e --- /dev/null +++ b/config/quickshell/.config/quickshell/Modules/Sidebar/Components/SidebarWrap.qml @@ -0,0 +1,79 @@ +import QtQuick +import Quickshell +import Quickshell.Wayland +import qs.Constants +import qs.Modules.Misc +import qs.Modules.Sidebar +import qs.Services + +PanelWindow { + id: root + + property int barWidth: Style.sidebarWidth + property int realWidth: 0 + property bool isOpen: false + property Component contentComponent: null + property bool isLeft: true + + function open() { + realWidth = barWidth; + isOpen = true; + } + + function close() { + realWidth = 0; + isOpen = false; + } + + Component.onCompleted: { + if (root.isLeft) + BarService.registerLeft(modelData.name, root); + else + BarService.registerRight(modelData.name, root); + } + screen: modelData + WlrLayershell.namespace: root.isLeft ? "quickshell-sidebar-left" : "quickshell-sidebar-right" + WlrLayershell.layer: WlrLayer.Top + WlrLayershell.exclusionMode: ExclusionMode.Ignore + implicitWidth: realWidth > 0 ? barWidth : 0 // jump change for better performance (maybe) + visible: realWidth > 0 + margins.top: Style.barHeight + color: Colors.transparent + + anchors { + left: isLeft + top: true + bottom: true + right: !isLeft + } + + Rectangle { + id: sidebarContent + + width: root.barWidth + height: parent.height + x: isLeft ? root.realWidth - root.barWidth : root.barWidth - root.realWidth + color: Colors.mSurface + + Loader { + anchors.fill: parent + active: root.realWidth > 0 + asynchronous: true + sourceComponent: root.contentComponent + } + + } + + mask: Region { + item: sidebarContent + } + + Behavior on realWidth { + NumberAnimation { + duration: Style.animationSlow + easing.type: Easing.InOutCubic + } + + } + +} diff --git a/config/quickshell/.config/quickshell/Modules/Panel/Misc/BluetoothDevicesList.qml b/config/quickshell/.config/quickshell/Modules/Sidebar/Misc/BluetoothDevicesList.qml similarity index 76% rename from config/quickshell/.config/quickshell/Modules/Panel/Misc/BluetoothDevicesList.qml rename to config/quickshell/.config/quickshell/Modules/Sidebar/Misc/BluetoothDevicesList.qml index af6d165..539d6ef 100644 --- a/config/quickshell/.config/quickshell/Modules/Panel/Misc/BluetoothDevicesList.qml +++ b/config/quickshell/.config/quickshell/Modules/Sidebar/Misc/BluetoothDevicesList.qml @@ -5,7 +5,7 @@ import Quickshell import Quickshell.Bluetooth import Quickshell.Wayland import qs.Constants -import qs.Noctalia +import qs.Components import qs.Services ColumnLayout { @@ -17,12 +17,12 @@ ColumnLayout { } Layout.fillWidth: true - spacing: Style.marginM + spacing: Style.marginS - NText { + UText { text: root.label pointSize: Style.fontSizeL - color: Color.mSecondary + color: Colors.mPrimary font.weight: Style.fontWeightMedium Layout.fillWidth: true visible: root.model.length > 0 @@ -35,39 +35,41 @@ ColumnLayout { model: root.model visible: BluetoothService.adapter && BluetoothService.adapter.enabled - NBox { + UBox { id: device readonly property bool canConnect: BluetoothService.canConnect(modelData) readonly property bool canDisconnect: BluetoothService.canDisconnect(modelData) readonly property bool isBusy: BluetoothService.isDeviceBusy(modelData) - function getContentColor(defaultColor = Color.mOnSurface) { + compact: true + + function getContentColor(defaultColor = Colors.mOnSurface) { if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting) - return Color.mPrimary; + return Colors.mPrimary; if (modelData.blocked) - return Color.mError; + return Colors.mError; return defaultColor; } Layout.fillWidth: true - Layout.preferredHeight: deviceLayout.implicitHeight + (Style.marginM * 2) + Layout.preferredHeight: deviceLayout.implicitHeight + (Style.marginS * 2) RowLayout { id: deviceLayout anchors.fill: parent - anchors.margins: Style.marginM - spacing: Style.marginM + anchors.margins: Style.marginS + spacing: Style.marginS Layout.alignment: Qt.AlignVCenter // One device BT icon - NIcon { - icon: BluetoothService.getDeviceIcon(modelData) - pointSize: Style.fontSizeXXL - color: getContentColor(Color.mOnSurface) + UIcon { + iconName: BluetoothService.getDeviceIcon(modelData) + iconSize: Style.fontSizeXXL + color: getContentColor(Colors.mOnSurface) Layout.alignment: Qt.AlignVCenter } @@ -76,21 +78,21 @@ ColumnLayout { spacing: Style.marginXXS // Device name - NText { + UText { text: modelData.name || modelData.deviceName pointSize: Style.fontSizeM font.weight: Style.fontWeightMedium elide: Text.ElideRight - color: getContentColor(Color.mOnSurface) + color: getContentColor(Colors.mOnSurface) Layout.fillWidth: true } // Status - NText { + UText { text: BluetoothService.getStatusString(modelData) visible: text !== "" pointSize: Style.fontSizeXS - color: getContentColor(Color.mOnSurfaceVariant) + color: getContentColor(Colors.mOnSurfaceVariant) } // Signal Strength @@ -100,34 +102,34 @@ ColumnLayout { spacing: Style.marginXS // Device signal strength - "Unknown" when not connected - NText { + UText { text: BluetoothService.getSignalStrength(modelData) pointSize: Style.fontSizeXS - color: getContentColor(Color.mOnSurfaceVariant) + color: getContentColor(Colors.mOnSurfaceVariant) } - NIcon { + UIcon { visible: modelData.signalStrength > 0 && !modelData.pairing && !modelData.blocked - icon: BluetoothService.getSignalIcon(modelData) - pointSize: Style.fontSizeXS - color: getContentColor(Color.mOnSurface) + iconName: BluetoothService.getSignalIcon(modelData) + iconSize: Style.fontSizeXS + color: getContentColor(Colors.mOnSurface) } - NText { + UText { visible: modelData.signalStrength > 0 && !modelData.pairing && !modelData.blocked text: (modelData.signalStrength !== undefined && modelData.signalStrength > 0) ? modelData.signalStrength + "%" : "" pointSize: Style.fontSizeXS - color: getContentColor(Color.mOnSurface) + color: getContentColor(Colors.mOnSurface) } } // Battery - NText { + UText { visible: modelData.batteryAvailable text: BluetoothService.getBattery(modelData) pointSize: Style.fontSizeXS - color: getContentColor(Color.mOnSurfaceVariant) + color: getContentColor(Colors.mOnSurfaceVariant) } } @@ -138,19 +140,18 @@ ColumnLayout { } // Call to action - NButton { + UButton { id: button visible: (modelData.state !== BluetoothDeviceState.Connecting) enabled: (canConnect || canDisconnect) && !isBusy - outlined: !button.hovered fontSize: Style.fontSizeXS fontWeight: Style.fontWeightMedium backgroundColor: { if (device.canDisconnect && !isBusy) - return Color.mError; + return Colors.mError; - return Color.mPrimary; + return Colors.mPrimary; } text: { if (modelData.pairing) diff --git a/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/BluetoothCard.qml b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/BluetoothCard.qml new file mode 100644 index 0000000..aee94a8 --- /dev/null +++ b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/BluetoothCard.qml @@ -0,0 +1,157 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Bluetooth +import Quickshell.Wayland +import qs.Constants +import qs.Services +import qs.Components +import qs.Modules.Sidebar.Misc + +ColumnLayout { + spacing: Style.marginM + + Rectangle { + visible: !(BluetoothService.adapter && BluetoothService.adapter.enabled) + Layout.fillWidth: true + Layout.fillHeight: true + color: Colors.transparent + + // Center the content within this rectangle + ColumnLayout { + anchors.centerIn: parent + spacing: Style.marginM + + UIcon { + iconName: "bluetooth-off" + iconSize: 64 + color: Colors.mOnSurfaceVariant + Layout.alignment: Qt.AlignHCenter + } + + UText { + text: "Bluetooth is turned off" + pointSize: Style.fontSizeL + color: Colors.mOnSurfaceVariant + Layout.alignment: Qt.AlignHCenter + } + + UText { + text: "Enable Bluetooth" + pointSize: Style.fontSizeS + color: Colors.mOnSurfaceVariant + Layout.alignment: Qt.AlignHCenter + } + + } + + } + + UScrollView { + visible: BluetoothService.adapter && BluetoothService.adapter.enabled + Layout.fillWidth: true + Layout.fillHeight: true + horizontalPolicy: ScrollBar.AlwaysOff + verticalPolicy: ScrollBar.AsNeeded + clip: true + contentWidth: availableWidth + + ColumnLayout { + width: parent.width + spacing: Style.marginM + + // Connected devices + BluetoothDevicesList { + property var items: { + if (!BluetoothService.adapter || !Bluetooth.devices) + return []; + + var filtered = Bluetooth.devices.values.filter((dev) => { + return dev && !dev.blocked && dev.connected; + }); + return BluetoothService.sortDevices(filtered); + } + + label: "Connected Devices" + model: items + visible: items.length > 0 + Layout.fillWidth: true + } + + // Known devices + BluetoothDevicesList { + property var items: { + if (!BluetoothService.adapter || !Bluetooth.devices) + return []; + + var filtered = Bluetooth.devices.values.filter((dev) => { + return dev && !dev.blocked && !dev.connected && (dev.paired || dev.trusted); + }); + return BluetoothService.sortDevices(filtered); + } + + label: "Known Devices" + model: items + visible: items.length > 0 + Layout.fillWidth: true + } + + // Available devices + BluetoothDevicesList { + property var items: { + if (!BluetoothService.adapter || !Bluetooth.devices) + return []; + + var filtered = Bluetooth.devices.values.filter((dev) => { + return dev && !dev.blocked && !dev.paired && !dev.trusted; + }); + return BluetoothService.sortDevices(filtered); + } + + label: "Available Devices" + model: items + visible: items.length > 0 + Layout.fillWidth: true + } + + // Fallback - No devices, scanning + ColumnLayout { + Layout.alignment: Qt.AlignHCenter + spacing: Style.marginL + + visible: { + if (!BluetoothService.adapter || !BluetoothService.adapter.discovering || !Bluetooth.devices) + return false; + + var availableCount = Bluetooth.devices.values.filter((dev) => { + return dev && !dev.paired && !dev.pairing && !dev.blocked && (dev.signalStrength === undefined || dev.signalStrength > 0); + }).length; + return (availableCount === 0); + } + + UBusyIndicator { + Layout.alignment: Qt.AlignHCenter + running: true + width: Style.fontSizeL + height: Style.fontSizeL + } + + UText { + text: "Pairing Mode" + pointSize: Style.fontSizeM + color: Colors.mOnSurfaceVariant + Layout.alignment: Qt.AlignHCenter + } + + } + + Item { + Layout.fillHeight: true + } + + } + + } + +} diff --git a/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/CalendarHeaderCard.qml b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/CalendarHeaderCard.qml new file mode 100644 index 0000000..6f37dce --- /dev/null +++ b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/CalendarHeaderCard.qml @@ -0,0 +1,133 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import qs.Components +import qs.Constants +import qs.Services +import qs.Utils + +UBox { + id: root + + // Internal state + readonly property var now: Time.now + readonly property bool weatherReady: LocationService.data.weather !== null + // Expose current month/year for potential synchronization with CalendarMonthCard + readonly property int currentMonth: now.getMonth() + readonly property int currentYear: now.getFullYear() + + implicitHeight: (60) + Style.marginM * 2 + color: Colors.mPrimary + + ColumnLayout { + id: capsuleColumn + + anchors.top: parent.top + anchors.left: parent.left + anchors.bottom: parent.bottom + anchors.topMargin: Style.marginM + anchors.bottomMargin: Style.marginM + anchors.rightMargin: clockLoader.width + Style.marginXL * 2 + anchors.leftMargin: Style.marginXL + spacing: 0 + + // Combined layout for date, month year, location and time-zone + RowLayout { + Layout.fillWidth: true + height: 60 + clip: true + spacing: Style.marginS + + // Today day number + UText { + Layout.preferredWidth: implicitWidth + elide: Text.ElideNone + clip: true + Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft + text: root.now.getDate() + pointSize: Style.fontSizeXXXL * 1.5 + font.weight: Style.fontWeightBold + color: Colors.mOnPrimary + } + + // Month, year, location + ColumnLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft + Layout.bottomMargin: Style.marginXXS + Layout.topMargin: -Style.marginXXS + spacing: -Style.marginXS + + RowLayout { + spacing: Style.marginS + + UText { + text: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][root.currentMonth] + pointSize: Style.fontSizeXL * 1.1 + font.weight: Style.fontWeightBold + color: Colors.mOnPrimary + Layout.alignment: Qt.AlignBaseline + elide: Text.ElideRight + } + + UText { + text: `${root.currentYear}` + pointSize: Style.fontSizeM + font.weight: Style.fontWeightBold + color: Qt.alpha(Colors.mOnPrimary, 0.7) + Layout.alignment: Qt.AlignBaseline + } + + } + + RowLayout { + spacing: 0 + + UText { + text: { + if (!root.weatherReady) + return "Loading weather..."; + + const chunks = SettingsService.location.split(","); + return chunks[0]; + } + pointSize: Style.fontSizeM + color: Colors.mOnPrimary + Layout.maximumWidth: 150 + elide: Text.ElideRight + } + + UText { + text: root.weatherReady && ` (${LocationService.data.weather.timezone_abbreviation})` + pointSize: Style.fontSizeXS + color: Qt.alpha(Colors.mOnPrimary, 0.7) + } + + } + + } + + // Spacer + Item { + Layout.fillWidth: true + } + + } + + } + + // Analog/Digital clock + UClock { + id: clockLoader + + anchors.right: parent.right + anchors.rightMargin: Style.marginXL + anchors.verticalCenter: parent.verticalCenter + clockStyle: "analog" + progressColor: Colors.mOnPrimary + Layout.alignment: Qt.AlignVCenter + now: root.now + } + +} diff --git a/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/CalendarMonthCard.qml b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/CalendarMonthCard.qml new file mode 100644 index 0000000..ad0bd12 --- /dev/null +++ b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/CalendarMonthCard.qml @@ -0,0 +1,344 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import qs.Components +import qs.Constants +import qs.Utils + +// Calendar month grid with navigation +UBox { + id: root + + // Internal state - independent from header + readonly property var now: Time.now + property int calendarMonth: now.getMonth() + property int calendarYear: now.getFullYear() + readonly property var locale: Qt.locale("en") + readonly property int firstDayOfWeek: locale.firstDayOfWeek + + // Helper function to calculate ISO week number + function getISOWeekNumber(date) { + const target = new Date(date.valueOf()); + const dayNr = (date.getDay() + 6) % 7; + target.setDate(target.getDate() - dayNr + 3); + const firstThursday = new Date(target.getFullYear(), 0, 4); + const diff = target - firstThursday; + const oneWeek = 1000 * 60 * 60 * 24 * 7; + const weekNumber = 1 + Math.round(diff / oneWeek); + return weekNumber; + } + + // Helper function to check if an event is all-day + function isAllDayEvent(event) { + const duration = event.end - event.start; + const startDate = new Date(event.start * 1000); + const isAtMidnight = startDate.getHours() === 0 && startDate.getMinutes() === 0; + return duration === 86400 && isAtMidnight; + } + + // Navigation functions + function navigateToPreviousMonth() { + let newDate = new Date(root.calendarYear, root.calendarMonth - 1, 1); + root.calendarYear = newDate.getFullYear(); + root.calendarMonth = newDate.getMonth(); + const now = new Date(); + const monthStart = new Date(root.calendarYear, root.calendarMonth, 1); + const monthEnd = new Date(root.calendarYear, root.calendarMonth + 1, 0); + const daysBehind = Math.max(0, Math.ceil((now - monthStart) / (24 * 60 * 60 * 1000))); + const daysAhead = Math.max(0, Math.ceil((monthEnd - now) / (24 * 60 * 60 * 1000))); + } + + function navigateToNextMonth() { + let newDate = new Date(root.calendarYear, root.calendarMonth + 1, 1); + root.calendarYear = newDate.getFullYear(); + root.calendarMonth = newDate.getMonth(); + const now = new Date(); + const monthStart = new Date(root.calendarYear, root.calendarMonth, 1); + const monthEnd = new Date(root.calendarYear, root.calendarMonth + 1, 0); + const daysBehind = Math.max(0, Math.ceil((now - monthStart) / (24 * 60 * 60 * 1000))); + const daysAhead = Math.max(0, Math.ceil((monthEnd - now) / (24 * 60 * 60 * 1000))); + } + + Layout.fillWidth: true + implicitHeight: calendarContent.implicitHeight + Style.marginM * 2 + compact: true + + // Wheel handler for month navigation + WheelHandler { + id: wheelHandler + + target: root + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + onWheel: function(event) { + if (event.angleDelta.y > 0) { + // Scroll up - go to previous month + root.navigateToPreviousMonth(); + event.accepted = true; + } else if (event.angleDelta.y < 0) { + // Scroll down - go to next month + root.navigateToNextMonth(); + event.accepted = true; + } + } + } + + ColumnLayout { + id: calendarContent + + anchors.fill: parent + anchors.margins: Style.marginM + spacing: Style.marginS + + // Navigation row + RowLayout { + Layout.fillWidth: true + spacing: Style.marginS + + Item { + Layout.preferredWidth: Style.marginS + } + + UText { + text: locale.monthName(root.calendarMonth, Locale.LongFormat).toUpperCase() + " " + root.calendarYear + pointSize: Style.fontSizeM + font.weight: Style.fontWeightBold + color: Colors.mOnSurface + } + + UDivider { + Layout.fillWidth: true + } + + UIconButton { + iconName: "chevron-left" + onClicked: root.navigateToPreviousMonth() + } + + UIconButton { + iconName: "calendar" + onClicked: { + root.calendarMonth = root.now.getMonth(); + root.calendarYear = root.now.getFullYear(); + } + } + + UIconButton { + iconName: "chevron-right" + onClicked: root.navigateToNextMonth() + } + + } + + // Day names header + RowLayout { + Layout.fillWidth: true + spacing: 0 + + Item { + Layout.preferredWidth: visible ? Style.baseWidgetSize * 0.7 : 0 + } + + GridLayout { + Layout.fillWidth: true + columns: 7 + rows: 1 + columnSpacing: 0 + rowSpacing: 0 + + Repeater { + model: 7 + + Item { + Layout.fillWidth: true + Layout.preferredHeight: Style.fontSizeS * 2 + + UText { + anchors.centerIn: parent + text: { + let dayIndex = (root.firstDayOfWeek + index) % 7; + const dayName = locale.dayName(dayIndex, Locale.ShortFormat); + return dayName.substring(0, 2).toUpperCase(); + } + color: Colors.mPrimary + pointSize: Style.fontSizeS + font.weight: Style.fontWeightBold + horizontalAlignment: Text.AlignHCenter + } + + } + + } + + } + + } + + // Calendar grid with week numbers + RowLayout { + Layout.fillWidth: true + spacing: 0 + + // Week numbers column + ColumnLayout { + property var weekNumbers: { + if (!grid.daysModel || grid.daysModel.length === 0) + return []; + + const weeks = []; + const numWeeks = Math.ceil(grid.daysModel.length / 7); + for (var i = 0; i < numWeeks; i++) { + const dayIndex = i * 7; + if (dayIndex < grid.daysModel.length) { + const weekDay = grid.daysModel[dayIndex]; + const date = new Date(weekDay.year, weekDay.month, weekDay.day); + let thursday = new Date(date); + if (root.firstDayOfWeek === 0) { + thursday.setDate(date.getDate() + 4); + } else if (root.firstDayOfWeek === 1) { + thursday.setDate(date.getDate() + 3); + } else { + let daysToThursday = (4 - root.firstDayOfWeek + 7) % 7; + thursday.setDate(date.getDate() + daysToThursday); + } + weeks.push(root.getISOWeekNumber(thursday)); + } + } + return weeks; + } + + Layout.preferredWidth: Style.baseWidgetSize * 0.7 + Layout.alignment: Qt.AlignTop + spacing: Style.marginXXS + + Repeater { + model: parent.weekNumbers + + Item { + Layout.preferredWidth: Style.baseWidgetSize * 0.7 + Layout.preferredHeight: Style.baseWidgetSize * 0.9 + + UText { + anchors.centerIn: parent + color: Qt.alpha(Colors.mPrimary, 0.7) + pointSize: Style.fontSizeXXS + text: modelData + } + + } + + } + + } + + // Calendar grid + GridLayout { + id: grid + + property int month: root.calendarMonth + property int year: root.calendarYear + property var daysModel: { + const firstOfMonth = new Date(year, month, 1); + const lastOfMonth = new Date(year, month + 1, 0); + const daysInMonth = lastOfMonth.getDate(); + const firstDayOfWeek = root.firstDayOfWeek; + const firstOfMonthDayOfWeek = firstOfMonth.getDay(); + let daysBefore = (firstOfMonthDayOfWeek - firstDayOfWeek + 7) % 7; + const lastOfMonthDayOfWeek = lastOfMonth.getDay(); + const daysAfter = (firstDayOfWeek - lastOfMonthDayOfWeek - 1 + 7) % 7; + const days = []; + const today = new Date(); + // Previous month days + const prevMonth = new Date(year, month, 0); + const prevMonthDays = prevMonth.getDate(); + for (var i = daysBefore - 1; i >= 0; i--) { + const day = prevMonthDays - i; + days.push({ + "day": day, + "month": month - 1, + "year": month === 0 ? year - 1 : year, + "today": false, + "currentMonth": false + }); + } + // Current month days + for (var day = 1; day <= daysInMonth; day++) { + const date = new Date(year, month, day); + const isToday = date.getFullYear() === today.getFullYear() && date.getMonth() === today.getMonth() && date.getDate() === today.getDate(); + days.push({ + "day": day, + "month": month, + "year": year, + "today": isToday, + "currentMonth": true + }); + } + // Next month days + for (var i = 1; i <= daysAfter; i++) { + days.push({ + "day": i, + "month": month + 1, + "year": month === 11 ? year + 1 : year, + "today": false, + "currentMonth": false + }); + } + return days; + } + + Layout.fillWidth: true + columns: 7 + columnSpacing: Style.marginXXS + rowSpacing: Style.marginXXS + + Repeater { + model: grid.daysModel + + Item { + Layout.fillWidth: true + Layout.preferredHeight: Style.baseWidgetSize * 0.9 + + Rectangle { + width: Style.baseWidgetSize * 0.9 + height: Style.baseWidgetSize * 0.9 + anchors.centerIn: parent + radius: Style.radiusM + color: modelData.today ? Colors.mPrimary : "transparent" + + UText { + anchors.centerIn: parent + text: modelData.day + color: { + if (modelData.today) + return Colors.mOnPrimary; + + if (modelData.currentMonth) + return Colors.mOnSurface; + + return Colors.mOnSurfaceVariant; + } + opacity: modelData.currentMonth ? 1 : 0.4 + pointSize: Style.fontSizeM + font.weight: modelData.today ? Style.fontWeightBold : Style.fontWeightMedium + } + + Behavior on color { + ColorAnimation { + duration: Style.animationFast + } + + } + + } + + } + + } + + } + + } + + } + +} diff --git a/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/ConnectionCard.qml b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/ConnectionCard.qml new file mode 100644 index 0000000..b899aa9 --- /dev/null +++ b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/ConnectionCard.qml @@ -0,0 +1,211 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import qs.Components +import qs.Constants +import qs.Services + +UBox { + id: root + + property string currentPanel: "bluetooth" // "bluetooth", "wifi" + + implicitHeight: contentLoader.implicitHeight + toggleGroup.implicitHeight + Style.marginXS * 2 + Style.marginS * 2 + + ColumnLayout { + spacing: Style.marginXS + anchors.fill: parent + anchors.margins: Style.marginS + + RowLayout { + Layout.fillWidth: true + + Rectangle { + id: toggleGroup + + Layout.preferredWidth: Style.baseWidgetSize * 2.8 + Layout.preferredHeight: Style.baseWidgetSize + radius: Math.min(Style.radiusS, height / 2) + color: Colors.mSurface + // border.color: Colors.mOutline + + Row { + anchors.fill: parent + spacing: Style.marginS / 2 + + Rectangle { + id: btnBluetooth + + width: root.currentPanel === "bluetooth" ? (parent.width - parent.spacing) * 0.65 : (parent.width - parent.spacing) * 0.35 + height: parent.height + radius: Math.min(Style.radiusS, height / 2) + color: root.currentPanel === "bluetooth" ? Colors.mPrimary : "transparent" + + Behavior on width { + NumberAnimation { + duration: 250 + easing.type: Easing.OutCubic + } + } + Behavior on color { + ColorAnimation { + duration: 200 + } + } + + UIcon { + anchors.centerIn: parent + iconName: "bluetooth" + iconSize: Style.fontSizeL + color: root.currentPanel === "bluetooth" ? Colors.mOnPrimary : Colors.mOnSurface + + Behavior on color { + ColorAnimation { + duration: 200 + } + } + } + + MouseArea { + anchors.fill: parent + onClicked: root.currentPanel = "bluetooth" + cursorShape: Qt.PointingHandCursor + } + } + + Rectangle { + id: btnWifi + + width: root.currentPanel === "wifi" ? (parent.width - parent.spacing) * 0.65 : (parent.width - parent.spacing) * 0.35 + height: parent.height + radius: Math.min(Style.radiusS, height / 2) + color: root.currentPanel === "wifi" ? Colors.mPrimary : "transparent" + + Behavior on width { + NumberAnimation { + duration: 250 + easing.type: Easing.OutCubic + } + } + Behavior on color { + ColorAnimation { + duration: 200 + } + } + + UIcon { + anchors.centerIn: parent + iconName: "wifi" + iconSize: Style.fontSizeL + color: root.currentPanel === "wifi" ? Colors.mOnPrimary : Colors.mOnSurface + + Behavior on color { + ColorAnimation { + duration: 200 + } + } + } + + MouseArea { + anchors.fill: parent + onClicked: root.currentPanel = "wifi" + cursorShape: Qt.PointingHandCursor + } + } + } + } + + Item { + Layout.fillWidth: true + } + + Loader { + sourceComponent: currentPanel === "bluetooth" ? bluetoothHeaderComponent : wifiHeaderComponent + + Component { + id: bluetoothHeaderComponent + + RowLayout { + UToggle { + id: bluetoothSwitch + + checked: BluetoothService.enabled + onToggled: (checked) => { + return BluetoothService.setBluetoothEnabled(checked); + } + baseSize: Style.baseWidgetSize * 0.65 + } + + UIconButton { + enabled: BluetoothService.enabled + iconName: BluetoothService.adapter && BluetoothService.adapter.discovering ? "stop" : "refresh" + baseSize: Style.baseWidgetSize * 0.8 + onClicked: { + if (BluetoothService.adapter) + BluetoothService.adapter.discovering = !BluetoothService.adapter.discovering; + + } + colorFg: Colors.mGreen + } + } + } + + Component { + id: wifiHeaderComponent + + RowLayout { + UToggle { + id: wifiSwitch + + checked: SettingsService.wifiEnabled + onToggled: (checked) => { + return NetworkService.setWifiEnabled(checked); + } + baseSize: Style.baseWidgetSize * 0.65 + } + + UIconButton { + iconName: "refresh" + baseSize: Style.baseWidgetSize * 0.8 + enabled: SettingsService.wifiEnabled && !NetworkService.scanning + onClicked: NetworkService.scan() + colorFg: Colors.mGreen + } + } + } + } + + } + + UDivider { + Layout.fillWidth: true + } + + Loader { + id: contentLoader + + Layout.fillWidth: true + Layout.fillHeight: true + + sourceComponent: currentPanel === "bluetooth" ? bluetoothComponent : wifiComponent + + Component { + id: bluetoothComponent + + BluetoothCard { + anchors.fill: parent + anchors.margins: Style.marginS + } + } + + Component { + id: wifiComponent + + WifiCard { + anchors.fill: parent + anchors.margins: Style.marginS + } + } + } + } +} diff --git a/config/quickshell/.config/quickshell/Modules/Panel/Cards/LyricsCard.qml b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/LyricsCard.qml similarity index 88% rename from config/quickshell/.config/quickshell/Modules/Panel/Cards/LyricsCard.qml rename to config/quickshell/.config/quickshell/Modules/Sidebar/Modules/LyricsCard.qml index 262d32f..5e7a11b 100644 --- a/config/quickshell/.config/quickshell/Modules/Panel/Cards/LyricsCard.qml +++ b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/LyricsCard.qml @@ -1,12 +1,12 @@ import QtQuick import QtQuick.Layouts import Quickshell +import qs.Components import qs.Constants -import qs.Noctalia import qs.Services import qs.Utils -NBox { +UBox { id: lyricsBox Component.onCompleted: { @@ -25,13 +25,13 @@ NBox { Repeater { model: LyricsService.lyrics - NText { + UText { Layout.fillWidth: true text: modelData - font.pointSize: index === LyricsService.currentIndex ? Style.fontSizeM : Style.fontSizeS + font.pointSize: index === LyricsService.currentIndex ? Style.fontSizeS : Style.fontSizeXS font.weight: index === LyricsService.currentIndex ? Style.fontWeightBold : Style.fontWeightRegular font.family: Fonts.sans - color: index === LyricsService.currentIndex ? Color.mOnSurface : Color.mOnSurfaceVariant + color: index === LyricsService.currentIndex ? Colors.mOnSurface : Colors.mOnSurfaceVariant horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter elide: Text.ElideRight diff --git a/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/LyricsControl.qml b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/LyricsControl.qml new file mode 100644 index 0000000..6be971e --- /dev/null +++ b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/LyricsControl.qml @@ -0,0 +1,111 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import qs.Components +import qs.Constants +import qs.Services + +ColumnLayout { + UText { + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + Layout.maximumWidth: buttonsGrid.width + Layout.bottomMargin: Style.marginS + horizontalAlignment: Text.AlignHCenter + text: (LyricsService.offset > 0 ? "+" + LyricsService.offset : LyricsService.offset) + " ms" + } + + GridLayout { + id: buttonsGrid + + columns: 2 + columnSpacing: Style.marginS + rowSpacing: Style.marginS + + UIconButton { + id: slowerButton + + baseSize: 32 + colorFg: Colors.mCyan + iconName: "arrow-bar-up" + onClicked: { + LyricsService.increaseOffset(); + } + } + + UIconButton { + id: playPauseButton + + baseSize: 32 + colorFg: Colors.mPurple + iconName: "arrow-bar-down" + onClicked: { + LyricsService.decreaseOffset(); + } + } + + UIconButton { + id: nextButton + + baseSize: 32 + colorFg: Colors.mGreen + iconName: "rotate-clockwise" + onClicked: { + LyricsService.resetOffset(); + } + } + + UIconButton { + id: fasterButton + + baseSize: 32 + colorFg: Colors.mRed + iconName: "trash" + onClicked: { + LyricsService.clearCache(); + } + } + + UIconButton { + id: barLyricsButton + + baseSize: 32 + colorFg: Colors.mSky + alwaysHover: LyricsService.showLyricsBar + iconName: "app-window" + onClicked: { + LyricsService.toggleLyricsBar(); + } + } + + UIconButton { + id: textButton + + baseSize: 32 + colorFg: Colors.mYellow + iconName: "align-box-left-bottom" + onClicked: { + LyricsService.showLyricsText(); + controlCenterPanel.close(); + } + } + + UIconButton { + baseSize: 32 + colorFg: Colors.mOrange + alwaysHover: SunsetService.isEnabled + iconName: "sunset-2" + onClicked: SunsetService.toggleSunset() + } + + UIconButton { + baseSize: 32 + colorFg: Colors.mBlue + alwaysHover: MediaService.autoSwitchingPaused + iconName: "lock-square" + onClicked: MediaService.toggleAutoSwitchingPaused() + } + + } + +} diff --git a/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/MediaCard.qml b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/MediaCard.qml new file mode 100644 index 0000000..98c4d8a --- /dev/null +++ b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/MediaCard.qml @@ -0,0 +1,483 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Effects +import QtQuick.Layouts +import Quickshell +import qs.Components +import qs.Constants +import qs.Services +import qs.Utils + +UBox { + id: root + + implicitHeight: 200 + + // Track whether we have an active media player + readonly property bool hasActivePlayer: MediaService.currentPlayer && MediaService.canPlay + + // Wrapper - rounded rect clipper + Item { + anchors.fill: parent + layer.enabled: true + layer.smooth: true + + // Solid color background (always present as base layer) + Rectangle { + anchors.fill: parent + color: Colors.mSurface + } + + // Background image that covers everything + Image { + id: bgImage + + readonly property int dim: 256 + + anchors.fill: parent + visible: source.toString() !== "" + source: MediaService.trackArtUrl + sourceSize: Qt.size(dim, dim) + fillMode: Image.PreserveAspectCrop + layer.enabled: true + layer.smooth: true + + layer.effect: MultiEffect { + blurEnabled: true + blurMax: 8 + blur: 0.33 + } + + } + + // Dark overlay for readability + Rectangle { + anchors.fill: parent + color: Colors.mSurface + opacity: 0.65 + radius: Style.radiusM + } + + // Background visualizer on top of the artwork + Item { + id: visualizerContainer + + anchors.fill: parent + layer.enabled: true + + Item { + anchors.fill: parent + + Cava { + id: cava + + count: 32 + } + + Repeater { + model: cava.values + + Rectangle { + anchors.bottom: parent.bottom + width: (parent.width - (cava.count - 1) * Style.marginXS) / cava.count + height: modelData * parent.height + x: index * (width + Style.marginXS) + color: Colors.mPrimary + radius: width / 2 + opacity: 0.25 + } + + } + + } + + layer.effect: MultiEffect { + maskEnabled: true + maskThresholdMin: 0.5 + maskSpreadAtMin: 0 + + maskSource: ShaderEffectSource { + + sourceItem: Rectangle { + width: root.width + height: root.height + radius: Style.radiusM + color: "white" + } + + } + + } + + } + + layer.effect: MultiEffect { + maskEnabled: true + maskThresholdMin: 0.95 + maskSpreadAtMin: 0.15 + + maskSource: ShaderEffectSource { + + sourceItem: Rectangle { + width: root.width + height: root.height + radius: Style.radiusM + color: "white" + } + + } + + } + + } + + // Player selector + Rectangle { + id: playerSelectorButton + + property var currentPlayer: MediaService.getAvailablePlayers()[MediaService.selectedPlayerIndex] + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: Style.marginXS + anchors.leftMargin: Style.marginM + anchors.rightMargin: Style.marginM + height: Style.baseWidgetSize + visible: MediaService.getAvailablePlayers().length > 1 + radius: Style.radiusM + color: "transparent" + + RowLayout { + anchors.fill: parent + spacing: Style.marginS + + UIcon { + iconName: "caret-down" + iconSize: Style.fontSizeXXL + color: Colors.mOnSurfaceVariant + } + + UText { + text: playerSelectorButton.currentPlayer ? playerSelectorButton.currentPlayer.identity : "" + pointSize: Style.fontSizeXS + color: Colors.mOnSurfaceVariant + Layout.fillWidth: true + } + + } + + MouseArea { + id: playerSelectorMouseArea + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + var menuItems = []; + var players = MediaService.getAvailablePlayers(); + for (var i = 0; i < players.length; i++) { + menuItems.push({ + "label": players[i].identity, + "action": i.toString(), + "icon": "disc", + "enabled": true, + "visible": true + }); + } + playerContextMenu.model = menuItems; + playerContextMenu.openAtItem(playerSelectorButton, 0, playerSelectorButton.height); + } + } + + UContextMenu { + id: playerContextMenu + + parent: root + width: 200 + verticalPolicy: ScrollBar.AlwaysOff + onTriggered: function(action) { + var index = parseInt(action); + if (!isNaN(index)) + MediaService.switchToPlayer(index); + + } + } + + } + + // Content container that adjusts for player selector + Item { + anchors.fill: parent + anchors.topMargin: playerSelectorButton.visible ? (playerSelectorButton.height + Style.marginXS + Style.marginM) : Style.marginM + anchors.leftMargin: Style.marginM + anchors.rightMargin: Style.marginM + anchors.bottomMargin: Style.marginM + + Item { + id: fallback + visible: !root.hasActivePlayer + anchors.fill: parent + + Item { + anchors.centerIn: parent + implicitWidth: Style.fontSizeXXXL * 4 + implicitHeight: Style.fontSizeXXXL * 4 + + // Pulsating audio circles (background) + Repeater { + model: 3 + + Rectangle { + anchors.centerIn: parent + width: parent.width * (1 + index * 0.2) + height: width + radius: width / 2 + color: "transparent" + border.color: Colors.mOnSurfaceVariant + border.width: 2 + opacity: 0 + + SequentialAnimation on opacity { + running: true + loops: Animation.Infinite + + PauseAnimation { + duration: index * 600 + } + + NumberAnimation { + from: 1 + to: 0 + duration: 2000 + easing.type: Easing.OutQuad + } + + } + + SequentialAnimation on scale { + running: true + loops: Animation.Infinite + + PauseAnimation { + duration: index * 600 + } + + NumberAnimation { + from: 0.5 + to: 1.2 + duration: 2000 + easing.type: Easing.OutQuad + } + + } + + } + + } + + // Spinning disc + UIcon { + anchors.centerIn: parent + iconName: "disc" + iconSize: Style.fontSizeXXXL * 3 + color: Colors.mOnSurfaceVariant + + RotationAnimator on rotation { + from: 0 + to: 360 + duration: 8000 + loops: Animation.Infinite + running: true + } + + } + + } + + } + + // MediaPlayer Main Content - use Loader for performance + Loader { + id: mainLoader + + anchors.fill: parent + active: root.hasActivePlayer + + sourceComponent: Item { + Layout.fillWidth: true + Layout.fillHeight: true + + // Exceptionaly we put shadow on text and controls to ease readability + UDropShadow { + anchors.fill: main + source: main + autoPaddingEnabled: true + shadowBlur: 1 + shadowOpacity: 0.9 + shadowHorizontalOffset: 0 + shadowVerticalOffset: 0 + shadowColor: "black" + } + + ColumnLayout { + id: main + + anchors.fill: parent + spacing: Style.marginS + + // Metadata + ColumnLayout { + id: metadata + + Layout.fillWidth: true + Layout.alignment: Qt.AlignLeft + spacing: Style.marginXS + + UText { + visible: MediaService.trackTitle !== "" + text: MediaService.trackTitle + pointSize: Style.fontSizeM + font.weight: Style.fontWeightBold + elide: Text.ElideRight + wrapMode: Text.Wrap + maximumLineCount: 2 + Layout.fillWidth: true + } + + UText { + visible: MediaService.trackArtist !== "" + text: MediaService.trackArtist + color: Colors.mPrimary + pointSize: Style.fontSizeXS + elide: Text.ElideRight + Layout.fillWidth: true + } + + UText { + visible: MediaService.trackAlbum !== "" + text: MediaService.trackAlbum + color: Colors.mOnSurfaceVariant + pointSize: Style.fontSizeS + elide: Text.ElideRight + Layout.fillWidth: true + } + + } + + // Progress slider + Item { + id: progressWrapper + + property real localSeekRatio: -1 + property real lastSentSeekRatio: -1 + property real seekEpsilon: 0.01 + property real progressRatio: { + if (!MediaService.currentPlayer || MediaService.trackLength <= 0) + return 0; + + const r = MediaService.currentPosition / MediaService.trackLength; + if (isNaN(r) || !isFinite(r)) + return 0; + + return Math.max(0, Math.min(1, r)); + } + property real effectiveRatio: (MediaService.isSeeking && localSeekRatio >= 0) ? Math.max(0, Math.min(1, localSeekRatio)) : progressRatio + + visible: (MediaService.currentPlayer && MediaService.trackLength > 0) + Layout.fillWidth: true + height: Style.baseWidgetSize * 0.5 + + Timer { + id: seekDebounce + + interval: 75 + repeat: false + onTriggered: { + if (MediaService.isSeeking && progressWrapper.localSeekRatio >= 0) { + const next = Math.max(0, Math.min(1, progressWrapper.localSeekRatio)); + if (progressWrapper.lastSentSeekRatio < 0 || Math.abs(next - progressWrapper.lastSentSeekRatio) >= progressWrapper.seekEpsilon) { + MediaService.seekByRatio(next); + progressWrapper.lastSentSeekRatio = next; + } + } + } + } + + USlider { + id: progressSlider + + anchors.fill: parent + from: 0 + to: 1 + stepSize: 0 + snapAlways: false + enabled: MediaService.trackLength > 0 && MediaService.canSeek + heightRatio: 0.6 + onMoved: { + progressWrapper.localSeekRatio = value; + seekDebounce.restart(); + } + onPressedChanged: { + if (pressed) { + MediaService.isSeeking = true; + progressWrapper.localSeekRatio = value; + MediaService.seekByRatio(value); + progressWrapper.lastSentSeekRatio = value; + } else { + seekDebounce.stop(); + MediaService.seekByRatio(value); + MediaService.isSeeking = false; + progressWrapper.localSeekRatio = -1; + progressWrapper.lastSentSeekRatio = -1; + } + } + } + + Binding { + target: progressSlider + property: "value" + value: progressWrapper.progressRatio + when: !MediaService.isSeeking + } + + } + + // Media controls + RowLayout { + spacing: Style.marginS + Layout.fillWidth: true + Layout.alignment: Qt.AlignHCenter + + UIconButton { + iconName: "media-prev" + visible: MediaService.canGoPrevious + onClicked: MediaService.canGoPrevious ? MediaService.previous() : { + } + } + + UIconButton { + iconName: MediaService.isPlaying ? "media-pause" : "media-play" + visible: (MediaService.canPlay || MediaService.canPause) + onClicked: (MediaService.canPlay || MediaService.canPause) ? MediaService.playPause() : { + } + } + + UIconButton { + iconName: "media-next" + visible: MediaService.canGoNext + onClicked: MediaService.canGoNext ? MediaService.next() : { + } + } + + } + + } + + } + + } + + } + +} diff --git a/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/NotificationHistoryCard.qml b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/NotificationHistoryCard.qml new file mode 100644 index 0000000..0a55f2d --- /dev/null +++ b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/NotificationHistoryCard.qml @@ -0,0 +1,939 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.Notifications +import Quickshell.Wayland +import qs.Components +import qs.Constants +import qs.Services +import qs.Utils + +// Notification History panel +Rectangle { + // Check if below visible area + // Stop at edges + + id: root + + // Calculate content height based on header + tabs (if visible) + content + property real calculatedHeight: { + if (NotificationService.historyList.count === 0) + return headerBox.implicitHeight + scrollView.implicitHeight + Style.marginL * 2 + Style.marginM; + + return headerBox.implicitHeight + scrollView.implicitHeight + Style.marginL * 2 + Style.marginM; + } + property real contentPreferredHeight: Math.min(root.height, Math.ceil(calculatedHeight)) + property real layoutWidth: Math.max(1, root.width - Style.marginL * 2) + // State (lazy-loaded with root) + property var rangeCounts: [0, 0, 0, 0] + property var lastKnownDate: null // Track the current date to detect day changes + // UI state (lazy-loaded with root) + // 0 = All, 1 = Today, 2 = Yesterday, 3 = Earlier + property int currentRange: 1 + // start on Today by default + property bool groupByDate: true + // Keyboard navigation state + property int focusIndex: -1 + property int actionIndex: -1 // For actions within a notification + + function parseActions(actions) { + try { + return JSON.parse(actions || "[]"); + } catch (e) { + return []; + } + } + + function moveSelection(dir) { + var m = NotificationService.historyList; + if (!m || m.count === 0) + return ; + + var newIndex = focusIndex; + var found = false; + var count = m.count; + // If no selection yet, start from beginning (or end if up) + if (focusIndex === -1) { + if (dir > 0) + newIndex = -1; + else + newIndex = count; + } + // Loop to find next visible item + var loopCount = 0; + while (loopCount < count) { + newIndex += dir; + // Bounds check + if (newIndex < 0 || newIndex >= count) + break; + + var item = m.get(newIndex); + if (item && isInCurrentRange(item.timestamp)) { + found = true; + break; + } + loopCount++; + } + if (found) { + focusIndex = newIndex; + actionIndex = -1; // Reset action selection + scrollToItem(focusIndex); + } + } + + function moveAction(dir) { + if (focusIndex === -1) + return ; + + var item = NotificationService.historyList.get(focusIndex); + if (!item) + return ; + + var actions = parseActions(item.actionsJson); + if (actions.length === 0) + return ; + + var newActionIndex = actionIndex + dir; + // Clamp between -1 (body) and actions.length - 1 + if (newActionIndex < -1) + newActionIndex = -1; + + if (newActionIndex >= actions.length) + newActionIndex = actions.length - 1; + + actionIndex = newActionIndex; + } + + function activateSelection() { + if (focusIndex === -1) + return ; + + var item = NotificationService.historyList.get(focusIndex); + if (!item) + return ; + + if (actionIndex >= 0) { + var actions = parseActions(item.actionsJson); + if (actionIndex < actions.length) + NotificationService.invokeAction(item.id, actions[actionIndex].identifier); + + } else { + var delegate = notificationColumn.children[focusIndex]; + if (!delegate) + return ; + + if (!(delegate.canExpand || delegate.isExpanded)) + return ; + + if (scrollView.expandedId === item.id) + scrollView.expandedId = ""; + else + scrollView.expandedId = item.id; + } + } + + function removeSelection() { + if (focusIndex === -1) + return ; + + var item = NotificationService.historyList.get(focusIndex); + if (!item) + return ; + + NotificationService.removeFromHistory(item.id); + } + + function scrollToItem(index) { + // Find the delegate item + if (index < 0 || index >= notificationColumn.children.length) + return ; + + var item = notificationColumn.children[index]; + if (item && item.visible) { + // Use the internal flickable from NScrollView for accurate scrolling + var flickable = scrollView._internalFlickable; + if (!flickable || !flickable.contentItem) + return ; + + var pos = flickable.contentItem.mapFromItem(item, 0, 0); + var itemY = pos.y; + var itemHeight = item.height; + var currentContentY = flickable.contentY; + var viewHeight = flickable.height; + // Check if above visible area + if (itemY < currentContentY) + flickable.contentY = Math.max(0, itemY - Style.marginM); + else if (itemY + itemHeight > currentContentY + viewHeight) + flickable.contentY = (itemY + itemHeight) - viewHeight + Style.marginM; + } + } + + function resetFocus() { + focusIndex = -1; + actionIndex = -1; + } + + // Helper functions (lazy-loaded with root) + function dateOnly(d) { + return new Date(d.getFullYear(), d.getMonth(), d.getDate()); + } + + function getDateKey(d) { + // Returns a string key for the date (YYYY-MM-DD) for comparison + var date = dateOnly(d); + return date.getFullYear() + "-" + date.getMonth() + "-" + date.getDate(); + } + + function rangeForTimestamp(ts) { + var dt = new Date(ts); + var today = dateOnly(new Date()); + var thatDay = dateOnly(dt); + var diffMs = today - thatDay; + var diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + if (diffDays === 0) + return 0; + + if (diffDays === 1) + return 1; + + return 2; + } + + function recalcRangeCounts() { + var m = NotificationService.historyList; + if (!m || typeof m.count === "undefined" || m.count <= 0) { + root.rangeCounts = [0, 0, 0, 0]; + return ; + } + var counts = [0, 0, 0, 0]; + counts[0] = m.count; + for (var i = 0; i < m.count; ++i) { + var item = m.get(i); + if (!item || typeof item.timestamp === "undefined") + continue; + + var r = rangeForTimestamp(item.timestamp); + counts[r + 1] = counts[r + 1] + 1; + } + root.rangeCounts = counts; + } + + function isInCurrentRange(ts) { + if (currentRange === 0) + return true; + + return rangeForTimestamp(ts) === (currentRange - 1); + } + + function countForRange(range) { + return rangeCounts[range] || 0; + } + + function hasNotificationsInCurrentRange() { + var m = NotificationService.historyList; + if (!m || m.count === 0) + return false; + + for (var i = 0; i < m.count; ++i) { + var item = m.get(i); + if (item && isInCurrentRange(item.timestamp)) + return true; + + } + return false; + } + + color: "transparent" + onCurrentRangeChanged: resetFocus() + Component.onCompleted: { + NotificationService.updateLastSeenTs(); + recalcRangeCounts(); + // Initialize lastKnownDate + lastKnownDate = getDateKey(new Date()); + } + + Connections { + function onCountChanged() { + root.recalcRangeCounts(); + } + + target: NotificationService.historyList + } + + // Timer to check for day changes at midnight + Timer { + // Day has changed, recalculate counts + + id: dayChangeTimer + + interval: 60000 // Check every minute + repeat: true + running: true // Always runs when root exists (panel is open) + onTriggered: { + var currentDateKey = root.getDateKey(new Date()); + if (root.lastKnownDate !== null && root.lastKnownDate !== currentDateKey) + root.recalcRangeCounts(); + + root.lastKnownDate = currentDateKey; + } + } + + ColumnLayout { + id: mainColumn + + anchors.fill: parent + spacing: Style.marginM + + // Header section + UBox { + id: headerBox + + Layout.fillWidth: true + implicitHeight: header.implicitHeight + Style.marginM * 2 + + ColumnLayout { + id: header + + anchors.fill: parent + anchors.margins: Style.marginM + spacing: Style.marginM + + RowLayout { + id: headerRow + + Layout.fillWidth: true + + UIcon { + iconName: "bell" + iconSize: Style.fontSizeXXL + color: Colors.mPrimary + } + + UText { + text: "Notifications" + " (" + root.countForRange(tabsBox.currentIndex) + ")" + pointSize: Style.fontSizeL + font.weight: Style.fontWeightBold + color: Colors.mOnSurface + Layout.fillWidth: true + } + + UIconButton { + iconName: NotificationService.doNotDisturb ? "bell-off" : "bell" + baseSize: Style.baseWidgetSize * 0.8 + colorFg: Colors.mGreen + alwaysHover: NotificationService.doNotDisturb + onClicked: NotificationService.toggleDoNotDisturb() + } + + UIconButton { + // Close panel as there is nothing more to see. + + iconName: "trash" + baseSize: Style.baseWidgetSize * 0.8 + colorFg: Colors.mError + onClicked: { + NotificationService.clearHistory(); + } + } + + } + + // Time range tabs ([All] / [Today] / [Yesterday] / [Earlier]) + UTabBar { + id: tabsBox + + Layout.fillWidth: true + visible: NotificationService.historyList.count > 0 && root.groupByDate + currentIndex: root.currentRange + tabHeight: Math.round(Style.baseWidgetSize * 0.8) + spacing: Style.marginXS + distributeEvenly: true + + UTabButton { + tabIndex: 0 + text: "All" + checked: tabsBox.currentIndex === 0 + onClicked: root.currentRange = 0 + pointSize: Style.fontSizeXS + } + + UTabButton { + tabIndex: 1 + text: "Today" + checked: tabsBox.currentIndex === 1 + onClicked: root.currentRange = 1 + pointSize: Style.fontSizeXS + } + + UTabButton { + tabIndex: 2 + text: "Yesterday" + checked: tabsBox.currentIndex === 2 + onClicked: root.currentRange = 2 + pointSize: Style.fontSizeXS + } + + UTabButton { + tabIndex: 3 + text: "Earlier" + checked: tabsBox.currentIndex === 3 + onClicked: root.currentRange = 3 + pointSize: Style.fontSizeXS + } + + } + + } + + } + + // Notification list container with gradient overlay + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + NScrollView { + id: scrollView + + // Track which notification is expanded + property string expandedId: "" + + anchors.fill: parent + horizontalPolicy: ScrollBar.AlwaysOff + verticalPolicy: ScrollBar.AsNeeded + reserveScrollbarSpace: false + gradientColor: Colors.mSurface + + ColumnLayout { + anchors.fill: parent + spacing: Style.marginM + + // Empty state when no notifications + UBox { + visible: !root.hasNotificationsInCurrentRange() + Layout.fillWidth: true + Layout.preferredHeight: emptyState.implicitHeight + Style.marginM * 2 + + ColumnLayout { + id: emptyState + + anchors.fill: parent + anchors.margins: Style.marginM + spacing: Style.marginM + + Item { + Layout.fillHeight: true + } + + UIcon { + iconName: "bell-off" + iconSize: (NotificationService.historyList.count === 0) ? 48 : Style.baseWidgetSize + color: Colors.mOnSurfaceVariant + Layout.alignment: Qt.AlignHCenter + } + + UText { + text: "No notifications" + pointSize: (NotificationService.historyList.count === 0) ? Style.fontSizeL : Style.fontSizeM + color: Colors.mOnSurfaceVariant + Layout.alignment: Qt.AlignHCenter + } + + UText { + visible: NotificationService.historyList.count === 0 + text: "No notifications in this range" + pointSize: Style.fontSizeS + color: Colors.mOnSurfaceVariant + horizontalAlignment: Text.AlignHCenter + Layout.fillWidth: true + wrapMode: Text.WordWrap + } + + Item { + Layout.fillHeight: true + } + + } + + } + + // Notification list container + Item { + visible: root.hasNotificationsInCurrentRange() + Layout.fillWidth: true + Layout.preferredHeight: notificationColumn.implicitHeight + + Column { + id: notificationColumn + + anchors.fill: parent + spacing: Style.marginS + + Repeater { + model: NotificationService.historyList + + delegate: Item { + id: notificationDelegate + + property int listIndex: index + property string notificationId: model.id + property string appName: model.appName || "" + property bool isExpanded: scrollView.expandedId === notificationId + property bool canExpand: summaryText.truncated || bodyText.truncated + property real swipeOffset: 0 + property real pressGlobalX: 0 + property real pressGlobalY: 0 + property bool isSwiping: false + property bool isRemoving: false + property string pendingLink: "" + readonly property real swipeStartThreshold: 16 + readonly property real swipeDismissThreshold: Math.max(110, width * 0.3) + readonly property int removeAnimationDuration: Style.animationNormal + readonly property int notificationTextFormat: notificationDelegate.isExpanded ? Text.MarkdownText : Text.StyledText + readonly property real actionButtonSize: Style.baseWidgetSize * 0.7 + readonly property real buttonClusterWidth: notificationDelegate.actionButtonSize * 2 + Style.marginXS + readonly property real iconSize: 40 + // Parse actions safely + property var actionsList: parseActions(model.actionsJson) + property bool isFocused: index === root.focusIndex + + function isSafeLink(link) { + if (!link) + return false; + + const lower = link.toLowerCase(); + const schemes = ["http://", "https://", "mailto:"]; + return schemes.some((scheme) => { + return lower.startsWith(scheme); + }); + } + + function linkAtPoint(x, y) { + if (!notificationDelegate.isExpanded) + return ""; + + if (summaryText) { + const summaryPoint = summaryText.mapFromItem(historyInteractionArea, x, y); + if (summaryPoint.x >= 0 && summaryPoint.y >= 0 && summaryPoint.x <= summaryText.width && summaryPoint.y <= summaryText.height) { + const summaryLink = summaryText.linkAt ? summaryText.linkAt(summaryPoint.x, summaryPoint.y) : ""; + if (isSafeLink(summaryLink)) + return summaryLink; + + } + } + if (bodyText) { + const bodyPoint = bodyText.mapFromItem(historyInteractionArea, x, y); + if (bodyPoint.x >= 0 && bodyPoint.y >= 0 && bodyPoint.x <= bodyText.width && bodyPoint.y <= bodyText.height) { + const bodyLink = bodyText.linkAt ? bodyText.linkAt(bodyPoint.x, bodyPoint.y) : ""; + if (isSafeLink(bodyLink)) + return bodyLink; + + } + } + return ""; + } + + function updateCursorAt(x, y) { + if (notificationDelegate.isExpanded && notificationDelegate.linkAtPoint(x, y)) + historyInteractionArea.cursorShape = Qt.PointingHandCursor; + else + historyInteractionArea.cursorShape = Qt.ArrowCursor; + } + + function dismissBySwipe() { + if (isRemoving) + return ; + + isRemoving = true; + isSwiping = false; + swipeOffset = swipeOffset >= 0 ? width + Style.marginL : -width - Style.marginL; + opacity = 0; + removeTimer.restart(); + } + + width: parent.width + visible: root.isInCurrentRange(model.timestamp) + height: visible && !isRemoving ? contentColumn.height + Style.marginM * 2 : 0 + onVisibleChanged: { + if (!visible) { + notificationDelegate.isSwiping = false; + notificationDelegate.swipeOffset = 0; + notificationDelegate.opacity = 1; + notificationDelegate.isRemoving = false; + removeTimer.stop(); + } + } + Component.onDestruction: removeTimer.stop() + + Timer { + id: removeTimer + + interval: notificationDelegate.removeAnimationDuration + repeat: false + onTriggered: NotificationService.removeFromHistory(notificationId) + } + + Rectangle { + // return "transparent"; + + anchors.fill: parent + radius: Style.radiusM + color: Colors.mSurfaceVariant + border.color: { + if (notificationDelegate.isFocused) + return Colors.mPrimary; + + return Qt.alpha(Colors.mOutline, Style.opacityHeavy); + } + border.width: notificationDelegate.isFocused ? Style.borderM : Style.borderS + + Behavior on color { + enabled: true + + ColorAnimation { + duration: Style.animationFast + } + + } + + } + + // Click to expand/collapse + MouseArea { + id: historyInteractionArea + + anchors.fill: parent + anchors.rightMargin: notificationDelegate.buttonClusterWidth + Style.marginM + enabled: !notificationDelegate.isRemoving + hoverEnabled: true + cursorShape: Qt.ArrowCursor + onPressed: (mouse) => { + root.focusIndex = index; + root.actionIndex = -1; + if (notificationDelegate.isExpanded) { + const link = notificationDelegate.linkAtPoint(mouse.x, mouse.y); + if (link) + notificationDelegate.pendingLink = link; + else + notificationDelegate.pendingLink = ""; + } + if (mouse.button !== Qt.LeftButton) + return ; + + const globalPoint = historyInteractionArea.mapToGlobal(mouse.x, mouse.y); + notificationDelegate.pressGlobalX = globalPoint.x; + notificationDelegate.pressGlobalY = globalPoint.y; + notificationDelegate.isSwiping = false; + } + onPositionChanged: (mouse) => { + if (!(mouse.buttons & Qt.LeftButton) || notificationDelegate.isRemoving) + return ; + + const globalPoint = historyInteractionArea.mapToGlobal(mouse.x, mouse.y); + const deltaX = globalPoint.x - notificationDelegate.pressGlobalX; + const deltaY = globalPoint.y - notificationDelegate.pressGlobalY; + if (!notificationDelegate.isSwiping) { + if (Math.abs(deltaX) < notificationDelegate.swipeStartThreshold) + return ; + + // Only start a swipe-dismiss when horizontal movement is dominant. + if (Math.abs(deltaX) <= Math.abs(deltaY) * 1.15) + return ; + + notificationDelegate.isSwiping = true; + } + if (notificationDelegate.pendingLink && Math.abs(deltaX) >= notificationDelegate.swipeStartThreshold) + notificationDelegate.pendingLink = ""; + + notificationDelegate.swipeOffset = deltaX; + } + onReleased: (mouse) => { + if (mouse.button !== Qt.LeftButton) + return ; + + if (notificationDelegate.isSwiping) { + if (Math.abs(notificationDelegate.swipeOffset) >= notificationDelegate.swipeDismissThreshold) + notificationDelegate.dismissBySwipe(); + else + notificationDelegate.swipeOffset = 0; + notificationDelegate.isSwiping = false; + notificationDelegate.pendingLink = ""; + return ; + } + if (notificationDelegate.pendingLink) { + Qt.openUrlExternally(notificationDelegate.pendingLink); + notificationDelegate.pendingLink = ""; + return ; + } + // Focus sender window (and invoke default action if available) + var actions = notificationDelegate.actionsList; + var hasDefault = actions.some(function(a) { + return a.identifier === "default"; + }); + if (hasDefault) { + NotificationService.focusSenderWindow(notificationDelegate.appName); + NotificationService.invokeAction(notificationDelegate.notificationId, "default"); + } else { + NotificationService.focusSenderWindow(notificationDelegate.appName); + } + } + onCanceled: { + notificationDelegate.isSwiping = false; + notificationDelegate.swipeOffset = 0; + notificationDelegate.pendingLink = ""; + historyInteractionArea.cursorShape = Qt.ArrowCursor; + } + } + + HoverHandler { + target: historyInteractionArea + onPointChanged: notificationDelegate.updateCursorAt(point.position.x, point.position.y) + onActiveChanged: { + if (!active) + historyInteractionArea.cursorShape = Qt.ArrowCursor; + + } + } + + Column { + id: contentColumn + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Style.marginM + spacing: Style.marginM + + Row { + width: parent.width + spacing: Style.marginM + + // Icon + UImageRounded { + anchors.verticalCenter: notificationDelegate.isExpanded ? undefined : parent.verticalCenter + width: notificationDelegate.iconSize + height: notificationDelegate.iconSize + radius: Math.min(Style.radiusL, width / 2) + imagePath: model.cachedImage || model.originalImage || "" + borderColor: "transparent" + borderWidth: 0 + fallbackIcon: "bell" + fallbackIconSize: 24 + } + + // Content + Column { + width: parent.width - notificationDelegate.iconSize - notificationDelegate.buttonClusterWidth - Style.marginM * 2 + spacing: Style.marginXS + + // Header row with app name and timestamp + Row { + width: parent.width + spacing: Style.marginS + + // Urgency indicator + Rectangle { + width: 6 + height: 6 + anchors.verticalCenter: parent.verticalCenter + radius: 3 + visible: model.urgency !== 1 + color: { + if (model.urgency === 2) + return Colors.mError; + else if (model.urgency === 0) + return Colors.mOnSurfaceVariant; + else + return "transparent"; + } + } + + UText { + text: model.appName || "Unknown App" + pointSize: Style.fontSizeXS + font.weight: Style.fontWeightBold + color: Colors.mPrimary + } + + UText { + textFormat: Text.PlainText + text: " " + Time.formatRelativeTime(model.timestamp) + pointSize: Style.fontSizeXXS + color: Colors.mOnSurfaceVariant + anchors.bottom: parent.bottom + } + + } + + // Summary + UText { + id: summaryText + + width: parent.width + text: notificationDelegate.isExpanded ? (model.summaryMarkdown || I18n.tr("common.no-summary")) : (model.summary || I18n.tr("common.no-summary")) + pointSize: Style.fontSizeM + color: Colors.mOnSurface + textFormat: notificationDelegate.notificationTextFormat + wrapMode: Text.Wrap + maximumLineCount: notificationDelegate.isExpanded ? 999 : 2 + elide: Text.ElideRight + } + + // Body + UText { + id: bodyText + + width: parent.width + text: notificationDelegate.isExpanded ? (model.bodyMarkdown || "") : (model.body || "") + pointSize: Style.fontSizeS + color: Colors.mOnSurfaceVariant + textFormat: notificationDelegate.notificationTextFormat + wrapMode: Text.Wrap + maximumLineCount: notificationDelegate.isExpanded ? 999 : 3 + elide: Text.ElideRight + visible: text.length > 0 + } + + // Actions Flow + Flow { + width: parent.width + spacing: Style.marginS + visible: notificationDelegate.actionsList.length > 0 + + Repeater { + model: notificationDelegate.actionsList + + delegate: UButton { + readonly property bool actionNavActive: notificationDelegate.isFocused && root.actionIndex !== -1 + readonly property bool isSelected: actionNavActive && root.actionIndex === index + // Capture modelData in a property to avoid reference errors + property var actionData: modelData + + text: modelData.text + fontSize: Style.fontSizeS + backgroundColor: isSelected ? Colors.mSecondary : Colors.mPrimary + textColor: isSelected ? Colors.mOnSecondary : Colors.mOnPrimary + outlined: false + implicitHeight: 24 + onHoveredChanged: { + if (hovered) + root.focusIndex = notificationDelegate.listIndex; + + } + onClicked: { + NotificationService.focusSenderWindow(notificationDelegate.appName); + NotificationService.invokeAction(notificationDelegate.notificationId, actionData.identifier); + } + } + + } + + } + + } + + Item { + width: notificationDelegate.buttonClusterWidth + height: notificationDelegate.actionButtonSize + + Row { + anchors.right: parent.right + spacing: Style.marginXS + + UIconButton { + id: expandButton + + iconName: notificationDelegate.isExpanded ? "chevron-up" : "chevron-down" + baseSize: notificationDelegate.actionButtonSize + opacity: (notificationDelegate.canExpand || notificationDelegate.isExpanded) ? 1 : 0 + enabled: notificationDelegate.canExpand || notificationDelegate.isExpanded + onClicked: { + notificationDelegate.pendingLink = ""; + historyInteractionArea.cursorShape = Qt.ArrowCursor; + if (scrollView.expandedId === notificationId) + scrollView.expandedId = ""; + else + scrollView.expandedId = notificationId; + } + } + + // Delete button + UIconButton { + iconName: "trash" + baseSize: notificationDelegate.actionButtonSize + colorFg: Colors.mError + onClicked: { + NotificationService.removeFromHistory(notificationId); + } + } + + } + + } + + } + + } + + transform: Translate { + x: notificationDelegate.swipeOffset + } + + Behavior on swipeOffset { + enabled: !notificationDelegate.isSwiping + + NumberAnimation { + duration: notificationDelegate.removeAnimationDuration + easing.type: Easing.OutCubic + } + + } + + Behavior on opacity { + enabled: notificationDelegate.isRemoving + + NumberAnimation { + duration: notificationDelegate.removeAnimationDuration + easing.type: Easing.OutCubic + } + + } + + Behavior on height { + enabled: notificationDelegate.isRemoving + + NumberAnimation { + duration: notificationDelegate.removeAnimationDuration + easing.type: Easing.OutCubic + } + + } + + Behavior on y { + enabled: notificationDelegate.isRemoving + + NumberAnimation { + duration: notificationDelegate.removeAnimationDuration + easing.type: Easing.OutCubic + } + + } + + } + + } + + } + + } + + } + + } + + } + + } + +} diff --git a/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/PowerMenuCard.qml b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/PowerMenuCard.qml new file mode 100644 index 0000000..0e3065f --- /dev/null +++ b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/PowerMenuCard.qml @@ -0,0 +1,240 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.UPower +import qs.Components +import qs.Constants +import qs.Services + +UBox { + id: root + + Layout.fillWidth: true + implicitHeight: layout.implicitHeight + Style.marginL * 2 + + ColumnLayout { + id: layout + + anchors.fill: parent + anchors.margins: Style.marginL + spacing: Style.marginL + + // Card Header + RowLayout { + Layout.fillWidth: true + + ColumnLayout { + RowLayout { + id: content + + spacing: Style.marginM + + UImageRounded { + width: Style.baseWidgetSize * 2.4 + height: Style.baseWidgetSize * 2.4 + imagePath: Quickshell.shellDir + "/Assets/Avatar.jpg" + fallbackIcon: "person" + radius: Style.radiusL + } + + ColumnLayout { + spacing: Style.marginXS + + UText { + text: `${HostService.username} @ ${HostService.hostname}` + font.weight: Style.fontWeightBold + font.pointSize: Style.fontSizeL + font.capitalization: Font.Capitalize + } + + UText { + text: "Uptime: " + HostService.uptimeText + font.pointSize: Style.fontSizeM + color: Colors.mOnSurfaceVariant + } + + RowLayout { + id: profileLayout + + spacing: Style.marginXS + + // Performance + UIconButton { + iconName: PowerService.getIcon(PowerProfile.Performance) + enabled: PowerService.available + opacity: enabled ? Style.opacityFull : Style.opacityMedium + colorFg: Colors.mRed + radius: height / 2 + alwaysHover: enabled && PowerService.profile === PowerProfile.Performance + onClicked: PowerService.setProfile(PowerProfile.Performance) + } + + // Balanced + UIconButton { + iconName: PowerService.getIcon(PowerProfile.Balanced) + enabled: PowerService.available + opacity: enabled ? Style.opacityFull : Style.opacityMedium + colorFg: Colors.mBlue + radius: height / 2 + alwaysHover: enabled && PowerService.profile === PowerProfile.Balanced + onClicked: PowerService.setProfile(PowerProfile.Balanced) + } + + // Eco + UIconButton { + iconName: PowerService.getIcon(PowerProfile.PowerSaver) + enabled: PowerService.available + opacity: enabled ? Style.opacityFull : Style.opacityMedium + colorFg: Colors.mGreen + radius: height / 2 + alwaysHover: enabled && PowerService.profile === PowerProfile.PowerSaver + onClicked: PowerService.setProfile(PowerProfile.PowerSaver) + } + + } + + } + + Item { + Layout.fillWidth: true + } + + } + + } + + } + + UDivider { + Layout.fillWidth: true + } + + // Action Tiles + GridLayout { + Layout.fillWidth: true + columns: 3 + rowSpacing: Style.marginM + columnSpacing: Style.marginM + + Repeater { + model: [{ + "name": "Lock", + "icon": "lock", + "isError": false, + "clicked": function() { + PowerService.lock(); + } + }, { + "name": "Logout", + "icon": "logout", + "isError": false, + "clicked": function() { + PowerService.logout(); + } + }, { + "name": "Suspend", + "icon": "moon", + "isError": false, + "clicked": function() { + PowerService.suspend(); + } + }, { + "name": "Hibernate", + "icon": "bed", + "isError": false, + "clicked": function() { + PowerService.hibernate(); + } + }, { + "name": "Reboot", + "icon": "refresh", + "isError": false, + "clicked": function() { + PowerService.reboot(); + } + }, { + "name": "Shutdown", + "icon": "power", + "isError": true, + "clicked": function() { + PowerService.shutdown(); + } + }] + + delegate: Rectangle { + id: tile + + readonly property bool hovered: mouseArea.containsMouse + + Layout.fillWidth: true + Layout.preferredHeight: Style.baseWidgetSize * 2.2 + radius: Style.radiusL + color: hovered ? (modelData.isError ? Colors.mError : Colors.mPrimary) : Colors.mSurface + border.color: hovered ? "transparent" : Colors.mOutline + border.width: 1 + + ColumnLayout { + anchors.centerIn: parent + spacing: Style.marginXS + + UIcon { + Layout.alignment: Qt.AlignHCenter + iconName: modelData.icon + iconSize: Style.fontSizeXL * 1.1 + color: tile.hovered ? (modelData.isError ? Colors.mOnError : Colors.mOnPrimary) : (modelData.isError ? Colors.mError : Colors.mOnSurface) + + Behavior on color { + ColorAnimation { + duration: 150 + } + + } + + } + + UText { + Layout.alignment: Qt.AlignHCenter + text: modelData.name + pointSize: Style.fontSizeS + font.weight: Style.fontWeightMedium + color: tile.hovered ? (modelData.isError ? Colors.mOnError : Colors.mOnPrimary) : Colors.mOnSurfaceVariant + + Behavior on color { + ColorAnimation { + duration: 150 + } + + } + + } + + } + + MouseArea { + id: mouseArea + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton + onClicked: { + modelData.clicked(); + } + } + + Behavior on color { + ColorAnimation { + duration: 150 + } + + } + + } + + } + + } + + } + +} diff --git a/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/WeatherCard.qml b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/WeatherCard.qml new file mode 100644 index 0000000..187cb32 --- /dev/null +++ b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/WeatherCard.qml @@ -0,0 +1,227 @@ +import QtQuick +import QtQuick.Effects +import QtQuick.Layouts +import Quickshell +import qs.Components +import qs.Constants +import qs.Services +import qs.Utils + +// Weather overview card (placeholder data) +UBox { + id: root + + property int forecastDays: 6 + property bool showLocation: true + property bool showEffects: true + readonly property bool weatherReady: LocationService.data.weather !== null + // Test mode: set to "clear_day", "clear_night", "rain", "snow", "cloud" or "fog" + property string testEffects: "" + // Weather condition detection + readonly property int currentWeatherCode: weatherReady ? LocationService.data.weather.current_weather.weathercode : 0 + readonly property bool isDayTime: weatherReady ? LocationService.data.weather.current_weather.is_day : true + readonly property bool isRaining: testEffects === "rain" || (testEffects === "" && ((currentWeatherCode >= 51 && currentWeatherCode <= 67) || (currentWeatherCode >= 80 && currentWeatherCode <= 82))) + readonly property bool isSnowing: testEffects === "snow" || (testEffects === "" && ((currentWeatherCode >= 71 && currentWeatherCode <= 77) || (currentWeatherCode >= 85 && currentWeatherCode <= 86))) + readonly property bool isCloudy: testEffects === "cloud" || (testEffects === "" && (currentWeatherCode === 3)) + readonly property bool isFoggy: testEffects === "fog" || (testEffects === "" && (currentWeatherCode >= 40 && currentWeatherCode <= 49)) + readonly property bool isClearDay: testEffects === "clear_day" || (testEffects === "" && (currentWeatherCode === 0 && isDayTime)) + readonly property bool isClearNight: testEffects === "clear_night" || (testEffects === "" && (currentWeatherCode === 0 && !isDayTime)) + + implicitHeight: Math.max(100, content.implicitHeight + Style.marginXL * 2) + + // Weather effect layer (rain/snow) + Loader { + id: weatherEffectLoader + + anchors.fill: parent + active: root.showEffects && (root.isRaining || root.isSnowing || root.isCloudy || root.isFoggy || root.isClearDay || root.isClearNight) + + sourceComponent: Item { + // Animated time for shaders + property real shaderTime: 0 + + anchors.fill: parent + + ShaderEffect { + id: weatherEffect + + property var source + property real time: parent.shaderTime + property real itemWidth: weatherEffect.width + property real itemHeight: weatherEffect.height + property color bgColor: root.color + property real cornerRadius: root.isRaining ? 0 : (root.radius - root.border.width) + property real alternative: root.isFoggy + + anchors.fill: parent + // Rain matches content margins, everything else fills the box + anchors.margins: root.isRaining ? Style.marginXL : root.border.width + fragmentShader: { + let shaderName; + if (root.isSnowing) + shaderName = "weather_snow"; + else if (root.isRaining) + shaderName = "weather_rain"; + else if (root.isCloudy || root.isFoggy) + shaderName = "weather_cloud"; + else if (root.isClearDay) + shaderName = "weather_sun"; + else if (root.isClearNight) + shaderName = "weather_stars"; + else + shaderName = ""; + return Qt.resolvedUrl(Quickshell.shellDir + "/Shaders/qsb/" + shaderName + ".frag.qsb"); + } + + source: ShaderEffectSource { + sourceItem: content + hideSource: root.isRaining // Only hide for rain (distortion), show for snow + } + + } + + NumberAnimation on shaderTime { + loops: Animation.Infinite + from: 0 + to: 1000 + duration: 100000 + } + + } + + } + + ColumnLayout { + id: content + + anchors.fill: parent + anchors.margins: Style.marginXL + spacing: Style.marginM + clip: true + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginS + + Item { + Layout.preferredWidth: Style.marginXXS + } + + RowLayout { + spacing: Style.marginL + Layout.fillWidth: true + + UIcon { + Layout.alignment: Qt.AlignVCenter + iconName: weatherReady ? LocationService.weatherSymbolFromCode(LocationService.data.weather.current_weather.weathercode, LocationService.data.weather.current_weather.is_day) : "weather-cloud-off" + iconSize: Style.fontSizeXXXL * 1.75 + color: Colors.mPrimary + } + + ColumnLayout { + spacing: Style.marginXXS + + UText { + text: { + // Ensure the name is not too long if one had to specify the country + const loc = SettingsService.location || "Unknown Location"; + const chunks = loc.split(","); + return chunks[0]; + } + pointSize: Style.fontSizeL + font.weight: Style.fontWeightBold + visible: showLocation + } + + RowLayout { + UText { + visible: weatherReady + text: { + if (!weatherReady) + return ""; + + var temp = LocationService.data.weather.current_weather.temperature; + var suffix = "C"; + temp = Math.round(temp); + return `${temp}°${suffix}`; + } + pointSize: showLocation ? Style.fontSizeXL : Style.fontSizeXL * 1.6 + font.weight: Style.fontWeightBold + } + + UText { + text: weatherReady ? `(${LocationService.data.weather.timezone_abbreviation})` : "" + pointSize: Style.fontSizeXS + color: Colors.mOnSurfaceVariant + visible: LocationService.data.weather && showLocation + } + + } + + } + + } + + } + + UDivider { + visible: weatherReady + Layout.fillWidth: true + } + + RowLayout { + visible: weatherReady + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + spacing: Style.marginM + + Repeater { + model: weatherReady ? Math.min(root.forecastDays, LocationService.data.weather.daily.time.length) : 0 + + delegate: ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginXS + + Item { + Layout.fillWidth: true + } + + UText { + Layout.alignment: Qt.AlignVCenter | Qt.AlignHCenter + text: { + var weatherDate = new Date(LocationService.data.weather.daily.time[index].replace(/-/g, "/")); + // return I18n.locale.toString(weatherDate, "ddd"); + return Qt.formatDate(weatherDate, "ddd"); + } + color: Colors.mOnSurface + } + + UIcon { + Layout.alignment: Qt.AlignVCenter | Qt.AlignHCenter + iconName: LocationService.weatherSymbolFromCode(LocationService.data.weather.daily.weathercode[index]) + iconSize: Style.fontSizeXXL * 1.6 + color: Colors.mPrimary + } + + UText { + Layout.alignment: Qt.AlignHCenter + text: { + var max = LocationService.data.weather.daily.temperature_2m_max[index]; + var min = LocationService.data.weather.daily.temperature_2m_min[index]; + max = Math.round(max); + min = Math.round(min); + return `${max}°/${min}°`; + } + pointSize: Style.fontSizeXS + color: Colors.mOnSurfaceVariant + } + + } + + } + + } + + } + +} diff --git a/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/WifiCard.qml b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/WifiCard.qml new file mode 100644 index 0000000..85f992a --- /dev/null +++ b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/WifiCard.qml @@ -0,0 +1,559 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Wayland +import qs.Constants +import qs.Components +import qs.Services +import qs.Utils + +ColumnLayout { + property string passwordSsid: "" + property string passwordInput: "" + property string expandedSsid: "" + + // Error message + Rectangle { + visible: NetworkService.lastError.length > 0 + Layout.fillWidth: true + Layout.preferredHeight: errorRow.implicitHeight + (Style.marginS * 2) + color: Qt.rgba(Colors.mError.r, Colors.mError.g, Colors.mError.b, 0.1) + radius: Style.radiusS + border.width: Math.max(1, Style.borderS) + border.color: Colors.mError + + RowLayout { + id: errorRow + + anchors.fill: parent + anchors.margins: Style.marginS + spacing: Style.marginS + + UIcon { + iconName: "warning" + iconSize: Style.fontSizeL + color: Colors.mError + } + + UText { + text: NetworkService.lastError + color: Colors.mError + pointSize: Style.fontSizeS + wrapMode: Text.Wrap + Layout.fillWidth: true + } + + UIconButton { + iconName: "close" + baseSize: Style.baseWidgetSize * 0.6 + onClicked: NetworkService.lastError = "" + } + + } + + } + + // Main content area + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + color: Colors.transparent + + // WiFi disabled state + ColumnLayout { + visible: !SettingsService.wifiEnabled + anchors.fill: parent + spacing: Style.marginS + + Item { + Layout.fillHeight: true + } + + UIcon { + iconName: "wifi-off" + iconSize: 64 + color: Colors.mOnSurfaceVariant + Layout.alignment: Qt.AlignHCenter + } + + UText { + text: "Wi-Fi Disabled" + pointSize: Style.fontSizeL + color: Colors.mOnSurfaceVariant + Layout.alignment: Qt.AlignHCenter + } + + UText { + text: "Please enable Wi-Fi to connect to a network." + pointSize: Style.fontSizeS + color: Colors.mOnSurfaceVariant + Layout.alignment: Qt.AlignHCenter + } + + Item { + Layout.fillHeight: true + } + + } + + // Scanning state + ColumnLayout { + visible: SettingsService.wifiEnabled && NetworkService.scanning && Object.keys(NetworkService.networks).length === 0 + anchors.fill: parent + spacing: Style.marginL + + Item { + Layout.fillHeight: true + } + + UBusyIndicator { + running: true + color: Colors.mPrimary + size: Style.baseWidgetSize + Layout.alignment: Qt.AlignHCenter + } + + UText { + text: "Searching for networks..." + pointSize: Style.fontSizeM + color: Colors.mOnSurfaceVariant + Layout.alignment: Qt.AlignHCenter + } + + Item { + Layout.fillHeight: true + } + + } + + // Networks list container + UScrollView { + visible: SettingsService.wifiEnabled && (!NetworkService.scanning || Object.keys(NetworkService.networks).length > 0) + anchors.fill: parent + horizontalPolicy: ScrollBar.AlwaysOff + verticalPolicy: ScrollBar.AsNeeded + clip: true + contentWidth: availableWidth + + ColumnLayout { + width: parent.width + spacing: Style.marginS + + // Network list + Repeater { + model: { + if (!SettingsService.wifiEnabled) + return []; + + const nets = Object.values(NetworkService.networks); + return nets.sort((a, b) => { + if (a.connected !== b.connected) + return a.connected ? -1 : 1; + + return b.signal - a.signal; + }); + } + + UBox { + Layout.fillWidth: true + implicitHeight: netColumn.implicitHeight + (Style.marginS * 2) + compact: true + + ColumnLayout { + id: netColumn + + width: parent.width - (Style.marginS * 2) + x: Style.marginS + y: Style.marginS + spacing: Style.marginS + + // Main row + RowLayout { + Layout.fillWidth: true + spacing: Style.marginS + + UIcon { + iconName: NetworkService.signalIcon(modelData.signal) + iconSize: Style.fontSizeXXL + color: modelData.connected ? Colors.mPrimary : Colors.mOnSurface + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + UText { + text: modelData.ssid + pointSize: Style.fontSizeM + font.weight: modelData.connected ? Style.fontWeightBold : Style.fontWeightMedium + color: Colors.mOnSurface + elide: Text.ElideRight + Layout.fillWidth: true + } + + RowLayout { + spacing: Style.marginXS + + UText { + text: `${modelData.signal}%` + pointSize: Style.fontSizeXXS + color: Colors.mOnSurfaceVariant + } + + UText { + text: "•" + pointSize: Style.fontSizeXXS + color: Colors.mOnSurfaceVariant + } + + UText { + text: NetworkService.isSecured(modelData.security) ? modelData.security : "Open" + pointSize: Style.fontSizeXXS + color: Colors.mOnSurfaceVariant + } + + Item { + Layout.preferredWidth: Style.marginXXS + } + + // Update the status badges area (around line 237) + Rectangle { + visible: modelData.connected && NetworkService.disconnectingFrom !== modelData.ssid + color: Colors.mPrimary + radius: height * 0.5 + width: connectedText.implicitWidth + (Style.marginS * 2) + height: connectedText.implicitHeight + (Style.marginXXS * 2) + + UText { + id: connectedText + + anchors.centerIn: parent + text: "Connected" + pointSize: Style.fontSizeXXS + color: Colors.mOnPrimary + } + + } + + Rectangle { + visible: NetworkService.disconnectingFrom === modelData.ssid + color: Colors.mError + radius: height * 0.5 + width: disconnectingText.implicitWidth + (Style.marginS * 2) + height: disconnectingText.implicitHeight + (Style.marginXXS * 2) + + UText { + id: disconnectingText + + anchors.centerIn: parent + text: "disconnecting" + pointSize: Style.fontSizeXXS + color: Colors.mOnPrimary + } + + } + + Rectangle { + visible: NetworkService.forgettingNetwork === modelData.ssid + color: Colors.mError + radius: height * 0.5 + width: forgettingText.implicitWidth + (Style.marginS * 2) + height: forgettingText.implicitHeight + (Style.marginXXS * 2) + + UText { + id: forgettingText + + anchors.centerIn: parent + text: "forgetting" + pointSize: Style.fontSizeXXS + color: Colors.mOnPrimary + } + + } + + Rectangle { + visible: modelData.cached && !modelData.connected && NetworkService.forgettingNetwork !== modelData.ssid && NetworkService.disconnectingFrom !== modelData.ssid + color: Colors.transparent + border.color: Colors.mOutline + border.width: Math.max(1, Style.borderS) + radius: height * 0.5 + width: savedText.implicitWidth + (Style.marginS * 2) + height: savedText.implicitHeight + (Style.marginXXS * 2) + + UText { + id: savedText + + anchors.centerIn: parent + text: "saved" + pointSize: Style.fontSizeXXS + color: Colors.mOnSurfaceVariant + } + + } + + } + + } + + // Action area + RowLayout { + spacing: Style.marginS + + UBusyIndicator { + visible: NetworkService.connectingTo === modelData.ssid || NetworkService.disconnectingFrom === modelData.ssid || NetworkService.forgettingNetwork === modelData.ssid + running: visible + color: Colors.mPrimary + size: Style.baseWidgetSize * 0.5 + } + + UIconButton { + visible: (modelData.existing || modelData.cached) && !modelData.connected && NetworkService.connectingTo !== modelData.ssid && NetworkService.forgettingNetwork !== modelData.ssid && NetworkService.disconnectingFrom !== modelData.ssid + iconName: "trash" + baseSize: Style.baseWidgetSize * 0.8 + onClicked: expandedSsid = expandedSsid === modelData.ssid ? "" : modelData.ssid + } + + UButton { + visible: !modelData.connected && NetworkService.connectingTo !== modelData.ssid && passwordSsid !== modelData.ssid && NetworkService.forgettingNetwork !== modelData.ssid && NetworkService.disconnectingFrom !== modelData.ssid + text: { + if (modelData.existing || modelData.cached) + return "Connect"; + + if (!NetworkService.isSecured(modelData.security)) + return "Connect"; + + return "Enter Password"; + } + outlined: false + fontSize: Style.fontSizeXS + enabled: !NetworkService.connecting + onClicked: { + if (modelData.existing || modelData.cached || !NetworkService.isSecured(modelData.security)) { + NetworkService.connect(modelData.ssid); + } else { + passwordSsid = modelData.ssid; + passwordInput = ""; + expandedSsid = ""; + } + } + } + + UButton { + visible: modelData.connected && NetworkService.disconnectingFrom !== modelData.ssid + text: "Disconnect" + outlined: false + fontSize: Style.fontSizeXS + backgroundColor: Colors.mError + onClicked: NetworkService.disconnect(modelData.ssid) + } + + } + + } + + // Password input + Rectangle { + visible: passwordSsid === modelData.ssid && NetworkService.disconnectingFrom !== modelData.ssid && NetworkService.forgettingNetwork !== modelData.ssid + Layout.fillWidth: true + height: passwordRow.implicitHeight + Style.marginS * 2 + color: Colors.mSurfaceVariant + border.color: Colors.mOutline + border.width: Math.max(1, Style.borderS) + radius: Style.radiusS + + RowLayout { + id: passwordRow + + anchors.fill: parent + anchors.margins: Style.marginS + spacing: Style.marginS + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + radius: Style.radiusXS + color: Colors.mSurface + border.color: pwdInput.activeFocus ? Colors.mSecondary : Colors.mOutline + border.width: Math.max(1, Style.borderS) + + TextInput { + id: pwdInput + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Style.marginS + text: passwordInput + font.family: Fonts.sans + font.pointSize: Style.fontSizeS + color: Colors.mOnSurface + echoMode: TextInput.Password + selectByMouse: true + focus: visible + passwordCharacter: "●" + onTextChanged: passwordInput = text + onVisibleChanged: { + if (visible) + forceActiveFocus(); + + } + onAccepted: { + if (text && !NetworkService.connecting) { + NetworkService.connect(passwordSsid, text); + passwordSsid = ""; + passwordInput = ""; + } + } + + UText { + visible: parent.text.length === 0 + anchors.verticalCenter: parent.verticalCenter + text: "Enter Password" + color: Colors.mOnSurfaceVariant + pointSize: Style.fontSizeS + } + + } + + } + + UButton { + text: "Connect" + fontSize: Style.fontSizeXXS + enabled: passwordInput.length > 0 && !NetworkService.connecting + outlined: false + onClicked: { + NetworkService.connect(passwordSsid, passwordInput); + passwordSsid = ""; + passwordInput = ""; + } + } + + UIconButton { + iconName: "close" + baseSize: Style.baseWidgetSize * 0.8 + onClicked: { + passwordSsid = ""; + passwordInput = ""; + } + } + + } + + } + + // Forget network + Rectangle { + visible: expandedSsid === modelData.ssid && NetworkService.disconnectingFrom !== modelData.ssid && NetworkService.forgettingNetwork !== modelData.ssid + Layout.fillWidth: true + height: forgetRow.implicitHeight + Style.marginS * 2 + color: Colors.mSurfaceVariant + radius: Style.radiusS + border.width: Math.max(1, Style.borderS) + border.color: Colors.mOutline + + RowLayout { + id: forgetRow + + anchors.fill: parent + anchors.margins: Style.marginS + spacing: Style.marginS + + RowLayout { + UIcon { + iconName: "trash" + iconSize: Style.fontSizeL + color: Colors.mError + } + + UText { + text: "Forget this network?" + pointSize: Style.fontSizeS + color: Colors.mError + Layout.fillWidth: true + } + + } + + UButton { + id: forgetButton + + text: "Forget" + fontSize: Style.fontSizeXXS + backgroundColor: Colors.mError + outlined: false + onClicked: { + NetworkService.forget(modelData.ssid); + expandedSsid = ""; + } + } + + UIconButton { + iconName: "close" + baseSize: Style.baseWidgetSize * 0.8 + onClicked: expandedSsid = "" + } + + } + + } + + } + + // Smooth opacity animation + Behavior on opacity { + NumberAnimation { + duration: Style.animationNormal + } + + } + + } + + } + + } + + } + + // Empty state when no networks + ColumnLayout { + visible: SettingsService.wifiEnabled && !NetworkService.scanning && Object.keys(NetworkService.networks).length === 0 + anchors.fill: parent + spacing: Style.marginL + + Item { + Layout.fillHeight: true + } + + UIcon { + iconName: "search" + iconSize: 64 + color: Colors.mOnSurfaceVariant + Layout.alignment: Qt.AlignHCenter + } + + UText { + text: "No networks found" + pointSize: Style.fontSizeL + color: Colors.mOnSurfaceVariant + Layout.alignment: Qt.AlignHCenter + } + + UButton { + text: "Scan Again" + icon: "refresh" + Layout.alignment: Qt.AlignHCenter + onClicked: NetworkService.scan() + } + + Item { + Layout.fillHeight: true + } + + } + + } + +} diff --git a/config/quickshell/.config/quickshell/Modules/Sidebar/Sidebars.qml b/config/quickshell/.config/quickshell/Modules/Sidebar/Sidebars.qml new file mode 100644 index 0000000..3e0127e --- /dev/null +++ b/config/quickshell/.config/quickshell/Modules/Sidebar/Sidebars.qml @@ -0,0 +1,83 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import qs.Constants +import qs.Modules.Sidebar.Components +import qs.Modules.Sidebar.Modules +import qs.Services + +Variants { + model: Quickshell.screens + + Item { + property var modelData + + SidebarWrap { + screen: modelData + isLeft: true + + contentComponent: ColumnLayout { + anchors.fill: parent + anchors.margins: Style.marginM + spacing: Style.marginM + + WeatherCard { + Layout.fillWidth: true + } + + CalendarMonthCard { + Layout.fillWidth: true + } + + LyricsCard { + Layout.fillWidth: true + Layout.preferredHeight: 100 + } + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginS + + LyricsControl { + } + + MediaCard { + Layout.fillWidth: true + } + + } + + ConnectionCard { + Layout.fillHeight: true + Layout.fillWidth: true + } + + } + + } + + SidebarWrap { + screen: modelData + isLeft: false + + contentComponent: ColumnLayout { + anchors.fill: parent + anchors.margins: Style.marginM + spacing: Style.marginM + + PowerMenuCard { + Layout.fillWidth: true + } + + NotificationHistoryCard { + Layout.fillWidth: true + Layout.fillHeight: true + } + + } + + } + + } + +} diff --git a/config/quickshell/.config/quickshell/Noctalia/NBox.qml b/config/quickshell/.config/quickshell/Noctalia/NBox.qml deleted file mode 100644 index c753d07..0000000 --- a/config/quickshell/.config/quickshell/Noctalia/NBox.qml +++ /dev/null @@ -1,28 +0,0 @@ -import Qt5Compat.GraphicalEffects -import QtQuick -import QtQuick.Layouts -import qs.Constants -import qs.Noctalia - -// Rounded group container using the variant surface color. -// To be used in side panels and settings panes to group fields or buttons. -Rectangle { - id: root - - property bool compact: false - - implicitWidth: childrenRect.width - implicitHeight: childrenRect.height - color: compact ? Color.transparent : Color.mSurfaceVariant - radius: Style.radiusM - layer.enabled: !compact - - layer.effect: DropShadow { - horizontalOffset: 6 - verticalOffset: 6 - radius: 8 - samples: 12 - color: Qt.rgba(0, 0, 0, 0.3) - } - -} diff --git a/config/quickshell/.config/quickshell/Noctalia/NButton.qml b/config/quickshell/.config/quickshell/Noctalia/NButton.qml deleted file mode 100644 index 0d8e4ba..0000000 --- a/config/quickshell/.config/quickshell/Noctalia/NButton.qml +++ /dev/null @@ -1,183 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import qs.Constants -import qs.Services - -Rectangle { - id: root - - // Public properties - property string text: "" - property string icon: "" - property string tooltipText - property color backgroundColor: Color.mPrimary - property color textColor: Color.mOnPrimary - property color hoverColor: Color.mTertiary - property bool enabled: true - property real fontSize: Style.fontSizeM - property int fontWeight: Style.fontWeightBold - property string fontFamily: Fonts.primary - property real iconSize: Style.fontSizeL - property bool outlined: false - // Internal properties - property bool hovered: false - property bool pressed: false - - // Signals - signal clicked() - signal rightClicked() - signal middleClicked() - - // Dimensions - implicitWidth: contentRow.implicitWidth + (Style.marginL * 2) - implicitHeight: Math.max(Style.baseWidgetSize, contentRow.implicitHeight + (Style.marginM)) - // Appearance - radius: Style.radiusS - color: { - if (!enabled) - return outlined ? Color.transparent : Qt.lighter(Color.mSurfaceVariant, 1.2); - - if (hovered) - return hoverColor; - - return outlined ? Color.transparent : backgroundColor; - } - border.width: outlined ? Math.max(1, Style.borderS) : 0 - border.color: { - if (!enabled) - return Color.mOutline; - - if (pressed || hovered) - return backgroundColor; - - return outlined ? backgroundColor : Color.transparent; - } - opacity: enabled ? 1 : 0.6 - - // Content - RowLayout { - id: contentRow - - anchors.centerIn: parent - spacing: Style.marginXS - - // Icon (optional) - NIcon { - Layout.alignment: Qt.AlignVCenter - visible: root.icon !== "" - icon: root.icon - pointSize: root.iconSize - color: { - if (!root.enabled) - return Color.mOnSurfaceVariant; - - if (root.outlined) { - if (root.pressed || root.hovered) - return root.backgroundColor; - - return root.backgroundColor; - } - return root.textColor; - } - - Behavior on color { - ColorAnimation { - duration: Style.animationFast - easing.type: Easing.OutCubic - } - - } - - } - - // Text - NText { - Layout.alignment: Qt.AlignVCenter - visible: root.text !== "" - text: root.text - pointSize: root.fontSize - font.weight: root.fontWeight - family: root.fontFamily - color: { - if (!root.enabled) - return Color.mOnSurfaceVariant; - - if (root.outlined) { - if (root.hovered) - return root.textColor; - - return root.backgroundColor; - } - return root.textColor; - } - - Behavior on color { - ColorAnimation { - duration: Style.animationFast - easing.type: Easing.OutCubic - } - - } - - } - - } - - // Mouse interaction - MouseArea { - id: mouseArea - - anchors.fill: parent - enabled: root.enabled - hoverEnabled: true - acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton - cursorShape: root.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor - onEntered: { - root.hovered = true; - if (tooltipText) - TooltipService.show(Screen, root, root.tooltipText); - - } - onExited: { - root.hovered = false; - if (tooltipText) - TooltipService.hide(); - - } - onPressed: (mouse) => { - if (tooltipText) - TooltipService.hide(); - - if (mouse.button === Qt.LeftButton) - root.clicked(); - else if (mouse.button == Qt.RightButton) - root.rightClicked(); - else if (mouse.button == Qt.MiddleButton) - root.middleClicked(); - } - onCanceled: { - root.hovered = false; - if (tooltipText) - TooltipService.hide(); - - } - } - - Behavior on color { - ColorAnimation { - duration: Style.animationFast - easing.type: Easing.OutCubic - } - - } - - Behavior on border.color { - ColorAnimation { - duration: Style.animationFast - easing.type: Easing.OutCubic - } - - } - -} diff --git a/config/quickshell/.config/quickshell/Noctalia/NCircleStat.qml b/config/quickshell/.config/quickshell/Noctalia/NCircleStat.qml deleted file mode 100644 index 59dc94f..0000000 --- a/config/quickshell/.config/quickshell/Noctalia/NCircleStat.qml +++ /dev/null @@ -1,122 +0,0 @@ -import QtQuick -import QtQuick.Layouts -import qs.Noctalia -import qs.Services -import qs.Utils - -// Compact circular statistic display using Layout management -Rectangle { - id: root - - property real value: 0 // 0..100 (or any range visually mapped) - property string icon: "" - property string suffix: "%" - // When nested inside a parent group (NBox), you can make it flat - property bool flat: false - // Scales the internal content (labels, gauge, icon) without changing the - // outer width/height footprint of the component - property real contentScale: 1 - - width: 68 - height: 92 - color: flat ? Color.transparent : Color.mSurface - radius: Style.radiusS - border.color: flat ? Color.transparent : Color.mSurfaceVariant - border.width: flat ? 0 : Math.max(1, Style.borderS) - // Repaint gauge when the bound value changes - onValueChanged: gauge.requestPaint() - - ColumnLayout { - id: mainLayout - - anchors.fill: parent - anchors.margins: Style.marginS * contentScale - spacing: 0 - - // Main gauge container - Item { - id: gaugeContainer - - Layout.fillWidth: true - Layout.fillHeight: true - Layout.alignment: Qt.AlignCenter - Layout.preferredWidth: 68 * contentScale - Layout.preferredHeight: 68 * contentScale - - Canvas { - // 390° (equivalent to 30°) - - id: gauge - - anchors.fill: parent - renderStrategy: Canvas.Cooperative - onPaint: { - const ctx = getContext("2d"); - const w = width, h = height; - const cx = w / 2, cy = h / 2; - const r = Math.min(w, h) / 2 - 5 * contentScale; - // Rotated 90° to the right: gap at the bottom - // Start at 150° and end at 390° (30°) → bottom opening - const start = Math.PI * 5 / 6; - // 150° - const endBg = Math.PI * 13 / 6; - ctx.reset(); - ctx.lineWidth = 6 * contentScale; - // Track uses surfaceVariant for stronger contrast - ctx.strokeStyle = Color.mSurface; - ctx.beginPath(); - ctx.arc(cx, cy, r, start, endBg); - ctx.stroke(); - // Value arc - const ratio = Math.max(0, Math.min(1, root.value / 100)); - const end = start + (endBg - start) * ratio; - ctx.strokeStyle = Color.mPrimary; - ctx.beginPath(); - ctx.arc(cx, cy, r, start, end); - ctx.stroke(); - } - } - - // Percent centered in the circle - NText { - id: valueLabel - - anchors.centerIn: parent - anchors.verticalCenterOffset: -4 * contentScale - text: `${root.value}${root.suffix}` - pointSize: Style.fontSizeM * contentScale - font.weight: Style.fontWeightBold - color: Color.mOnSurface - horizontalAlignment: Text.AlignHCenter - } - - // Tiny circular badge for the icon, positioned inside below the percentage - Rectangle { - id: iconBadge - - width: iconText.implicitWidth + Style.marginXXS - height: width - radius: width / 2 - color: Color.mPrimary - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: valueLabel.bottom - anchors.topMargin: 8 * contentScale - - NIcon { - id: iconText - - anchors.centerIn: parent - icon: root.icon - color: Color.mOnPrimary - pointSize: Style.fontSizeS - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - } - - } - - } - - } - -} diff --git a/config/quickshell/.config/quickshell/Noctalia/NIcon.qml b/config/quickshell/.config/quickshell/Noctalia/NIcon.qml deleted file mode 100644 index 831aa60..0000000 --- a/config/quickshell/.config/quickshell/Noctalia/NIcon.qml +++ /dev/null @@ -1,28 +0,0 @@ -import QtQuick -import QtQuick.Layouts -import qs.Constants -import qs.Noctalia - -Text { - id: root - - property string icon: Icons.defaultIcon - property real pointSize: Style.fontSizeL - - visible: (icon !== undefined) && (icon !== "") - text: { - if ((icon === undefined) || (icon === "")) - return ""; - - if (Icons.get(icon) === undefined) { - Logger.warn("Icon", `"${icon}"`, "doesn't exist in the icons font"); - Logger.callStack(); - return Icons.get(Icons.defaultIcon); - } - return Icons.get(icon); - } - font.family: Icons.fontFamily - font.pointSize: root.pointSize - color: Color.mOnSurface - verticalAlignment: Text.AlignVCenter -} diff --git a/config/quickshell/.config/quickshell/Noctalia/NIconButton.qml b/config/quickshell/.config/quickshell/Noctalia/NIconButton.qml deleted file mode 100644 index bebffe4..0000000 --- a/config/quickshell/.config/quickshell/Noctalia/NIconButton.qml +++ /dev/null @@ -1,92 +0,0 @@ -import QtQuick -import Quickshell -import Quickshell.Widgets -import qs.Constants -import qs.Noctalia - -Rectangle { - id: root - - property real baseSize: Style.baseWidgetSize - property string icon - property bool enabled: true - property bool allowClickWhenDisabled: false - property bool hovering: false - property bool compact: false - property color colorBg: Color.mSurfaceVariant - property color colorFg: Color.mPrimary - property color colorBgHover: Color.mTertiary - property color colorFgHover: Color.mOnTertiary - property color colorBorder: Color.transparent - property color colorBorderHover: Color.transparent - - signal entered() - signal exited() - signal clicked() - signal rightClicked() - signal middleClicked() - - implicitWidth: Math.round(baseSize) - implicitHeight: Math.round(baseSize) - opacity: root.enabled ? Style.opacityFull : Style.opacityMedium - color: root.enabled && root.hovering ? colorBgHover : colorBg - radius: width * 0.5 - border.color: root.enabled && root.hovering ? colorBorderHover : colorBorder - border.width: Math.max(1, Style.borderS) - - NIcon { - icon: root.icon - pointSize: Math.max(1, root.compact ? root.width * 0.65 : root.width * 0.48) - color: root.enabled && root.hovering ? colorFgHover : colorFg - // Center horizontally - x: (root.width - width) / 2 - // Center vertically accounting for font metrics - y: (root.height - height) / 2 + (height - contentHeight) / 2 - - Behavior on color { - ColorAnimation { - duration: Style.animationFast - easing.type: Easing.InOutQuad - } - - } - - } - - MouseArea { - // Always enabled to allow hover/tooltip even when the button is disabled - enabled: true - anchors.fill: parent - cursorShape: root.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor - acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton - hoverEnabled: true - onEntered: { - hovering = root.enabled ? true : false; - root.entered(); - } - onExited: { - hovering = false; - root.exited(); - } - onClicked: function(mouse) { - if (!root.enabled && !allowClickWhenDisabled) - return ; - - if (mouse.button === Qt.LeftButton) - root.clicked(); - else if (mouse.button === Qt.RightButton) - root.rightClicked(); - else if (mouse.button === Qt.MiddleButton) - root.middleClicked(); - } - } - - Behavior on color { - ColorAnimation { - duration: Style.animationNormal - easing.type: Easing.InOutQuad - } - - } - -} diff --git a/config/quickshell/.config/quickshell/Noctalia/NImageCircled.qml b/config/quickshell/.config/quickshell/Noctalia/NImageCircled.qml deleted file mode 100644 index d091fe1..0000000 --- a/config/quickshell/.config/quickshell/Noctalia/NImageCircled.qml +++ /dev/null @@ -1,85 +0,0 @@ -import QtQuick -import QtQuick.Effects -import Quickshell -import Quickshell.Widgets -import qs.Constants -import qs.Noctalia -import qs.Services - -Rectangle { - id: root - - property string imagePath: "" - property color borderColor: Color.transparent - property real borderWidth: 0 - property string fallbackIcon: "" - property real fallbackIconSize: Style.fontSizeXXL - - color: Color.transparent - radius: parent.width * 0.5 - anchors.margins: Style.marginXXS - - Rectangle { - color: Color.transparent - anchors.fill: parent - - Image { - id: img - - anchors.fill: parent - source: imagePath - visible: false // Hide since we're using it as shader source - mipmap: true - smooth: true - asynchronous: true - antialiasing: true - fillMode: Image.PreserveAspectCrop - } - - ShaderEffect { - property var source - property real imageOpacity: root.opacity - - anchors.fill: parent - fragmentShader: Qt.resolvedUrl(Quickshell.shellDir + "/Shaders/qsb/circled_image.frag.qsb") - supportsAtlasTextures: false - blending: true - - source: ShaderEffectSource { - sourceItem: img - hideSource: true - live: true - recursive: false - format: ShaderEffectSource.RGBA - } - - } - - // Fallback icon - Loader { - active: fallbackIcon !== undefined && fallbackIcon !== "" && (imagePath === undefined || imagePath === "") - anchors.centerIn: parent - - sourceComponent: NIcon { - anchors.centerIn: parent - icon: fallbackIcon - pointSize: fallbackIconSize - z: 0 - } - - } - - } - - // Border - Rectangle { - anchors.fill: parent - radius: parent.radius - color: Color.transparent - border.color: parent.borderColor - border.width: parent.borderWidth - antialiasing: true - z: 10 - } - -} diff --git a/config/quickshell/.config/quickshell/Noctalia/NImageRounded.qml b/config/quickshell/.config/quickshell/Noctalia/NImageRounded.qml deleted file mode 100644 index d7dfea3..0000000 --- a/config/quickshell/.config/quickshell/Noctalia/NImageRounded.qml +++ /dev/null @@ -1,103 +0,0 @@ -import QtQuick -import QtQuick.Effects -import Quickshell -import Quickshell.Widgets -import qs.Constants -import qs.Noctalia - -Rectangle { - id: root - - property string imagePath: "" - property color borderColor: Color.transparent - property real borderWidth: 0 - property real imageRadius: width * 0.5 - property string fallbackIcon: "" - property real fallbackIconSize: Style.fontSizeXXL - property real scaledRadius: imageRadius - - signal statusChanged(int status) - - color: Color.transparent - radius: scaledRadius - anchors.margins: Style.marginXXS - - Rectangle { - color: Color.transparent - anchors.fill: parent - - Image { - id: img - - anchors.fill: parent - source: imagePath - visible: false // Hide since we're using it as shader source - mipmap: true - smooth: true - asynchronous: true - antialiasing: true - fillMode: Image.PreserveAspectCrop - onStatusChanged: root.statusChanged(status) - } - - ShaderEffect { - property var source - // Use custom property names to avoid conflicts with final properties - property real itemWidth: root.width - property real itemHeight: root.height - property real cornerRadius: root.radius - property real imageOpacity: root.opacity - - anchors.fill: parent - fragmentShader: Qt.resolvedUrl(Quickshell.shellDir + "/Shaders/qsb/rounded_image.frag.qsb") - // Qt6 specific properties - ensure proper blending - supportsAtlasTextures: false - blending: true - - // Make sure the background is transparent - Rectangle { - id: background - - anchors.fill: parent - color: Color.transparent - z: -1 - } - - source: ShaderEffectSource { - sourceItem: img - hideSource: true - live: true - recursive: false - format: ShaderEffectSource.RGBA - } - - } - - // Fallback icon - Loader { - active: fallbackIcon !== undefined && fallbackIcon !== "" && (imagePath === undefined || imagePath === "") - anchors.centerIn: parent - - sourceComponent: NIcon { - anchors.centerIn: parent - icon: fallbackIcon - pointSize: fallbackIconSize - z: 0 - } - - } - - } - - // Border - Rectangle { - anchors.fill: parent - radius: parent.radius - color: Color.transparent - border.color: parent.borderColor - border.width: parent.borderWidth - antialiasing: true - z: 10 - } - -} diff --git a/config/quickshell/.config/quickshell/Noctalia/NLabel.qml b/config/quickshell/.config/quickshell/Noctalia/NLabel.qml deleted file mode 100644 index 721761b..0000000 --- a/config/quickshell/.config/quickshell/Noctalia/NLabel.qml +++ /dev/null @@ -1,34 +0,0 @@ -import QtQuick -import QtQuick.Layouts -import qs.Constants - -ColumnLayout { - id: root - - property string label: "" - property string description: "" - property color labelColor: Color.mOnSurface - property color descriptionColor: Color.mOnSurfaceVariant - - spacing: Style.marginXXS - Layout.fillWidth: true - - NText { - text: label - pointSize: Style.fontSizeL - font.weight: Style.fontWeightBold - color: labelColor - visible: label !== "" - Layout.fillWidth: true - } - - NText { - text: description - pointSize: Style.fontSizeS - color: descriptionColor - wrapMode: Text.WordWrap - visible: description !== "" - Layout.fillWidth: true - } - -} diff --git a/config/quickshell/.config/quickshell/Noctalia/NPanel.qml b/config/quickshell/.config/quickshell/Noctalia/NPanel.qml deleted file mode 100644 index 107bf44..0000000 --- a/config/quickshell/.config/quickshell/Noctalia/NPanel.qml +++ /dev/null @@ -1,459 +0,0 @@ -import QtQuick -import Quickshell -import Quickshell.Wayland -import qs.Noctalia -import qs.Services -import qs.Constants -import qs.Utils - -Loader { - id: root - - property ShellScreen screen - - property Component panelContent: null - property real preferredWidth: 700 - property real preferredHeight: 900 - property real preferredWidthRatio - property real preferredHeightRatio - property color panelBackgroundColor: Color.mSurface - property bool draggable: false - property var buttonItem: null - property string buttonName: "" - - property bool panelAnchorHorizontalCenter: false - property bool panelAnchorVerticalCenter: false - property bool panelAnchorTop: false - property bool panelAnchorBottom: false - property bool panelAnchorLeft: false - property bool panelAnchorRight: false - - property bool isMasked: false - - // Properties to support positioning relative to the opener (button) - property bool useButtonPosition: false - property point buttonPosition: Qt.point(0, 0) - property int buttonWidth: 0 - property int buttonHeight: 0 - - property bool panelKeyboardFocus: false - property bool backgroundClickEnabled: true - - // Animation properties - readonly property real originalScale: 0.7 - readonly property real originalOpacity: 0.0 - property real scaleValue: originalScale - property real opacityValue: originalOpacity - property real dimmingOpacity: 0 - - signal opened - signal closed - - active: false - asynchronous: true - - Component.onCompleted: { - PanelService.registerPanel(root) - } - - // ----------------------------------------- - // Functions to control background click behavior - function disableBackgroundClick() { - backgroundClickEnabled = false - } - - function enableBackgroundClick() { - // Add a small delay to prevent immediate close after drag release - enableBackgroundClickTimer.restart() - } - - Timer { - id: enableBackgroundClickTimer - interval: 100 - repeat: false - onTriggered: backgroundClickEnabled = true - } - - // ----------------------------------------- - function toggle(buttonItem, buttonName) { - if (!active) { - open(buttonItem, buttonName) - } else { - close() - } - } - - // ----------------------------------------- - function open(buttonItem, buttonName) { - root.buttonItem = buttonItem - root.buttonName = buttonName || "" - - setPosition() - - PanelService.willOpenPanel(root) - - backgroundClickEnabled = true - active = true - root.opened() - } - - // ----------------------------------------- - function close() { - dimmingOpacity = 0 - scaleValue = originalScale - opacityValue = originalOpacity - root.closed() - active = false - useButtonPosition = false - backgroundClickEnabled = true - PanelService.closedPanel(root) - } - - // ----------------------------------------- - function setPosition() { - // If we have a button name, we are landing here from an IPC call. - // IPC calls have no idead on which screen they panel will spawn. - // Resolve the button name to a proper button item now that we have a screen. - if (buttonName !== "" && root.screen !== null) { - buttonItem = BarService.lookupWidget(buttonName, root.screen.name) - } - - // Get the button position if provided - if (buttonItem !== undefined && buttonItem !== null) { - useButtonPosition = true - var itemPos = buttonItem.mapToItem(null, 0, 0) - buttonPosition = Qt.point(itemPos.x, itemPos.y) - buttonWidth = buttonItem.width - buttonHeight = buttonItem.height - } else { - useButtonPosition = false - } - } - - // ----------------------------------------- - sourceComponent: Component { - PanelWindow { - id: panelWindow - - readonly property bool isVertical: false - readonly property bool barIsVisible: (screen !== null) - readonly property real verticalBarWidth: Math.round(Style.barHeight) - - Component.onCompleted: { - Logger.log("NPanel", "Opened", root.objectName) - dimmingOpacity = Style.opacityHeavy - } - - Connections { - target: panelWindow - function onScreenChanged() { - root.screen = screen - - // If called from IPC always reposition if screen is updated - if (buttonName) { - setPosition() - } - // Logger.log("NPanel", "OnScreenChanged", root.screen.name) - } - } - - visible: true - color: Qt.alpha(Color.mShadow, dimmingOpacity) - - WlrLayershell.exclusionMode: ExclusionMode.Ignore - WlrLayershell.namespace: "noctalia-panel" - WlrLayershell.keyboardFocus: root.panelKeyboardFocus ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None - - mask: root.isMasked ? maskRegion : null - - Region { - id: maskRegion - } - - Behavior on color { - ColorAnimation { - duration: Style.animationNormal - } - } - - anchors.top: true - anchors.left: true - anchors.right: true - anchors.bottom: true - - // Close any panel with Esc without requiring focus - Shortcut { - sequences: ["Escape"] - enabled: root.active - onActivated: root.close() - context: Qt.WindowShortcut - } - - // Clicking outside of the rectangle to close - MouseArea { - anchors.fill: parent - enabled: root.backgroundClickEnabled - onClicked: root.close() - } - - // The actual panel's content - Rectangle { - id: panelBackground - color: panelBackgroundColor - radius: Style.radiusL - border.color: Color.mOutline - border.width: Math.max(1, Style.borderS ) - // Dragging support - property bool draggable: root.draggable - property bool isDragged: false - property real manualX: 0 - property real manualY: 0 - width: { - var w - if (preferredWidthRatio !== undefined) { - w = Math.round(Math.max(screen?.width * preferredWidthRatio, preferredWidth) ) - } else { - w = preferredWidth - } - // Clamp width so it is never bigger than the screen - return Math.min(w, screen?.width - Style.marginL * 2) - } - height: { - var h - if (preferredHeightRatio !== undefined) { - h = Math.round(Math.max(screen?.height * preferredHeightRatio, preferredHeight) ) - } else { - h = preferredHeight - } - - // Clamp width so it is never bigger than the screen - return Math.min(h, screen?.height - Style.barHeight - Style.marginL * 2) - } - - scale: root.scaleValue - opacity: root.isMasked ? 0 : root.opacityValue - x: isDragged ? manualX : calculatedX - y: isDragged ? manualY : calculatedY - - // --------------------------------------------- - // Does not account for corners are they are negligible and helps keep the code clean. - // --------------------------------------------- - property real marginTop: { - if (!barIsVisible) { - return 0 - } - return (Style.barHeight + Style.marginS) - } - - property real marginBottom: { - if (!barIsVisible) { - return 0 - } - return Style.marginS - } - - property real marginLeft: { - if (!barIsVisible) { - return 0 - } - return Style.marginS - } - - property real marginRight: { - if (!barIsVisible) { - return 0 - } - return Style.marginS - } - - // --------------------------------------------- - property int calculatedX: { - // Priority to fixed anchoring - if (panelAnchorHorizontalCenter) { - // Center horizontally but respect bar margins - var centerX = Math.round((panelWindow.width - panelBackground.width) / 2) - var minX = marginLeft - var maxX = panelWindow.width - panelBackground.width - marginRight - return Math.round(Math.max(minX, Math.min(centerX, maxX))) - } else if (panelAnchorLeft) { - return marginLeft - } else if (panelAnchorRight) { - return Math.round(panelWindow.width - panelBackground.width - marginRight) - } - - // No fixed anchoring - if (isVertical) { - // Vertical bar - if (barPosition === "right") { - // To the left of the right bar - return Math.round(panelWindow.width - panelBackground.width - marginRight) - } else { - // To the right of the left bar - return marginLeft - } - } else { - // Horizontal bar - if (root.useButtonPosition) { - // Position panel relative to button - var targetX = buttonPosition.x + (buttonWidth / 2) - (panelBackground.width / 2) - // Keep panel within screen bounds - var maxX = panelWindow.width - panelBackground.width - marginRight - var minX = marginLeft - return Math.round(Math.max(minX, Math.min(targetX, maxX))) - } else { - // Fallback to center horizontally - return Math.round((panelWindow.width - panelBackground.width) / 2) - } - } - } - - // --------------------------------------------- - property int calculatedY: { - // Priority to fixed anchoring - if (panelAnchorVerticalCenter) { - // Center vertically but respect bar margins - var centerY = Math.round((panelWindow.height - panelBackground.height) / 2) - var minY = marginTop - var maxY = panelWindow.height - panelBackground.height - marginBottom - return Math.round(Math.max(minY, Math.min(centerY, maxY))) - } else if (panelAnchorTop) { - return marginTop - } else if (panelAnchorBottom) { - return Math.round(panelWindow.height - panelBackground.height - marginBottom) - } - - // No fixed anchoring - if (isVertical) { - // Vertical bar - if (useButtonPosition) { - // Position panel relative to button - var targetY = buttonPosition.y + (buttonHeight / 2) - (panelBackground.height / 2) - // Keep panel within screen bounds - var maxY = panelWindow.height - panelBackground.height - marginBottom - var minY = marginTop - return Math.round(Math.max(minY, Math.min(targetY, maxY))) - } else { - // Fallback to center vertically - return Math.round((panelWindow.height - panelBackground.height) / 2) - } - } else { - return marginTop - } - } - - // Animate in when component is completed - Component.onCompleted: { - root.scaleValue = 1.0 - root.opacityValue = 1.0 - } - - // Reset drag position when panel closes - Connections { - target: root - function onClosed() { - panelBackground.isDragged = false - } - } - - // Prevent closing when clicking in the panel bg - MouseArea { - anchors.fill: parent - } - - // Animation behaviors - Behavior on scale { - NumberAnimation { - duration: Style.animationNormal - easing.type: Easing.OutExpo - } - } - - Behavior on opacity { - NumberAnimation { - duration: Style.animationNormal - easing.type: Easing.OutQuad - } - } - - Loader { - id: panelContentLoader - anchors.fill: parent - sourceComponent: root.panelContent - } - - // Handle drag move on the whole panel area - DragHandler { - id: dragHandler - target: null - enabled: panelBackground.draggable - property real dragStartX: 0 - property real dragStartY: 0 - onActiveChanged: { - if (active) { - // Capture current position into manual coordinates BEFORE toggling isDragged - panelBackground.manualX = panelBackground.x - panelBackground.manualY = panelBackground.y - dragStartX = panelBackground.x - dragStartY = panelBackground.y - panelBackground.isDragged = true - if (root.enableBackgroundClick) - root.disableBackgroundClick() - } else { - // Keep isDragged true so we continue using the manual x/y after release - if (root.enableBackgroundClick) - root.enableBackgroundClick() - } - } - onTranslationChanged: { - // Proposed new coordinates from fixed drag origin - var nx = dragStartX + translation.x - var ny = dragStartY + translation.y - - // Calculate gaps so we never overlap the bar on any side - var baseGap = Style.marginS - var floatExtraH = Settings.data.bar.floating ? Settings.data.bar.marginHorizontal * 2 * Style.marginXL : 0 - var floatExtraV = Settings.data.bar.floating ? Settings.data.bar.marginVertical * 2 * Style.marginXL : 0 - - var insetLeft = baseGap + ((barIsVisible && barPosition === "left") ? (Style.barHeight + floatExtraH) : 0) - var insetRight = baseGap + ((barIsVisible && barPosition === "right") ? (Style.barHeight + floatExtraH) : 0) - var insetTop = baseGap + ((barIsVisible && barPosition === "top") ? (Style.barHeight + floatExtraV) : 0) - var insetBottom = baseGap + ((barIsVisible && barPosition === "bottom") ? (Style.barHeight + floatExtraV) : 0) - - // Clamp within screen bounds accounting for insets - var maxX = panelWindow.width - panelBackground.width - insetRight - var minX = insetLeft - var maxY = panelWindow.height - panelBackground.height - insetBottom - var minY = insetTop - - panelBackground.manualX = Math.round(Math.max(minX, Math.min(nx, maxX))) - panelBackground.manualY = Math.round(Math.max(minY, Math.min(ny, maxY))) - } - } - - // Drag indicator border - Rectangle { - anchors.fill: parent - anchors.margins: 0 - color: Color.transparent - border.color: Color.mPrimary - border.width: Math.max(2, Style.borderL ) - radius: parent.radius - visible: panelBackground.isDragged && dragHandler.active - opacity: 0.8 - z: 3000 - - // Subtle glow effect - Rectangle { - anchors.fill: parent - anchors.margins: 0 - color: Color.transparent - border.color: Color.mPrimary - border.width: Math.max(1, Style.borderS ) - radius: parent.radius - opacity: 0.3 - } - } - } - } - } -} diff --git a/config/quickshell/.config/quickshell/Noctalia/NSlider.qml b/config/quickshell/.config/quickshell/Noctalia/NSlider.qml deleted file mode 100644 index 45f8491..0000000 --- a/config/quickshell/.config/quickshell/Noctalia/NSlider.qml +++ /dev/null @@ -1,152 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Effects -import qs.Constants -import qs.Noctalia -import qs.Services -import qs.Utils - -Slider { - id: root - - property var cutoutColor: Color.mSurface - property bool snapAlways: true - property real heightRatio: 0.7 - readonly property real knobDiameter: Math.round((Style.baseWidgetSize * heightRatio) / 2) * 2 - readonly property real trackHeight: Math.round((knobDiameter * 0.4) / 2) * 2 - readonly property real cutoutExtra: Math.round((Style.baseWidgetSize * 0.1) / 2) * 2 - - padding: cutoutExtra / 2 - snapMode: snapAlways ? Slider.SnapAlways : Slider.SnapOnRelease - implicitHeight: Math.max(trackHeight, knobDiameter) - - background: Rectangle { - x: root.leftPadding - y: root.topPadding + root.availableHeight / 2 - height / 2 - implicitWidth: Style.sliderWidth - implicitHeight: trackHeight - width: root.availableWidth - height: implicitHeight - radius: height / 2 - color: Qt.alpha(Color.mSurface, 0.5) - border.color: Qt.alpha(Color.mOutline, 0.5) - border.width: Math.max(1, Style.borderS) - - // A container composite shape that puts a semicircle on the end - Item { - id: activeTrackContainer - - width: root.visualPosition * parent.width - height: parent.height - - // The rounded end cap made from a rounded rectangle - Rectangle { - width: parent.height - height: parent.height - radius: width / 2 - color: Qt.darker(Color.mPrimary, 1.2) //starting color of gradient - } - - // The main rectangle - Rectangle { - x: parent.height / 2 - width: parent.width - x // Fills the rest of the container - height: parent.height - radius: 0 - - // Animated gradient fill - gradient: Gradient { - orientation: Gradient.Horizontal - - GradientStop { - position: 0 - color: Qt.darker(Color.mPrimary, 1.2) - - Behavior on color { - ColorAnimation { - duration: 300 - } - - } - - } - - GradientStop { - position: 0.5 - color: Color.mPrimary - - SequentialAnimation on position { - loops: Animation.Infinite - - NumberAnimation { - from: 0.3 - to: 0.7 - duration: 2000 - easing.type: Easing.InOutSine - } - - NumberAnimation { - from: 0.7 - to: 0.3 - duration: 2000 - easing.type: Easing.InOutSine - } - - } - - } - - GradientStop { - position: 1 - color: Qt.lighter(Color.mPrimary, 1.2) - } - - } - - } - - } - - // Circular cutout - Rectangle { - id: knobCutout - - implicitWidth: knobDiameter + cutoutExtra - implicitHeight: knobDiameter + cutoutExtra - radius: width / 2 - color: root.cutoutColor !== undefined ? root.cutoutColor : Color.mSurface - x: root.leftPadding + root.visualPosition * (root.availableWidth - root.knobDiameter) - cutoutExtra - anchors.verticalCenter: parent.verticalCenter - } - - } - - handle: Item { - implicitWidth: knobDiameter - implicitHeight: knobDiameter - x: root.leftPadding + root.visualPosition * (root.availableWidth - width) - anchors.verticalCenter: parent.verticalCenter - - Rectangle { - id: knob - - implicitWidth: knobDiameter - implicitHeight: knobDiameter - radius: width / 2 - color: root.pressed ? Color.mTertiary : Color.mSurface - border.color: Color.mPrimary - border.width: Math.max(1, Style.borderL) - anchors.centerIn: parent - - Behavior on color { - ColorAnimation { - duration: Style.animationFast - } - - } - - } - - } - -} diff --git a/config/quickshell/.config/quickshell/Services/AudioService.qml b/config/quickshell/.config/quickshell/Services/AudioService.qml index 0b63944..6a4ebb4 100644 --- a/config/quickshell/.config/quickshell/Services/AudioService.qml +++ b/config/quickshell/.config/quickshell/Services/AudioService.qml @@ -5,142 +5,632 @@ import Quickshell import Quickshell.Io import Quickshell.Services.Pipewire import qs.Utils +import qs.Services Singleton { id: root - 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) - } - } - return acc - }, { - "sources": [], - "sinks": [] - }) + // Rate limiting for volume feedback (minimum 100ms between sounds) + property var lastVolumeFeedbackTime: 0 + readonly property int minVolumeFeedbackInterval: 100 - readonly property PwNode sink: Pipewire.defaultAudioSink - readonly property PwNode source: Pipewire.defaultAudioSource - readonly property list sinks: nodes.sinks - readonly property list sources: nodes.sources + // Devices + readonly property PwNode sink: Pipewire.ready ? Pipewire.defaultAudioSink : null + readonly property PwNode source: validatedSource + readonly property bool hasInput: !!source + readonly property list sinks: deviceNodes.sinks + readonly property list sources: deviceNodes.sources - // Volume [0..1] is readonly from outside - readonly property alias volume: root._volume - property real _volume: sink?.audio?.volume ?? 0 + readonly property real epsilon: 0.005 - readonly property alias muted: root._muted - property bool _muted: !!sink?.audio?.muted + // Fallback state sourced from wpctl when PipeWire node values go stale. + property bool wpctlAvailable: false + property bool wpctlStateValid: false + property real wpctlOutputVolume: 0 + property bool wpctlOutputMuted: true - // Input volume [0..1] is readonly from outside - readonly property alias inputVolume: root._inputVolume - property real _inputVolume: source?.audio?.volume ?? 0 + function clampOutputVolume(vol: real): real { + const maxVolume = 1.0; + if (vol === undefined || isNaN(vol)) { + return 0; + } + return Math.max(0, Math.min(maxVolume, vol)); + } - readonly property alias inputMuted: root._inputMuted - property bool _inputMuted: !!source?.audio?.muted + function refreshWpctlOutputState(): void { + if (!wpctlAvailable || wpctlStateProcess.running) { + return; + } + wpctlStateProcess.command = ["wpctl", "get-volume", "@DEFAULT_AUDIO_SINK@"]; + wpctlStateProcess.running = true; + } + + function applyWpctlOutputState(raw: string): bool { + const text = String(raw || "").trim(); + const match = text.match(/Volume:\s*([0-9]*\.?[0-9]+)/i); + if (!match || match.length < 2) { + return false; + } + + const parsedVolume = Number(match[1]); + if (isNaN(parsedVolume)) { + return false; + } + + wpctlOutputVolume = clampOutputVolume(parsedVolume); + wpctlOutputMuted = /\[MUTED\]/i.test(text); + wpctlStateValid = true; + return true; + } + + // Output volume (prefer wpctl state when available) + readonly property real volume: { + if (wpctlAvailable && wpctlStateValid) { + return clampOutputVolume(wpctlOutputVolume); + } + + if (!sink?.audio) + return 0; + + return clampOutputVolume(sink.audio.volume); + } + readonly property bool muted: { + if (wpctlAvailable && wpctlStateValid) { + return wpctlOutputMuted; + } + return sink?.audio?.muted ?? true; + } + + // Input Volume - read directly from device + readonly property real inputVolume: { + if (!source?.audio) + return 0; + const vol = source.audio.volume; + if (vol === undefined || isNaN(vol)) + return 0; + const maxVolume = 1.0; + return Math.max(0, Math.min(maxVolume, vol)); + } + readonly property bool inputMuted: source?.audio?.muted ?? true + + // Allow callers to skip the next OSD notification when they are already + // presenting volume state (e.g. the Audio Panel UI). We track this as a short + // time window so suppression applies to every monitor, not just the first one + // that receives the signal. + property double outputOSDSuppressedUntilMs: 0 + property double inputOSDSuppressedUntilMs: 0 + + function suppressOutputOSD(durationMs = 400) { + const target = Date.now() + durationMs; + outputOSDSuppressedUntilMs = Math.max(outputOSDSuppressedUntilMs, target); + } + + function suppressInputOSD(durationMs = 400) { + const target = Date.now() + durationMs; + inputOSDSuppressedUntilMs = Math.max(inputOSDSuppressedUntilMs, target); + } + + function consumeOutputOSDSuppression(): bool { + return Date.now() < outputOSDSuppressedUntilMs; + } + + function consumeInputOSDSuppression(): bool { + return Date.now() < inputOSDSuppressedUntilMs; + } readonly property real stepVolume: 5 / 100.0 + // Filtered device nodes (non-stream sinks and sources) + readonly property var deviceNodes: Pipewire.ready ? Pipewire.nodes.values.reduce((acc, node) => { + if (!node.isStream) { + // Filter out quickshell nodes (unlikely to be devices, but for consistency) + const name = node.name || ""; + const mediaName = (node.properties && node.properties["media.name"]) || ""; + if (name === "quickshell" || mediaName === "quickshell") { + return acc; + } + + if (node.isSink) { + acc.sinks.push(node); + } else if (node.audio) { + acc.sources.push(node); + } + } + return acc; + }, { + "sources": [], + "sinks": [] + }) : { + "sources": [], + "sinks": [] + } + + // Validated source (ensures it's a proper audio source, not a sink) + readonly property PwNode validatedSource: { + if (!Pipewire.ready) { + return null; + } + const raw = Pipewire.defaultAudioSource; + if (!raw || raw.isSink || !raw.audio) { + return null; + } + // Optional: check type if available (type reflects media.class per docs) + if (raw.type && typeof raw.type === "string" && !raw.type.startsWith("Audio/Source")) { + return null; + } + return raw; + } + + // Internal state for feedback loop prevention + property bool isSettingOutputVolume: false + property bool isSettingInputVolume: false + + // Bind default sink and source to ensure their properties are available + PwObjectTracker { + id: sinkTracker + objects: root.sink ? [root.sink] : [] + } + + PwObjectTracker { + id: sourceTracker + objects: root.source ? [root.source] : [] + } + + // Track links to the default sink to find active streams + PwNodeLinkTracker { + id: sinkLinkTracker + node: root.sink + } + + // Find application streams that are connected to the default sink + readonly property var appStreams: { + if (!Pipewire.ready || !root.sink) { + return []; + } + + var connectedStreamIds = {}; + var connectedStreams = []; + + // Use PwNodeLinkTracker to get properly bound link groups + if (!sinkLinkTracker.linkGroups) { + return []; + } + + var linkGroupsCount = 0; + if (sinkLinkTracker.linkGroups.length !== undefined) { + linkGroupsCount = sinkLinkTracker.linkGroups.length; + } else if (sinkLinkTracker.linkGroups.count !== undefined) { + linkGroupsCount = sinkLinkTracker.linkGroups.count; + } else { + return []; + } + + if (linkGroupsCount === 0) { + return []; + } + + var intermediateNodeIds = {}; + var nodesToCheck = []; + + for (var i = 0; i < linkGroupsCount; i++) { + var linkGroup; + if (sinkLinkTracker.linkGroups.get) { + linkGroup = sinkLinkTracker.linkGroups.get(i); + } else { + linkGroup = sinkLinkTracker.linkGroups[i]; + } + + if (!linkGroup || !linkGroup.source) { + continue; + } + + var sourceNode = linkGroup.source; + + // Filter out quickshell + const name = sourceNode.name || ""; + const mediaName = (sourceNode.properties && sourceNode.properties["media.name"]) || ""; + if (name === "quickshell" || mediaName === "quickshell") { + continue; + } + + // If it's a stream node, add it directly + if (sourceNode.isStream && sourceNode.audio) { + if (!connectedStreamIds[sourceNode.id]) { + connectedStreamIds[sourceNode.id] = true; + connectedStreams.push(sourceNode); + } + } else { + // Not a stream - this is an intermediate node, track it + intermediateNodeIds[sourceNode.id] = true; + nodesToCheck.push(sourceNode); + } + } + + // If we found intermediate nodes, we need to find streams connected to them + if (nodesToCheck.length > 0 || connectedStreams.length === 0) { + try { + var allNodes = Pipewire.nodes.values || []; + + // Find all stream nodes + for (var j = 0; j < allNodes.length; j++) { + var node = allNodes[j]; + if (!node || !node.isStream || !node.audio) { + continue; + } + + // Filter out quickshell + const nodeName = node.name || ""; + const nodeMediaName = (node.properties && node.properties["media.name"]) || ""; + if (nodeName === "quickshell" || nodeMediaName === "quickshell") { + continue; + } + + var streamId = node.id; + if (connectedStreamIds[streamId]) { + continue; + } + + if (Object.keys(intermediateNodeIds).length > 0) { + connectedStreamIds[streamId] = true; + connectedStreams.push(node); + } else if (connectedStreams.length === 0) { + connectedStreamIds[streamId] = true; + connectedStreams.push(node); + } + } + } catch (e) {} + } + + return connectedStreams; + } + + // Bind all devices to ensure their properties are available PwObjectTracker { objects: [...root.sinks, ...root.sources] } - Connections { - target: sink?.audio ? sink?.audio : null - - function onVolumeChanged() { - var vol = (sink?.audio.volume ?? 0) - if (isNaN(vol)) { - vol = 0 - } - root._volume = vol - } - - function onMutedChanged() { - root._muted = (sink?.audio.muted ?? true) - Logger.log("AudioService", "OnMuteChanged:", root._muted) - } + Component.onCompleted: { + wpctlAvailabilityProcess.running = true; } Connections { - target: source?.audio ? source?.audio : null - - function onVolumeChanged() { - var vol = (source?.audio.volume ?? 0) - if (isNaN(vol)) { - vol = 0 + target: root + function onSinkChanged() { + if (root.wpctlAvailable) { + root.refreshWpctlOutputState(); } - root._inputVolume = vol - } - - function onMutedChanged() { - root._inputMuted = (source?.audio.muted ?? true) - Logger.log("AudioService", "OnInputMuteChanged:", root._inputMuted) } } + Timer { + id: wpctlPollTimer + // Safety net only; regular updates are event-driven from sink audio signals. + interval: 20000 + running: root.wpctlAvailable + repeat: true + onTriggered: root.refreshWpctlOutputState() + } + + Process { + id: wpctlAvailabilityProcess + command: ["sh", "-c", "command -v wpctl"] + running: false + + onExited: function (code) { + root.wpctlAvailable = (code === 0); + root.wpctlStateValid = false; + if (root.wpctlAvailable) { + root.refreshWpctlOutputState(); + } + } + + stdout: StdioCollector {} + stderr: StdioCollector {} + } + + Process { + id: wpctlStateProcess + running: false + + onExited: function (code) { + if (code !== 0 || !root.applyWpctlOutputState(stdout.text)) { + root.wpctlStateValid = false; + } + } + + stdout: StdioCollector {} + stderr: StdioCollector {} + } + + Process { + id: wpctlSetVolumeProcess + running: false + + onExited: function (code) { + root.isSettingOutputVolume = false; + if (code !== 0) { + Logger.w("AudioService", "wpctl set-volume failed, falling back to PipeWire node audio"); + if (root.sink?.audio) { + root.sink.audio.muted = false; + root.sink.audio.volume = root.clampOutputVolume(root.wpctlOutputVolume); + } + } + root.refreshWpctlOutputState(); + } + + stdout: StdioCollector {} + stderr: StdioCollector {} + } + + Process { + id: wpctlSetMuteProcess + running: false + + onExited: function (_code) { + root.refreshWpctlOutputState(); + } + + stdout: StdioCollector {} + stderr: StdioCollector {} + } + + // Watch output device changes for clamping + Connections { + target: sink?.audio ?? null + + function onVolumeChanged() { + if (root.wpctlAvailable) { + root.refreshWpctlOutputState(); + } + + // Ignore volume changes if we're the one setting it (to prevent feedback loop) + if (root.isSettingOutputVolume) { + return; + } + + if (!root.sink?.audio) { + return; + } + + const vol = root.sink.audio.volume; + if (vol === undefined || isNaN(vol)) { + return; + } + + const maxVolume = 1.0; + + // If volume exceeds max, clamp it (but only if we didn't just set it) + if (vol > maxVolume) { + root.isSettingOutputVolume = true; + Qt.callLater(() => { + if (root.sink?.audio && root.sink.audio.volume > maxVolume) { + root.sink.audio.volume = maxVolume; + } + root.isSettingOutputVolume = false; + }); + } + } + + function onMutedChanged() { + if (root.wpctlAvailable) { + root.refreshWpctlOutputState(); + } + } + } + + // Watch input device changes for clamping + Connections { + target: source?.audio ?? null + + function onVolumeChanged() { + // Ignore volume changes if we're the one setting it (to prevent feedback loop) + if (root.isSettingInputVolume) { + return; + } + + if (!root.source?.audio) { + return; + } + + const vol = root.source.audio.volume; + if (vol === undefined || isNaN(vol)) { + return; + } + + const maxVolume = 1.0; + + // If volume exceeds max, clamp it (but only if we didn't just set it) + if (vol > maxVolume) { + root.isSettingInputVolume = true; + Qt.callLater(() => { + if (root.source?.audio && root.source.audio.volume > maxVolume) { + root.source.audio.volume = maxVolume; + } + root.isSettingInputVolume = false; + }); + } + } + } + + // Output Control function increaseVolume() { - setVolume(volume + stepVolume) + if (!Pipewire.ready || (!sink?.audio && !wpctlAvailable)) { + return; + } + const maxVolume = 1.0; + if (volume >= maxVolume) { + return; + } + setVolume(Math.min(maxVolume, volume + stepVolume)); } function decreaseVolume() { - setVolume(volume - stepVolume) + if (!Pipewire.ready || (!sink?.audio && !wpctlAvailable)) { + return; + } + if (volume <= 0) { + return; + } + setVolume(Math.max(0, volume - stepVolume)); } function setVolume(newVolume: real) { - if (sink?.ready && sink?.audio) { - // Clamp it accordingly - sink.audio.muted = false - sink.audio.volume = Math.max(0, Math.min(1.0, newVolume)) - } else { - Logger.warn("AudioService", "No sink available") + if (!Pipewire.ready || (!sink?.audio && !wpctlAvailable)) { + Logger.w("AudioService", "No sink available or not ready"); + return; } + + const clampedVolume = clampOutputVolume(newVolume); + const delta = Math.abs(clampedVolume - volume); + if (delta < root.epsilon) { + return; + } + + if (wpctlAvailable) { + if (wpctlSetVolumeProcess.running) { + return; + } + + isSettingOutputVolume = true; + wpctlOutputMuted = false; + wpctlOutputVolume = clampedVolume; + wpctlStateValid = true; + + const volumePct = Math.round(clampedVolume * 10000) / 100; + wpctlSetVolumeProcess.command = ["sh", "-c", "wpctl set-mute @DEFAULT_AUDIO_SINK@ 0 && wpctl set-volume @DEFAULT_AUDIO_SINK@ " + volumePct + "%"]; + wpctlSetVolumeProcess.running = true; + + return; + } + + if (!sink?.ready || !sink?.audio) { + Logger.w("AudioService", "No sink available or not ready"); + return; + } + + // Set flag to prevent feedback loop, then set the actual volume + isSettingOutputVolume = true; + sink.audio.muted = false; + sink.audio.volume = clampedVolume; + + + // Clear flag after a short delay to allow external changes to be detected + Qt.callLater(() => { + isSettingOutputVolume = false; + }); } function setOutputMuted(muted: bool) { - if (sink?.ready && sink?.audio) { - sink.audio.muted = muted - } else { - Logger.warn("AudioService", "No sink available") + if (!Pipewire.ready || (!sink?.audio && !wpctlAvailable)) { + Logger.w("AudioService", "No sink available or Pipewire not ready"); + return; } + + if (wpctlAvailable) { + if (wpctlSetMuteProcess.running) { + return; + } + + wpctlOutputMuted = muted; + wpctlStateValid = true; + wpctlSetMuteProcess.command = ["wpctl", "set-mute", "@DEFAULT_AUDIO_SINK@", muted ? "1" : "0"]; + wpctlSetMuteProcess.running = true; + return; + } + + sink.audio.muted = muted; + } + + function getOutputIcon() { + if (muted) + return "volume-mute"; + + const maxVolume = 1.0; + const clampedVolume = Math.max(0, Math.min(volume, maxVolume)); + + // Show volume-x icon when volume is effectively 0% (within rounding threshold) + if (clampedVolume < root.epsilon) { + return "volume-x"; + } + if (clampedVolume <= 0.5) { + return "volume-low"; + } + return "volume-high"; + } + + // Input Control + function increaseInputVolume() { + if (!Pipewire.ready || !source?.audio) { + return; + } + const maxVolume = 1.0; + if (inputVolume >= maxVolume) { + return; + } + setInputVolume(Math.min(maxVolume, inputVolume + stepVolume)); + } + + function decreaseInputVolume() { + if (!Pipewire.ready || !source?.audio) { + return; + } + setInputVolume(Math.max(0, inputVolume - stepVolume)); } function setInputVolume(newVolume: real) { - if (source?.ready && source?.audio) { - // Clamp it accordingly - source.audio.muted = false - source.audio.volume = Math.max(0, Math.min(1.0, newVolume)) - } else { - Logger.warn("AudioService", "No source available") + if (!Pipewire.ready || !source?.ready || !source?.audio) { + Logger.w("AudioService", "No source available or not ready"); + return; } + + const maxVolume = 1.0; + const clampedVolume = Math.max(0, Math.min(maxVolume, newVolume)); + const delta = Math.abs(clampedVolume - source.audio.volume); + if (delta < root.epsilon) { + return; + } + + // Set flag to prevent feedback loop, then set the actual volume + isSettingInputVolume = true; + source.audio.muted = false; + source.audio.volume = clampedVolume; + + // Clear flag after a short delay to allow external changes to be detected + Qt.callLater(() => { + isSettingInputVolume = false; + }); } function setInputMuted(muted: bool) { - if (source?.ready && source?.audio) { - source.audio.muted = muted - } else { - Logger.warn("AudioService", "No source available") + if (!Pipewire.ready || !source?.audio) { + Logger.w("AudioService", "No source available or Pipewire not ready"); + return; } + + source.audio.muted = muted; } + function getInputIcon() { + if (inputMuted || inputVolume <= Number.EPSILON) { + return "microphone-mute"; + } + return "microphone"; + } + + // Device Selection function setAudioSink(newSink: PwNode): void { - Pipewire.preferredDefaultAudioSink = newSink - // Volume is changed by the sink change - root._volume = newSink?.audio?.volume ?? 0 - root._muted = !!newSink?.audio?.muted + if (!Pipewire.ready) { + Logger.w("AudioService", "Pipewire not ready"); + return; + } + Pipewire.preferredDefaultAudioSink = newSink; } function setAudioSource(newSource: PwNode): void { - Pipewire.preferredDefaultAudioSource = newSource - // Volume is changed by the source change - root._inputVolume = newSource?.audio?.volume ?? 0 - root._inputMuted = !!newSource?.audio?.muted - } - - function toggleMute() { - setOutputMuted(!muted) + if (!Pipewire.ready) { + Logger.w("AudioService", "Pipewire not ready"); + return; + } + Pipewire.preferredDefaultAudioSource = newSource; } } diff --git a/config/quickshell/.config/quickshell/Services/BackgroundService.qml b/config/quickshell/.config/quickshell/Services/BackgroundService.qml new file mode 100644 index 0000000..dfa015a --- /dev/null +++ b/config/quickshell/.config/quickshell/Services/BackgroundService.qml @@ -0,0 +1,94 @@ +import QtQuick +import Quickshell +import qs.Constants +import qs.Services +import qs.Utils +pragma Singleton + +Singleton { + id: root + + property string backgroundWidth: "2560" + property string backgroundHeight: "1440" + property string cachedPath: "" + property string cachedBlurredPath: "" + property string previewPath: "" + // Preserved for getBlurredOverview + property string tintColor: Colors.mSurface + property bool isDarkMode: false + + function loadBackground() { + if (!SettingsService.backgroundPath) { + Logger.w("BackgroundService", "No background path set, skipping loading background."); + return ; + } + ImageCacheService.getLarge(SettingsService.backgroundPath, backgroundWidth, backgroundHeight, function(path) { + if (!path) { + Logger.e("BackgroundService", "Failed to load background image from path: " + SettingsService.backgroundPath); + return ; + } + cachedPath = path; + Logger.i("BackgroundService", "Loaded background image as cached path: " + path); + ImageCacheService.getBlurredOverview(SettingsService.backgroundPath, backgroundWidth, backgroundHeight, tintColor, isDarkMode, function(blurredPath) { + if (!blurredPath) { + Logger.e("BackgroundService", "Failed to load blurred background image from path: " + SettingsService.backgroundPath); + return ; + } + cachedBlurredPath = blurredPath; + Logger.i("BackgroundService", "Loaded blurred background image as cached blurred path: " + blurredPath); + }); + }); + } + + function previewWallpaper(path) { + if (!path) { + previewPath = ""; + return ; + } + ImageCacheService.checkFileExists(path, function(exists) { + if (!exists) { + previewPath = ""; + return ; + } + previewPath = path; + }); + } + + function setWallpaper(path) { + if (!path) + return ; + + previewPath = ""; // clear preview path + ImageCacheService.checkFileExists(path, function(exists) { + if (!exists) + return ; + + SettingsService.backgroundPath = path; + loadWallpaperDebouncer.start(); + }); + } + + Component.onCompleted: { + loadWallpaperDebouncer.start(); + } + + Connections { + function onBackgroundPathChanged() { + loadWallpaperDebouncer.start(); + } + + target: SettingsService + } + + Timer { + id: loadWallpaperDebouncer + + interval: 200 + running: false + repeat: false + onTriggered: { + root.loadBackground(); + } + } + +} diff --git a/config/quickshell/.config/quickshell/Services/BarService.qml b/config/quickshell/.config/quickshell/Services/BarService.qml new file mode 100644 index 0000000..8539445 --- /dev/null +++ b/config/quickshell/.config/quickshell/Services/BarService.qml @@ -0,0 +1,62 @@ +import QtQuick +import Quickshell +import qs.Services +pragma Singleton + +Singleton { + property var _leftSideBarMap: ({ + }) + property var _rightSideBarMap: ({ + }) + property var _currentLeftSidebar: null + property var _currentRightSidebar: null + // property int leftOffset: _currentLeftSidebar?.barWidth ?? 0 + // property int rightOffset: _currentRightSidebar?.barWidth ?? 0 + property bool focusMode: Niri.hasFocusedWindow || _currentLeftSidebar !== null || _currentRightSidebar !== null + + function getLeftSidebar(screen) { + return _leftSideBarMap[screen] || null; + } + + function getRightSidebar(screen) { + return _rightSideBarMap[screen] || null; + } + + // screen should be string, like "eDP-1" + function registerLeft(screen, sidebar) { + _leftSideBarMap[screen] = sidebar; + } + + function registerRight(screen, sidebar) { + _rightSideBarMap[screen] = sidebar; + } + + function toggleLeft() { + if (_currentLeftSidebar) { + _currentLeftSidebar.close(); + _currentLeftSidebar = null; + } else { + const screen = Niri.focusedOutput; + const sidebar = _leftSideBarMap[screen]; + if (sidebar) { + sidebar.open(); + _currentLeftSidebar = sidebar; + } + } + } + + function toggleRight() { + if (_currentRightSidebar) { + _currentRightSidebar.close(); + _currentRightSidebar = null; + } else { + const screen = Niri.focusedOutput; + const sidebar = _rightSideBarMap[screen]; + if (sidebar) { + sidebar.open(); + _currentRightSidebar = sidebar; + } + } + } + +} diff --git a/config/quickshell/.config/quickshell/Services/BatteryService.qml b/config/quickshell/.config/quickshell/Services/BatteryService.qml new file mode 100644 index 0000000..6f4191d --- /dev/null +++ b/config/quickshell/.config/quickshell/Services/BatteryService.qml @@ -0,0 +1,285 @@ +pragma Singleton +import QtQml +import QtQuick + +import Quickshell +import Quickshell.Io +import Quickshell.Services.UPower +import qs.Utils +import qs.Services + +Singleton { + id: root + + readonly property var primaryDevice: _laptopBattery || _bluetoothBattery || null // Primary battery device (prioritizes laptop over Bluetooth) + readonly property real batteryPercentage: getPercentage(primaryDevice) + readonly property bool batteryCharging: isCharging(primaryDevice) + readonly property bool batteryPluggedIn: isPluggedIn(primaryDevice) + readonly property bool batteryReady: isDeviceReady(primaryDevice) + readonly property bool batteryPresent: isDevicePresent(primaryDevice) + readonly property real warningThreshold: Settings.data.systemMonitor.batteryWarningThreshold + readonly property real criticalThreshold: Settings.data.systemMonitor.batteryCriticalThreshold + readonly property string batteryIcon: getIcon(batteryPercentage, batteryCharging, batteryPluggedIn, batteryReady) + + readonly property var laptopBatteries: UPower.devices.values.filter(d => d.isLaptopBattery).sort((x, y) => { + // Force DisplayDevice to the top + if (x.nativePath.includes("DisplayDevice")) + return -1; + if (y.nativePath.includes("DisplayDevice")) + return 1; + + // Standard string comparison works for BAT0 vs BAT1 + return x.nativePath.localeCompare(y.nativePath, undefined, { + numeric: true + }); + }) + + readonly property var bluetoothBatteries: { + var list = []; + var btArray = BluetoothService.devices?.values || []; + for (var i = 0; i < btArray.length; i++) { + var btd = btArray[i]; + if (btd && btd.connected && btd.batteryAvailable) { + list.push(btd); + } + } + return list; + } + + readonly property var _laptopBattery: UPower.displayDevice.isPresent ? UPower.displayDevice : (laptopBatteries.length > 0 ? laptopBatteries[0] : null) + readonly property var _bluetoothBattery: bluetoothBatteries.length > 0 ? bluetoothBatteries[0] : null + + property var deviceModel: { + var model = [ + { + "key": "__default__", + "name": I18n.tr("bar.battery.device-default") + } + ]; + const devices = UPower.devices?.values || []; + for (let d of devices) { + if (!d || d.type === UPowerDeviceType.LinePower) { + continue; + } + model.push({ + key: d.nativePath || "", + name: d.model || d.nativePath || I18n.tr("common.unknown") + }); + } + return model; + } + + property var _hasNotified: ({}) + + function findDevice(nativePath) { + if (!nativePath || nativePath === "__default__" || nativePath === "DisplayDevice") { + return _laptopBattery; + } + + if (!UPower.devices) { + return null; + } + + const devices = UPower.devices?.values || []; + for (let d of devices) { + if (d && d.nativePath === nativePath) { + if (d.type === UPowerDeviceType.LinePower) { + continue; + } + return d; + } + } + return null; + } + + function isDevicePresent(device) { + if (!device) { + return false; + } + + // Handle Bluetooth devices (identified by having batteryAvailable property) + if (device.batteryAvailable !== undefined) { + return device.connected === true; + } + + // Handle UPower devices + if (device.type !== undefined) { + if (device.type === UPowerDeviceType.Battery && device.isPresent !== undefined) { + return device.isPresent === true; + } + // Fallback for non-battery UPower devices or if isPresent is missing + return device.ready && device.percentage !== undefined; + } + return false; + } + + function isDeviceReady(device) { + if (!isDevicePresent(device)) { + return false; + } + if (device.batteryAvailable !== undefined) { + return device.battery !== undefined; + } + return device.ready && device.percentage !== undefined; + } + + function getPercentage(device) { + if (!device) { + return -1; + } + if (device.batteryAvailable !== undefined) { + return Math.round((device.battery || 0) * 100); + } + return Math.round((device.percentage || 0) * 100); + } + + function isCharging(device) { + if (!device || isBluetoothDevice(device)) { + // Tracking bluetooth devices can charge or not is a loop hole, none of my devices has it, even if it possible?! + return false; // Assuming not charging until someone/quickshell brings a way to do pretty unlikely. + } + if (device.state !== undefined) { + return device.state === UPowerDeviceState.Charging; + } + return false; + } + + function isPluggedIn(device) { + if (!device || isBluetoothDevice(device)) { + // Tracking bluetooth devices can charge or not is a loop hole, none of my devices has it, even if it possible?! + return false; // Assuming not charging until someone/quickshell brings a way to do pretty unlikely. + } + if (device.state !== undefined) { + return device.state === UPowerDeviceState.FullyCharged || device.state === UPowerDeviceState.PendingCharge; + } + return false; + } + + function isCriticalBattery(device) { + return (!isCharging(device) && !isPluggedIn(device)) && getPercentage(device) <= criticalThreshold; + } + + function isLowBattery(device) { + return (!isCharging(device) && !isPluggedIn(device)) && getPercentage(device) <= warningThreshold && getPercentage(device) > criticalThreshold; + } + + function isBluetoothDevice(device) { + if (!device) { + return false; + } + // Check for Quickshell Bluetooth device property + if (device.batteryAvailable !== undefined) { + return true; + } + // Check for UPower device path indicating it's a Bluetooth device + if (device.nativePath && (device.nativePath.includes("bluez") || device.nativePath.includes("bluetooth"))) { + return true; + } + return false; + } + + function getDeviceName(device) { + if (!isDeviceReady(device)) { + return ""; + } + + if (!isBluetoothDevice(device) && device.isLaptopBattery) { + // If there is more than one battery explicitly name them + // Logger.e("BatteryDebug", "Available Battery count: " + laptopBatteries.length); // can be useful for debugging + if (laptopBatteries.length > 1 && device.nativePath) { + if (device.nativePath === "DisplayDevice") { + return "All batteries (combined)"; // TODO: i18n + } + var match = device.nativePath.match(/(\d+)$/); + if (match) { + // In case of 2 batteries: bat0 => bat1 bat1 => bat2 + return I18n.tr("common.battery") + " " + (parseInt(match[1]) + 1); // Append numbers + } + } + // Return Battery if there is only one + return I18n.tr("common.battery"); + } + + if (isBluetoothDevice(device) && device.name) { + return device.name; + } + + if (device.model) { + return device.model; + } + + return ""; + } + + function getIcon(percent, charging, pluggedIn, isReady) { + if (!isReady) { + return "battery-exclamation"; + } + if (charging) { + return "battery-charging"; + } + if (pluggedIn) { + return "battery-charging-2"; + } + + const icons = [ + { + threshold: 86, + icon: "battery-4" + }, + { + threshold: 56, + icon: "battery-3" + }, + { + threshold: 31, + icon: "battery-2" + }, + { + threshold: 11, + icon: "battery-1" + }, + { + threshold: 0, + icon: "battery" + } + ]; + + const match = icons.find(tier => percent >= tier.threshold); + return match ? match.icon : "battery-off"; // New fallback icon clearly represent if nothing is true here. + } + + function getRateText(device) { + if (!device || device.changeRate === undefined) { + return ""; + } + const rate = Math.abs(device.changeRate); + if (device.timeToFull > 0) { + return I18n.tr("battery.charging-rate", { + "rate": rate.toFixed(2) + }); + } else if (device.timeToEmpty > 0) { + return I18n.tr("battery.discharging-rate", { + "rate": rate.toFixed(2) + }); + } + } + + function getTimeRemainingText(device) { + if (!isDeviceReady(device)) { + return I18n.tr("battery.no-battery-detected"); + } + if (isPluggedIn(device)) { + return I18n.tr("battery.plugged-in"); + } else if (device.timeToFull > 0) { + return I18n.tr("battery.time-until-full", { + "time": Time.formatVagueHumanReadableDuration(device.timeToFull) + }); + } else if (device.timeToEmpty > 0) { + return I18n.tr("battery.time-left", { + "time": Time.formatVagueHumanReadableDuration(device.timeToEmpty) + }); + } + return I18n.tr("common.idle"); + } +} diff --git a/config/quickshell/.config/quickshell/Services/BluetoothService.qml b/config/quickshell/.config/quickshell/Services/BluetoothService.qml index 66b98ee..84c39c8 100644 --- a/config/quickshell/.config/quickshell/Services/BluetoothService.qml +++ b/config/quickshell/.config/quickshell/Services/BluetoothService.qml @@ -32,7 +32,7 @@ Singleton { } function init() { - Logger.log("Bluetooth", "Service initialized") + Logger.i("Bluetooth", "Service initialized") } Timer { @@ -46,11 +46,11 @@ Singleton { target: adapter function onEnabledChanged() { if (!adapter) { - Logger.warn("Bluetooth", "onEnabledChanged", "No adapter available") + Logger.w("Bluetooth", "onEnabledChanged", "No adapter available") return } - Logger.debug("Bluetooth", "onEnableChanged", adapter.enabled) + Logger.d("Bluetooth", "onEnableChanged", adapter.enabled) if (adapter.enabled) { discoveryTimer.running = true } @@ -226,7 +226,7 @@ Singleton { return } - Logger.i("Bluetooth", "SetBluetoothEnabled", state) + Logger.d("Bluetooth", "SetBluetoothEnabled", state) adapter.enabled = state } } diff --git a/config/quickshell/.config/quickshell/Services/BrightnessService.qml b/config/quickshell/.config/quickshell/Services/BrightnessService.qml index 6409cf8..401f93d 100644 --- a/config/quickshell/.config/quickshell/Services/BrightnessService.qml +++ b/config/quickshell/.config/quickshell/Services/BrightnessService.qml @@ -11,47 +11,161 @@ Singleton { property list ddcMonitors: [] readonly property list monitors: variants.instances property bool appleDisplayPresent: false + property list availableBacklightDevices: [] + property bool enableDdcSupport: true + property var backlightDeviceMappings: [] + property int brightnessStep: 10 + property bool enforceMinimumBrightness: false function getMonitorForScreen(screen: ShellScreen): var { - return monitors.find(m => m.modelData === screen) + return monitors.find(m => m.modelData === screen); } // Signal emitted when a specific monitor's brightness changes, includes monitor context signal monitorBrightnessChanged(var monitor, real newBrightness) function getAvailableMethods(): list { - var methods = [] - if (monitors.some(m => m.isDdc)) - methods.push("ddcutil") + var methods = []; + if (root.enableDdcSupport && monitors.some(m => m.isDdc)) + methods.push("ddcutil"); if (monitors.some(m => !m.isDdc)) - methods.push("internal") + methods.push("internal"); if (appleDisplayPresent) - methods.push("apple") - return methods + methods.push("apple"); + return methods; } // Global helpers for IPC and shortcuts function increaseBrightness(): void { - monitors.forEach(m => m.increaseBrightness()) + monitors.forEach(m => m.increaseBrightness()); } function decreaseBrightness(): void { - monitors.forEach(m => m.decreaseBrightness()) + monitors.forEach(m => m.decreaseBrightness()); + } + + function setBrightness(value: real): void { + monitors.forEach(m => m.setBrightnessDebounced(value)); } function getDetectedDisplays(): list { - return detectedDisplays + return detectedDisplays; + } + + function normalizeBacklightDevicePath(devicePath): string { + if (devicePath === undefined || devicePath === null) + return ""; + + var normalized = String(devicePath).trim(); + if (normalized === "") + return ""; + + if (normalized.startsWith("/sys/class/backlight/")) + return normalized; + + if (normalized.indexOf("/") === -1) + return "/sys/class/backlight/" + normalized; + + return normalized; + } + + function getBacklightDeviceName(devicePath): string { + var normalized = normalizeBacklightDevicePath(devicePath); + if (normalized === "") + return ""; + + var parts = normalized.split("/"); + while (parts.length > 0 && parts[parts.length - 1] === "") { + parts.pop(); + } + return parts.length > 0 ? parts[parts.length - 1] : ""; + } + + function getMappedBacklightDevice(outputName): string { + var normalizedOutput = String(outputName || "").trim(); + if (normalizedOutput === "") + return ""; + + var mappings = root.backlightDeviceMappings || []; + for (var i = 0; i < mappings.length; i++) { + var mapping = mappings[i]; + if (!mapping || typeof mapping !== "object") + continue; + + if (String(mapping.output || "").trim() === normalizedOutput) + return normalizeBacklightDevicePath(mapping.device || ""); + } + + return ""; + } + + function setMappedBacklightDevice(outputName, devicePath): void { + var normalizedOutput = String(outputName || "").trim(); + if (normalizedOutput === "") + return; + + var normalizedDevicePath = normalizeBacklightDevicePath(devicePath); + var mappings = root.backlightDeviceMappings || []; + var nextMappings = []; + var replaced = false; + + for (var i = 0; i < mappings.length; i++) { + var mapping = mappings[i]; + if (!mapping || typeof mapping !== "object") + continue; + + var mappingOutput = String(mapping.output || "").trim(); + var mappingDevice = normalizeBacklightDevicePath(mapping.device || ""); + if (mappingOutput === "" || mappingDevice === "") + continue; + + if (mappingOutput === normalizedOutput) { + if (!replaced && normalizedDevicePath !== "") { + nextMappings.push({ + "output": normalizedOutput, + "device": normalizedDevicePath + }); + } + replaced = true; + } else { + nextMappings.push({ + "output": mappingOutput, + "device": mappingDevice + }); + } + } + + if (!replaced && normalizedDevicePath !== "") { + nextMappings.push({ + "output": normalizedOutput, + "device": normalizedDevicePath + }); + } + + root.backlightDeviceMappings = nextMappings; + } + + function scanBacklightDevices(): void { + if (!scanBacklightProc.running) + scanBacklightProc.running = true; } reloadableId: "brightness" Component.onCompleted: { - Logger.log("Brightness", "Service started") + Logger.i("Brightness", "Service started"); + scanBacklightDevices(); + if (root.enableDdcSupport) { + ddcProc.running = true; + } } onMonitorsChanged: { - ddcMonitors = [] - ddcProc.running = true + ddcMonitors = []; + scanBacklightDevices(); + if (root.enableDdcSupport) { + ddcProc.running = true; + } } Variants { @@ -69,6 +183,34 @@ Singleton { } } + // Detect available internal backlight devices + Process { + id: scanBacklightProc + command: ["sh", "-c", "for dev in /sys/class/backlight/*; do if [ -f \"$dev/brightness\" ] && [ -f \"$dev/max_brightness\" ]; then echo \"$dev\"; fi; done"] + stdout: StdioCollector { + onStreamFinished: { + var data = text.trim(); + if (data === "") { + root.availableBacklightDevices = []; + return; + } + + var lines = data.split("\n"); + var found = []; + var seen = ({}); + for (var i = 0; i < lines.length; i++) { + var path = root.normalizeBacklightDevicePath(lines[i]); + if (path === "" || seen[path]) + continue; + seen[path] = true; + found.push(path); + } + + root.availableBacklightDevices = found; + } + } + } + // Detect DDC monitors Process { id: ddcProc @@ -76,23 +218,25 @@ Singleton { command: ["ddcutil", "detect", "--sleep-multiplier=0.5"] stdout: StdioCollector { onStreamFinished: { - var displays = text.trim().split("\n\n") + var displays = text.trim().split("\n\n"); ddcProc.ddcMonitors = displays.map(d => { - - var ddcModelMatch = d.match(/(This monitor does not support DDC\/CI|Invalid display)/) - var modelMatch = d.match(/Model:\s*(.*)/) - var busMatch = d.match(/I2C bus:[ ]*\/dev\/i2c-([0-9]+)/) - var ddcModel = ddcModelMatch ? ddcModelMatch.length > 0 : false - var model = modelMatch ? modelMatch[1] : "Unknown" - var bus = busMatch ? busMatch[1] : "Unknown" - Logger.log("Brigthness", "Detected DDC Monitor:", model, "on bus", bus, "is DDC:", !ddcModel) + var ddcModelMatch = d.match(/(This monitor does not support DDC\/CI|Invalid display)/); + var modelMatch = d.match(/Model:\s*(.*)/); + var busMatch = d.match(/I2C bus:[ ]*\/dev\/i2c-([0-9]+)/); + var connectorMatch = d.match(/DRM[_ ]connector:\s*card\d+-(.+)/); + var ddcModel = ddcModelMatch ? ddcModelMatch.length > 0 : false; + var model = modelMatch ? modelMatch[1] : "Unknown"; + var bus = busMatch ? busMatch[1] : "Unknown"; + var connector = connectorMatch ? connectorMatch[1].trim() : ""; + Logger.i("Brightness", "Detected DDC Monitor:", model, "connector:", connector, "bus:", bus, "is DDC:", !ddcModel); return { "model": model, "busNum": bus, + "connector": connector, "isDdc": !ddcModel - } - }) - root.ddcMonitors = ddcProc.ddcMonitors.filter(m => m.isDdc) + }; + }); + root.ddcMonitors = ddcProc.ddcMonitors.filter(m => m.isDdc); } } } @@ -101,14 +245,25 @@ Singleton { id: monitor required property ShellScreen modelData - readonly property bool isDdc: root.ddcMonitors.some(m => m.model === modelData.model) - readonly property string busNum: root.ddcMonitors.find(m => m.model === modelData.model)?.busNum ?? "" + readonly property bool isDdc: root.enableDdcSupport && 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") readonly property string method: isAppleDisplay ? "apple" : (isDdc ? "ddcutil" : "internal") + // Check if brightness control is available for this monitor + readonly property bool brightnessControlAvailable: { + if (isAppleDisplay) + return true; + if (isDdc) + return true; + // For internal displays, check if we have a brightness path + return brightnessPath !== ""; + } + property real brightness property real lastBrightness: 0 property real queuedBrightness: NaN + property bool commandRunning: false // For internal displays - store the backlight device path property string backlightDevice: "" @@ -116,6 +271,7 @@ Singleton { property string maxBrightnessPath: "" property int maxBrightness: 100 property bool ignoreNextChange: false + property bool initInProgress: false // Signal for brightness changes signal brightnessUpdated(real newBrightness) @@ -124,26 +280,63 @@ Singleton { readonly property Process refreshProc: Process { stdout: StdioCollector { onStreamFinished: { - var dataText = text.trim() + var dataText = text.trim(); if (dataText === "") { - return + return; } - var lines = dataText.split("\n") - if (lines.length >= 2) { - var current = parseInt(lines[0].trim()) - var max = parseInt(lines[1].trim()) - if (!isNaN(current) && !isNaN(max) && max > 0) { - var newBrightness = current / max - // Only update if it's actually different (avoid feedback loops) - if (Math.abs(newBrightness - monitor.brightness) > 0.01) { - // Update internal value to match system state - monitor.brightness = newBrightness - monitor.brightnessUpdated(monitor.brightness) - root.monitorBrightnessChanged(monitor, monitor.brightness) + var newBrightness = NaN; + + if (monitor.isAppleDisplay) { + // Apple display format: single integer (0-101) + var val = parseInt(dataText); + if (!isNaN(val)) { + newBrightness = val / 101; + } + } else if (monitor.isDdc) { + // DDC format: "VCP 10 C 100 100" (space-separated) + var parts = dataText.split(" "); + if (parts.length >= 4) { + var current = parseInt(parts[3]); + var max = parseInt(parts[4]); + if (!isNaN(current) && !isNaN(max) && max > 0) { + monitor.maxBrightness = max; + newBrightness = current / max; + } + } + } else { + // Internal display format: two lines (current\nmax) + var lines = dataText.split("\n"); + if (lines.length >= 2) { + var current = parseInt(lines[0].trim()); + var max = parseInt(lines[1].trim()); + if (!isNaN(current) && !isNaN(max) && max > 0) { + newBrightness = current / max; } } } + + // Update if we got a valid brightness value + if (!isNaN(newBrightness) && (Math.abs(newBrightness - monitor.brightness) > 0.001 || monitor.brightness === 0)) { + monitor.brightness = newBrightness; + monitor.brightnessUpdated(monitor.brightness); + root.monitorBrightnessChanged(monitor, monitor.brightness); + Logger.d("Brightness", "Refreshed brightness from system:", monitor.modelData.name, monitor.brightness); + } + } + } + } + + readonly property Process setBrightnessProc: Process { + stdout: StdioCollector {} + onExited: (exitCode, exitStatus) => { + monitor.commandRunning = false; + // If there's a queued brightness change, process it now + if (!isNaN(monitor.queuedBrightness)) { + Qt.callLater(() => { + monitor.setBrightness(monitor.queuedBrightness); + monitor.queuedBrightness = NaN; + }); } } } @@ -152,16 +345,16 @@ Singleton { function refreshBrightnessFromSystem() { if (!monitor.isDdc && !monitor.isAppleDisplay) { // For internal displays, query the system directly - refreshProc.command = ["sh", "-c", "cat " + monitor.brightnessPath + " && " + "cat " + monitor.maxBrightnessPath] - refreshProc.running = true - } else if (monitor.isDdc) { + refreshProc.command = ["sh", "-c", "cat " + monitor.brightnessPath + " && " + "cat " + monitor.maxBrightnessPath]; + refreshProc.running = true; + } else if (monitor.isDdc && monitor.busNum !== "") { // For DDC displays, get the current value - refreshProc.command = ["ddcutil", "-b", monitor.busNum, "getvcp", "10", "--brief"] - refreshProc.running = true + refreshProc.command = ["ddcutil", "-b", monitor.busNum, "--sleep-multiplier=0.05", "getvcp", "10", "--brief"]; + refreshProc.running = true; } else if (monitor.isAppleDisplay) { // For Apple displays, get the current value - refreshProc.command = ["asdbctl", "get"] - refreshProc.running = true + refreshProc.command = ["asdbctl", "get"]; + refreshProc.running = true; } } @@ -175,8 +368,8 @@ Singleton { // When a file change is detected, actively refresh from system // to ensure we get the most up-to-date value Qt.callLater(() => { - monitor.refreshBrightnessFromSystem() - }) + monitor.refreshBrightnessFromSystem(); + }); } } @@ -184,125 +377,166 @@ Singleton { readonly property Process initProc: Process { stdout: StdioCollector { onStreamFinished: { - var dataText = text.trim() + var dataText = text.trim(); if (dataText === "") { - return + return; } + //Logger.i("Brightness", "Raw brightness data for", monitor.modelData.name + ":", dataText) if (monitor.isAppleDisplay) { - var val = parseInt(dataText) + var val = parseInt(dataText); if (!isNaN(val)) { - monitor.brightness = val / 101 - Logger.log("Brightness", "Apple display brightness:", monitor.brightness) + monitor.brightness = val / 101; + Logger.d("Brightness", "Apple display brightness:", monitor.brightness); } } else if (monitor.isDdc) { - var parts = dataText.split(" ") + var parts = dataText.split(" "); if (parts.length >= 4) { - var current = parseInt(parts[3]) - var max = parseInt(parts[4]) + var current = parseInt(parts[3]); + var max = parseInt(parts[4]); if (!isNaN(current) && !isNaN(max) && max > 0) { - monitor.brightness = current / max - Logger.log("Brightness", "DDC brightness:", current + "/" + max + " =", monitor.brightness) + monitor.maxBrightness = max; + monitor.brightness = current / max; + Logger.d("Brightness", "DDC brightness:", current + "/" + max + " =", monitor.brightness); } } } else { // Internal backlight - parse the response which includes device path - var lines = dataText.split("\n") + var lines = dataText.split("\n"); if (lines.length >= 3) { - monitor.backlightDevice = lines[0] - monitor.brightnessPath = monitor.backlightDevice + "/brightness" - monitor.maxBrightnessPath = monitor.backlightDevice + "/max_brightness" + monitor.backlightDevice = lines[0]; + monitor.brightnessPath = monitor.backlightDevice + "/brightness"; + monitor.maxBrightnessPath = monitor.backlightDevice + "/max_brightness"; - var current = parseInt(lines[1]) - var max = parseInt(lines[2]) + var current = parseInt(lines[1]); + var max = parseInt(lines[2]); if (!isNaN(current) && !isNaN(max) && max > 0) { - monitor.maxBrightness = max - monitor.brightness = current / max - Logger.log("Brightness", "Internal brightness:", current + "/" + max + " =", monitor.brightness) - Logger.log("Brightness", "Using backlight device:", monitor.backlightDevice) + monitor.maxBrightness = max; + monitor.brightness = current / max; + Logger.d("Brightness", "Internal brightness:", current + "/" + max + " =", monitor.brightness); + Logger.d("Brightness", "Using backlight device:", monitor.backlightDevice); } + } else { + monitor.backlightDevice = ""; + monitor.brightnessPath = ""; + monitor.maxBrightnessPath = ""; } } - // Always update - monitor.brightnessUpdated(monitor.brightness) - root.monitorBrightnessChanged(monitor, monitor.brightness) + monitor.initInProgress = false; } } + onExited: (exitCode, exitStatus) => { + monitor.initInProgress = false; + } } - readonly property real stepSize: 5.0 / 100.0 + readonly property real stepSize: root.brightnessStep / 100.0 + readonly property real minBrightnessValue: (root.enforceMinimumBrightness ? 0.01 : 0.0) // Timer for debouncing rapid changes readonly property Timer timer: Timer { - interval: 100 + interval: monitor.isDdc ? 250 : 33 onTriggered: { if (!isNaN(monitor.queuedBrightness)) { - monitor.setBrightness(monitor.queuedBrightness) - monitor.queuedBrightness = NaN + monitor.setBrightness(monitor.queuedBrightness); + monitor.queuedBrightness = NaN; } } } function setBrightnessDebounced(value: real): void { - monitor.queuedBrightness = value - timer.start() + monitor.queuedBrightness = value; + timer.start(); } function increaseBrightness(): void { - const value = !isNaN(monitor.queuedBrightness) ? monitor.queuedBrightness : monitor.brightness - setBrightnessDebounced(value + stepSize) + const value = !isNaN(monitor.queuedBrightness) ? monitor.queuedBrightness : monitor.brightness; + // Enforce minimum brightness if enabled + if (root.enforceMinimumBrightness && value < minBrightnessValue) { + setBrightnessDebounced(Math.max(stepSize, minBrightnessValue)); + } else { + // Normal brightness increase + setBrightnessDebounced(value + stepSize); + } } function decreaseBrightness(): void { - const value = !isNaN(monitor.queuedBrightness) ? monitor.queuedBrightness : monitor.brightness - setBrightnessDebounced(value - stepSize) + const value = !isNaN(monitor.queuedBrightness) ? monitor.queuedBrightness : monitor.brightness; + setBrightnessDebounced(value - stepSize); } function setBrightness(value: real): void { - value = Math.max(0, Math.min(1, value)) - var rounded = Math.round(value * 100) + value = Math.max(minBrightnessValue, Math.min(1, value)); + var rounded = Math.round(value * 100); + + // Always update internal value and trigger UI feedback immediately + monitor.brightness = value; + monitor.brightnessUpdated(value); + root.monitorBrightnessChanged(monitor, monitor.brightness); if (timer.running) { - monitor.queuedBrightness = value - return + monitor.queuedBrightness = value; + return; } - // Update internal value and trigger UI feedback - monitor.brightness = value - monitor.brightnessUpdated(value) - root.monitorBrightnessChanged(monitor, monitor.brightness) + // If a command is already running, queue this value + if (monitor.commandRunning) { + monitor.queuedBrightness = value; + return; + } + // Execute the brightness change command if (isAppleDisplay) { - monitor.ignoreNextChange = true - Quickshell.execDetached(["asdbctl", "set", rounded]) - } else if (isDdc) { - monitor.ignoreNextChange = true - Quickshell.execDetached(["ddcutil", "-b", busNum, "setvcp", "10", rounded]) - } else { - monitor.ignoreNextChange = true - Quickshell.execDetached(["sh", "-c", "brightnessctl -d $BRIGHTNESSCTL_DEVICE set "+ rounded + "%"]) - } - - if (isDdc) { - timer.restart() + monitor.commandRunning = true; + monitor.ignoreNextChange = true; + setBrightnessProc.command = ["asdbctl", "set", rounded]; + setBrightnessProc.running = true; + } else if (isDdc && busNum !== "") { + monitor.commandRunning = true; + monitor.ignoreNextChange = true; + var ddcValue = Math.round(value * monitor.maxBrightness); + var ddcBus = busNum; + Qt.callLater(() => { + setBrightnessProc.command = ["ddcutil", "-b", ddcBus, "--noverify", "--async", "--sleep-multiplier=0.05", "setvcp", "10", ddcValue]; + setBrightnessProc.running = true; + }); + } else if (!isDdc) { + monitor.commandRunning = true; + monitor.ignoreNextChange = true; + var backlightDeviceName = root.getBacklightDeviceName(monitor.backlightDevice); + if (backlightDeviceName !== "") { + setBrightnessProc.command = ["brightnessctl", "-d", backlightDeviceName, "s", rounded + "%"]; + } else { + setBrightnessProc.command = ["brightnessctl", "s", rounded + "%"]; + } + setBrightnessProc.running = true; } } function initBrightness(): void { + monitor.initInProgress = true; if (isAppleDisplay) { - initProc.command = ["asdbctl", "get"] - } else if (isDdc) { - initProc.command = ["ddcutil", "-b", busNum, "getvcp", "10", "--brief"] + initProc.command = ["asdbctl", "get"]; + initProc.running = true; + } else if (isDdc && busNum !== "") { + initProc.command = ["ddcutil", "-b", busNum, "--sleep-multiplier=0.05", "getvcp", "10", "--brief"]; + initProc.running = true; + } else if (!isDdc) { + // Internal backlight: first try explicit output mapping, then fall back to first available. + var preferredDevicePath = root.getMappedBacklightDevice(modelData.name); + var probeScript = ["preferred=\"$1\"", "if [ -n \"$preferred\" ] && [ ! -d \"$preferred\" ]; then preferred=\"/sys/class/backlight/$preferred\"; fi", "selected=\"\"", + "if [ -n \"$preferred\" ] && [ -f \"$preferred/brightness\" ] && [ -f \"$preferred/max_brightness\" ]; then selected=\"$preferred\"; else for dev in /sys/class/backlight/*; do if [ -f \"$dev/brightness\" ] && [ -f \"$dev/max_brightness\" ]; then selected=\"$dev\"; break; fi; done; fi", + "if [ -n \"$selected\" ]; then echo \"$selected\"; cat \"$selected/brightness\"; cat \"$selected/max_brightness\"; fi"].join("; "); + initProc.command = ["sh", "-c", probeScript, "sh", preferredDevicePath]; + initProc.running = true; } else { - // Internal backlight - find the first available backlight device and get its info - // This now returns: device_path, current_brightness, max_brightness (on separate lines) - initProc.command = ["sh", "-c", "for dev in /sys/class/backlight/*; do " + " if [ -f \"$dev/brightness\" ] && [ -f \"$dev/max_brightness\" ]; then " + " echo \"$dev\"; " + " cat \"$dev/brightness\"; " + " cat \"$dev/max_brightness\"; " + " break; " + " fi; " + "done"] + monitor.initInProgress = false; } - initProc.running = true } onBusNumChanged: initBrightness() + onIsDdcChanged: initBrightness() Component.onCompleted: initBrightness() } } diff --git a/config/quickshell/.config/quickshell/Services/CacheService.qml b/config/quickshell/.config/quickshell/Services/CacheService.qml deleted file mode 100644 index 5a3d0f0..0000000 --- a/config/quickshell/.config/quickshell/Services/CacheService.qml +++ /dev/null @@ -1,34 +0,0 @@ -import QtQuick -import Quickshell -import Quickshell.Io -import qs.Utils -pragma Singleton - -Singleton { - id: root - - readonly property string cacheDir: Quickshell.env("HOME") + "/.cache/quickshell/" - // also create recording directory here - readonly property string recordingDir: Quickshell.env("HOME") + "/Videos/recordings/" - readonly property var cacheFiles: Object.freeze(["Location.json", "Ip.json", "Notifications.json", "LyricsOffset.txt", "Network.json"]) - property bool loaded: false - readonly property string locationCacheFile: cacheDir + "Location.json" - readonly property string ipCacheFile: cacheDir + "Ip.json" - readonly property string notificationsCacheFile: cacheDir + "Notifications.json" - readonly property string lyricsOffsetCacheFile: cacheDir + "LyricsOffset.txt" - readonly property string networkCacheFile: cacheDir + "Network.json" - - Process { - id: process - - running: true - command: ["sh", "-c", `mkdir -p ${cacheDir} && mkdir -p ${recordingDir} && touch ${cacheDir + cacheFiles.join(` && touch ${cacheDir}`)}`] - onExited: (code, status) => { - if (code === 0) - loaded = true; - else - Logger.error("CacheService", `Failed to create cache files: ${command.join(" ")}`); - } - } - -} diff --git a/config/quickshell/.config/quickshell/Services/Caffeine.qml b/config/quickshell/.config/quickshell/Services/Caffeine.qml index 8b676af..c8a0de7 100644 --- a/config/quickshell/.config/quickshell/Services/Caffeine.qml +++ b/config/quickshell/.config/quickshell/Services/Caffeine.qml @@ -1,46 +1,33 @@ import QtQuick import Quickshell import Quickshell.Io -import qs.Services import qs.Utils pragma Singleton Singleton { id: root - property string reason: "Application request" property bool isInhibited: false + property string reason: "User requested" property var activeInhibitors: [] - // Different inhibitor strategies - property string strategy: "systemd" + property var timeout: null // in seconds + // True when the native Wayland IdleInhibitor is handling inhibition + // (set by the IdleInhibitor element in MainScreen via the nativeInhibitor property) + property bool nativeInhibitorAvailable: false - // Auto-detect the best strategy - function detectStrategy() { - if (strategy === "auto") { - // Check if systemd-inhibit is available - try { - var systemdResult = Quickshell.execDetached(["which", "systemd-inhibit"]); - strategy = "systemd"; - return ; - } catch (e) { - } - try { - var waylandResult = Quickshell.execDetached(["which", "wayhibitor"]); - strategy = "wayland"; - return ; - } catch (e) { - } - strategy = "systemd"; // Fallback to systemd even if not detected - } + function init() { + Logger.i("IdleInhibitor", "Service started"); } // Add an inhibitor function addInhibitor(id, reason = "Application request") { - if (activeInhibitors.includes(id)) + if (activeInhibitors.includes(id)) { + Logger.w("IdleInhibitor", "Inhibitor already active:", id); return false; - + } activeInhibitors.push(id); updateInhibition(reason); + Logger.d("IdleInhibitor", "Added inhibitor:", id); return true; } @@ -48,11 +35,12 @@ Singleton { function removeInhibitor(id) { const index = activeInhibitors.indexOf(id); if (index === -1) { - console.log("Inhibitor not found:", id); + Logger.w("IdleInhibitor", "Inhibitor not found:", id); return false; } activeInhibitors.splice(index, 1); updateInhibition(); + Logger.d("IdleInhibitor", "Removed inhibitor:", id); return true; } @@ -62,7 +50,6 @@ Singleton { if (shouldInhibit === isInhibited) return ; - // No change needed if (shouldInhibit) startInhibition(newReason); else @@ -72,13 +59,12 @@ Singleton { // Start system inhibition function startInhibition(newReason) { reason = newReason; - if (strategy === "systemd") - startSystemdInhibition(); - else if (strategy === "wayland") - startWaylandInhibition(); + if (nativeInhibitorAvailable) + Logger.d("IdleInhibitor", "Native inhibitor active"); else - return ; + startSubprocessInhibition(); isInhibited = true; + Logger.i("IdleInhibitor", "Started inhibition:", reason); } // Stop system inhibition @@ -86,57 +72,127 @@ Singleton { if (!isInhibited) return ; - // SIGTERM - if (inhibitorProcess.running) + if (!nativeInhibitorAvailable && inhibitorProcess.running) inhibitorProcess.signal(15); + // SIGTERM isInhibited = false; + Logger.i("IdleInhibitor", "Stopped inhibition"); } - // Systemd inhibition using systemd-inhibit - function startSystemdInhibition() { + // Subprocess fallback using systemd-inhibit + function startSubprocessInhibition() { inhibitorProcess.command = ["systemd-inhibit", "--what=idle", "--why=" + reason, "--mode=block", "sleep", "infinity"]; inhibitorProcess.running = true; } - // Wayland inhibition using wayhibitor or similar - function startWaylandInhibition() { - inhibitorProcess.command = ["wayhibitor"]; - inhibitorProcess.running = true; - } - // Manual toggle for user control function manualToggle() { + // clear any existing timeout + timeout = null; if (activeInhibitors.includes("manual")) { - removeInhibitor("manual"); + removeManualInhibitor(); return false; } else { - addInhibitor("manual", "Manually activated by user"); + addManualInhibitor(null); return true; } } - Component.onCompleted: { - detectStrategy(); + function changeTimeout(delta) { + if (timeout == null && delta < 0) + return ; + + if (timeout == null && delta > 0) { + // enable manual inhibitor and set timeout + addManualInhibitor(timeout + delta); + return ; + } + if (timeout + delta <= 0) { + // disable manual inhibitor + removeManualInhibitor(); + return ; + } + if (timeout + delta > 0) { + // change timeout + addManualInhibitor(timeout + delta); + return ; + } } + + function removeManualInhibitor() { + if (timeout !== null) { + timeout = null; + if (inhibitorTimeout.running) + inhibitorTimeout.stop(); + + } + if (activeInhibitors.includes("manual")) { + removeInhibitor("manual"); + Logger.i("IdleInhibitor", "Manual inhibition disabled"); + } + } + + function addManualInhibitor(timeoutSec) { + if (!activeInhibitors.includes("manual")) + addInhibitor("manual", "Manually activated by user"); + + if (timeoutSec === null && timeout === null) { + Logger.i("IdleInhibitor", "Manual inhibition enabled"); + return ; + } else if (timeoutSec !== null && timeout === null) { + timeout = timeoutSec; + inhibitorTimeout.start(); + Logger.i("IdleInhibitor", "Manual inhibition enabled with timeout:", timeoutSec); + return ; + } else if (timeoutSec !== null && timeout !== null) { + timeout = timeoutSec; + Logger.i("IdleInhibitor", "Manual inhibition timeout changed to:", timeoutSec); + return ; + } else if (timeoutSec === null && timeout !== null) { + timeout = null; + inhibitorTimeout.stop(); + Logger.i("IdleInhibitor", "Manual inhibition timeout cleared"); + return ; + } + } + // Clean up on shutdown Component.onDestruction: { stopInhibition(); } - // Process for maintaining the inhibition + // Process for maintaining the inhibition (subprocess fallback only) Process { id: inhibitorProcess running: false onExited: function(exitCode, exitStatus) { - if (isInhibited) + if (isInhibited) { + Logger.w("IdleInhibitor", "Inhibitor process exited unexpectedly:", exitCode); isInhibited = false; - - Logger.log("Caffeine", "Inhibitor process exited with code:", exitCode, "status:", exitStatus); + } } onStarted: function() { - Logger.log("Caffeine", "Inhibitor process started with PID:", inhibitorProcess.processId); + Logger.d("IdleInhibitor", "Inhibitor process started successfully"); + } + } + + Timer { + id: inhibitorTimeout + + repeat: true + interval: 1000 // 1 second + onTriggered: function() { + if (timeout == null) { + inhibitorTimeout.stop(); + return ; + } + timeout -= 1; + if (timeout <= 0) { + removeManualInhibitor(); + return ; + } } } diff --git a/config/quickshell/.config/quickshell/Services/HostService.qml b/config/quickshell/.config/quickshell/Services/HostService.qml new file mode 100644 index 0000000..638af29 --- /dev/null +++ b/config/quickshell/.config/quickshell/Services/HostService.qml @@ -0,0 +1,67 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Utils +pragma Singleton + +Singleton { + id: root + + property string username: Quickshell.env("USER") || "user" + property string hostname: "--" + property string uptimeText: "--" + + Process { + id: usernameProcess + + command: ["whoami"] + running: true + + stdout: StdioCollector { + onStreamFinished: { + root.username = this.text.trim(); + usernameProcess.running = false; + } + } + + } + + Process { + id: hostnameProcess + + command: ["cat", "/etc/hostname"] + running: true + + stdout: StdioCollector { + onStreamFinished: { + root.hostname = this.text.trim(); + hostnameProcess.running = false; + } + } + + } + + Timer { + interval: 60000 + repeat: true + running: true + onTriggered: uptimeProcess.running = true + } + + Process { + id: uptimeProcess + + command: ["cat", "/proc/uptime"] + running: true + + stdout: StdioCollector { + onStreamFinished: { + var uptimeSeconds = parseFloat(this.text.trim().split(' ')[0]); + root.uptimeText = Time.formatVagueHumanReadableDuration(uptimeSeconds); + uptimeProcess.running = false; + } + } + + } + +} diff --git a/config/quickshell/.config/quickshell/Services/IPCService.qml b/config/quickshell/.config/quickshell/Services/IPCService.qml index 0759631..4392b22 100644 --- a/config/quickshell/.config/quickshell/Services/IPCService.qml +++ b/config/quickshell/.config/quickshell/Services/IPCService.qml @@ -2,34 +2,103 @@ import QtQuick import Quickshell import Quickshell.Io import qs.Constants +import qs.Services Item { IpcHandler { - function setPrimary(color: color) { - SettingsService.primaryColor = color; + function startOrStopRecording() { + RecordService.startOrStop(); + } + + target: "recording" + } + + IpcHandler { + function clearAll() { + ImageCacheService.clearAll(); + } + + target: "cache" + } + + IpcHandler { + function previewWallpaper(path: string) { + BackgroundService.previewWallpaper(path); + } + + function setWallpaper(path: string) { + BackgroundService.setWallpaper(path); + } + + target: "background" + } + + IpcHandler { + function playPause() { + MediaService.playPause(); + } + + function next() { + MediaService.next(); + } + + function previous() { + MediaService.previous(); + } + + function volumeUp() { + AudioService.increaseVolume(); + } + + function volumeDown() { + AudioService.decreaseVolume(); + } + + function toggleOutputMute() { + AudioService.setOutputMuted(!AudioService.muted); + } + + function toggleInputMute() { + AudioService.setInputMuted(!AudioService.inputMuted); + } + + target: "media" + } + + IpcHandler { + function setColor(name: string, value: color) { + Colors.setColor(name, value); + } + + function unsetColor(name: string) { + Colors.unsetColor(name); + } + + function getColor(name: string) : string { + const hex = String(Colors[name]); + if (hex.startsWith("#") && hex.length === 9) + return "#" + hex.substring(3); + + return hex; } target: "colors" } IpcHandler { - function toggleCalendar() { - calendarPanel.toggle(); + function toggleLeft() { + BarService.toggleLeft(); } - function toggleControlCenter() { - controlCenterPanel.toggle(); + function toggleRight() { + BarService.toggleRight(); } - target: "panels" - } - - IpcHandler { - function toggleBarLyrics() { - SettingsService.showLyricsBar = !SettingsService.showLyricsBar; + function toggleLyrics() { + LyricsService.toggleLyricsBar(); } - target: "lyrics" + target: "bars" } IpcHandler { @@ -40,14 +109,6 @@ Item { target: "idleInhibitor" } - IpcHandler { - function startOrStopRecording() { - RecordService.startOrStop(); - } - - target: "recording" - } - IpcHandler { function toggleSunset() { SunsetService.toggleSunset(); @@ -56,4 +117,16 @@ Item { target: "sunset" } + IpcHandler { + function brightnessUp() { + BrightnessService.getMonitorForScreen(Niri.focusedScreen).increaseBrightness(); + } + + function brightnessDown() { + BrightnessService.getMonitorForScreen(Niri.focusedScreen).decreaseBrightness(); + } + + target: "brightness" + } + } diff --git a/config/quickshell/.config/quickshell/Services/ImageCacheService.qml b/config/quickshell/.config/quickshell/Services/ImageCacheService.qml new file mode 100644 index 0000000..786dddd --- /dev/null +++ b/config/quickshell/.config/quickshell/Services/ImageCacheService.qml @@ -0,0 +1,771 @@ +import "./sha256.js" as Checksum +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import qs.Constants +import qs.Utils +pragma Singleton + +Singleton { + id: root + + // ------------------------------------------------- + // Public Properties + // ------------------------------------------------- + property bool imageMagickAvailable: false + property bool initialized: false + // Cache directories + readonly property string baseDir: Paths.cacheDir + "images/" + readonly property string wpThumbDir: baseDir + "wallpapers/thumbnails/" + readonly property string wpLargeDir: baseDir + "wallpapers/large/" + readonly property string wpOverviewDir: baseDir + "wallpapers/overview/" + readonly property string notificationsDir: baseDir + "notifications/" + readonly property string contributorsDir: baseDir + "contributors/" + // Supported image formats - extended list when ImageMagick is available + readonly property var basicImageFilters: ["*.jpg", "*.jpeg", "*.png", "*.gif", "*.bmp"] + readonly property var extendedImageFilters: ["*.jpg", "*.jpeg", "*.png", "*.gif", "*.bmp", "*.webp", "*.avif", "*.heic", "*.heif", "*.tiff", "*.tif", "*.pnm", "*.pgm", "*.ppm", "*.pbm", "*.svg", "*.svgz", "*.ico", "*.icns", "*.jxl", "*.jp2", "*.j2k", "*.exr", "*.hdr", "*.dds", "*.tga"] + readonly property var imageFilters: imageMagickAvailable ? extendedImageFilters : basicImageFilters + // ------------------------------------------------- + // Internal State + // ------------------------------------------------- + property var pendingRequests: ({ + }) + property var fallbackQueue: [] + property bool fallbackProcessing: false + // Process queues to prevent "too many open files" errors + property var utilityProcessQueue: [] + property int runningUtilityProcesses: 0 + readonly property int maxConcurrentUtilityProcesses: 16 + // Separate queue for heavy ImageMagick processing (lower concurrency) + property var imageMagickQueue: [] + property int runningImageMagickProcesses: 0 + readonly property int maxConcurrentImageMagickProcesses: 4 + + // ------------------------------------------------- + // Signals + // ------------------------------------------------- + signal cacheHit(string cacheKey, string cachedPath) + signal cacheMiss(string cacheKey) + signal processingComplete(string cacheKey, string cachedPath) + signal processingFailed(string cacheKey, string error) + + // Check if a file format needs conversion (not natively supported by Qt) + function needsConversion(filePath) { + const ext = "*." + filePath.toLowerCase().split('.').pop(); + return !basicImageFilters.includes(ext); + } + + // ------------------------------------------------- + // Initialization + // ------------------------------------------------- + function init() { + Logger.i("ImageCache", "Service started"); + createDirectories(); + cleanupOldCache(); + checkMagickProcess.running = true; + } + + function createDirectories() { + Quickshell.execDetached(["mkdir", "-p", wpThumbDir]); + Quickshell.execDetached(["mkdir", "-p", wpLargeDir]); + Quickshell.execDetached(["mkdir", "-p", wpOverviewDir]); + Quickshell.execDetached(["mkdir", "-p", notificationsDir]); + Quickshell.execDetached(["mkdir", "-p", contributorsDir]); + } + + function cleanupOldCache() { + const dirs = [wpThumbDir, wpLargeDir, wpOverviewDir, notificationsDir, contributorsDir]; + dirs.forEach(function(dir) { + Quickshell.execDetached(["find", dir, "-type", "f", "-mtime", "+30", "-delete"]); + }); + Logger.d("ImageCache", "Cleanup triggered for files older than 30 days"); + } + + // ------------------------------------------------- + // Public API: Get Thumbnail (384x384) + // ------------------------------------------------- + function getThumbnail(sourcePath, callback) { + if (!sourcePath || sourcePath === "") { + callback("", false); + return ; + } + getMtime(sourcePath, function(mtime) { + const cacheKey = generateThumbnailKey(sourcePath, mtime); + const cachedPath = wpThumbDir + cacheKey + ".png"; + processRequest(cacheKey, cachedPath, sourcePath, callback, function() { + if (imageMagickAvailable) + startThumbnailProcessing(sourcePath, cachedPath, cacheKey); + else + queueFallbackProcessing(sourcePath, cachedPath, cacheKey, 384); + }); + }); + } + + // ------------------------------------------------- + // Public API: Get Large Image (scaled to specified dimensions) + // ------------------------------------------------- + function getLarge(sourcePath, width, height, callback) { + if (!sourcePath || sourcePath === "") { + callback("", false); + return ; + } + if (!imageMagickAvailable) { + Logger.d("ImageCache", "ImageMagick not available, using original:", sourcePath); + callback(sourcePath, false); + return ; + } + // Fast dimension check - skip processing if image fits screen AND format is Qt-native + getImageDimensions(sourcePath, function(imgWidth, imgHeight) { + // const fitsScreen = imgWidth > 0 && imgHeight > 0 && imgWidth <= width && imgHeight <= height; + // if (fitsScreen) { + // // Only skip if format is natively supported by Qt + // if (!needsConversion(sourcePath)) { + // Logger.d("ImageCache", `Image ${imgWidth}x${imgHeight} fits screen ${width}x${height}, using original`); + // callback(sourcePath, false); + // return ; + // } + // Logger.d("ImageCache", `Image needs conversion despite fitting screen`); + // } + // Use actual image dimensions if it fits (convert without upscaling), otherwise use screen dimensions + // const targetWidth = fitsScreen ? imgWidth : width; + // const targetHeight = fitsScreen ? imgHeight : height; + const targetWidth = width; + const targetHeight = height; + getMtime(sourcePath, function(mtime) { + const cacheKey = generateLargeKey(sourcePath, width, height, mtime); + const cachedPath = wpLargeDir + cacheKey + ".png"; + processRequest(cacheKey, cachedPath, sourcePath, callback, function() { + startLargeProcessing(sourcePath, cachedPath, targetWidth, targetHeight, cacheKey); + }); + }); + }); + } + + // ------------------------------------------------- + // Public API: Get Notification Icon (64x64) + // ------------------------------------------------- + function getNotificationIcon(imageUri, appName, summary, callback) { + if (!imageUri || imageUri === "") { + callback("", false); + return ; + } + // Resolve bare file path for temp check + const filePath = imageUri.startsWith("file://") ? imageUri.substring(7) : imageUri; + // File paths in persistent locations are used directly, not cached + if ((imageUri.startsWith("/") || imageUri.startsWith("file://")) && !isTemporaryPath(filePath)) { + callback(imageUri, false); + return ; + } + const cacheKey = generateNotificationKey(imageUri, appName, summary); + const cachedPath = notificationsDir + cacheKey + ".png"; + // Temporary file paths are copied to cache before the source is cleaned up + if (imageUri.startsWith("/") || imageUri.startsWith("file://")) { + processRequest(cacheKey, cachedPath, imageUri, callback, function() { + copyTempFileToCache(filePath, cachedPath, cacheKey); + }); + return ; + } + processRequest(cacheKey, cachedPath, imageUri, callback, function() { + // Notifications always use Qt fallback (image:// URIs can't be read by ImageMagick) + queueFallbackProcessing(imageUri, cachedPath, cacheKey, 64); + }); + } + + // Check if a path is in a temporary directory that may be cleaned up + function isTemporaryPath(path) { + return path.startsWith("/tmp/"); + } + + // Copy a temporary file to the cache directory + function copyTempFileToCache(sourcePath, destPath, cacheKey) { + const srcEsc = sourcePath.replace(/'/g, "'\\''"); + const dstEsc = destPath.replace(/'/g, "'\\''"); + const processString = ` + import QtQuick + import Quickshell.Io + Process { + command: ["cp", "--", "${srcEsc}", "${dstEsc}"] + stdout: StdioCollector {} + stderr: StdioCollector {} + } + `; + queueUtilityProcess({ + "name": "CopyTempFile_" + cacheKey, + "processString": processString, + "onComplete": function(exitCode) { + if (exitCode === 0) { + Logger.d("ImageCache", "Temp file cached:", destPath); + notifyCallbacks(cacheKey, destPath, true); + } else { + Logger.w("ImageCache", "Failed to cache temp file:", sourcePath); + notifyCallbacks(cacheKey, "", false); + } + }, + "onError": function() { + Logger.e("ImageCache", "Error caching temp file:", sourcePath); + notifyCallbacks(cacheKey, "", false); + } + }); + } + + // ------------------------------------------------- + // Public API: Get Circular Avatar (256x256) + // ------------------------------------------------- + function getCircularAvatar(url, username, callback) { + if (!url || !username) { + callback("", false); + return ; + } + const cacheKey = username; + const cachedPath = contributorsDir + username + "_circular.png"; + processRequest(cacheKey, cachedPath, url, callback, function() { + if (imageMagickAvailable) { + downloadAndProcessAvatar(url, username, cachedPath, cacheKey); + } else { + // No fallback for circular avatars without ImageMagick + Logger.w("ImageCache", "Circular avatars require ImageMagick"); + notifyCallbacks(cacheKey, "", false); + } + }); + } + + // ------------------------------------------------- + // Public API: Get Blurred Overview (for Niri overview background) + // ------------------------------------------------- + function getBlurredOverview(sourcePath, width, height, tintColor, isDarkMode, callback) { + if (!sourcePath || sourcePath === "") { + callback("", false); + return ; + } + if (!imageMagickAvailable) { + Logger.d("ImageCache", "ImageMagick not available for overview blur, using original:", sourcePath); + callback(sourcePath, false); + return ; + } + getMtime(sourcePath, function(mtime) { + const cacheKey = generateOverviewKey(sourcePath, width, height, tintColor, isDarkMode, mtime); + const cachedPath = wpOverviewDir + cacheKey + ".png"; + processRequest(cacheKey, cachedPath, sourcePath, callback, function() { + startOverviewProcessing(sourcePath, cachedPath, width, height, tintColor, isDarkMode, cacheKey); + }); + }); + } + + // ------------------------------------------------- + // Cache Key Generation + // ------------------------------------------------- + function generateThumbnailKey(sourcePath, mtime) { + const keyString = sourcePath + "@384x384@" + (mtime || "unknown"); + return Checksum.sha256(keyString); + } + + function generateLargeKey(sourcePath, width, height, mtime) { + const keyString = sourcePath + "@" + width + "x" + height + "@" + (mtime || "unknown"); + return Checksum.sha256(keyString); + } + + function generateNotificationKey(imageUri, appName, summary) { + if (imageUri.startsWith("image://qsimage/")) + return Checksum.sha256(appName + "|" + summary); + + return Checksum.sha256(imageUri); + } + + function generateOverviewKey(sourcePath, width, height, tintColor, isDarkMode, mtime) { + const keyString = sourcePath + "@" + width + "x" + height + "@" + tintColor + "@" + (isDarkMode ? "dark" : "light") + "@" + (mtime || "unknown"); + return Checksum.sha256(keyString); + } + + // ------------------------------------------------- + // Request Processing (with coalescing) + // ------------------------------------------------- + function processRequest(cacheKey, cachedPath, sourcePath, callback, processFn) { + // Check if already processing this request + if (pendingRequests[cacheKey]) { + pendingRequests[cacheKey].callbacks.push(callback); + Logger.d("ImageCache", "Coalescing request for:", cacheKey); + return ; + } + // Check cache first + checkFileExists(cachedPath, function(exists) { + if (exists) { + Logger.d("ImageCache", "Cache hit:", cachedPath); + callback(cachedPath, true); + cacheHit(cacheKey, cachedPath); + return ; + } + // Re-check pendingRequests (race condition fix) + if (pendingRequests[cacheKey]) { + pendingRequests[cacheKey].callbacks.push(callback); + return ; + } + // Start new processing + Logger.d("ImageCache", "Cache miss, processing:", sourcePath); + cacheMiss(cacheKey); + pendingRequests[cacheKey] = { + "callbacks": [callback], + "sourcePath": sourcePath + }; + processFn(); + }); + } + + function notifyCallbacks(cacheKey, path, success) { + const request = pendingRequests[cacheKey]; + if (request) { + request.callbacks.forEach(function(cb) { + cb(path, success); + }); + delete pendingRequests[cacheKey]; + } + if (success) + processingComplete(cacheKey, path); + else + processingFailed(cacheKey, "Processing failed"); + } + + // ------------------------------------------------- + // ImageMagick Processing: Thumbnail + // ------------------------------------------------- + function startThumbnailProcessing(sourcePath, outputPath, cacheKey) { + const srcEsc = sourcePath.replace(/'/g, "'\\''"); + const dstEsc = outputPath.replace(/'/g, "'\\''"); + // Use Lanczos filter for high-quality downscaling, subtle unsharp mask, and PNG for lossless output + const command = `magick '${srcEsc}' -auto-orient -filter Lanczos -resize '384x384^' -gravity center -extent 384x384 -unsharp 0x0.5 '${dstEsc}'`; + runProcess(command, cacheKey, outputPath, sourcePath); + } + + // ------------------------------------------------- + // ImageMagick Processing: Large + // ------------------------------------------------- + function startLargeProcessing(sourcePath, outputPath, width, height, cacheKey) { + const srcEsc = sourcePath.replace(/'/g, "'\\''"); + const dstEsc = outputPath.replace(/'/g, "'\\''"); + // Use Lanczos filter for high-quality downscaling, subtle unsharp mask, and PNG for lossless output + const command = `magick '${srcEsc}' -auto-orient -filter Lanczos -resize '${width}x${height}' -gravity center -unsharp 0x0.5 '${dstEsc}'`; + runProcess(command, cacheKey, outputPath, sourcePath); + } + + // ------------------------------------------------- + // ImageMagick Processing: Blurred Overview + // ------------------------------------------------- + function startOverviewProcessing(sourcePath, outputPath, width, height, tintColor, isDarkMode, cacheKey) { + const srcEsc = sourcePath.replace(/'/g, "'\\''"); + const dstEsc = outputPath.replace(/'/g, "'\\''"); + // Resize, blur, then tint overlay + const command = `magick '${srcEsc}' -auto-orient -resize '${width}x${height}' -gravity center -blur 0x20 \\( +clone -fill '${tintColor}' -colorize 100 -alpha set -channel A -evaluate set 50% +channel \\) -composite '${dstEsc}'`; + runProcess(command, cacheKey, outputPath, sourcePath); + } + + // ------------------------------------------------- + // ImageMagick Processing: Circular Avatar + // ------------------------------------------------- + function downloadAndProcessAvatar(url, username, outputPath, cacheKey) { + const tempPath = contributorsDir + username + "_temp.png"; + const tempEsc = tempPath.replace(/'/g, "'\\''"); + const urlEsc = url.replace(/'/g, "'\\''"); + // Download first (uses utility queue since curl/wget are lightweight) + const downloadCmd = `curl -L -s -o '${tempEsc}' '${urlEsc}' || wget -q -O '${tempEsc}' '${urlEsc}'`; + const processString = ` + import QtQuick + import Quickshell.Io + Process { + command: ["sh", "-c", "${downloadCmd.replace(/"/g, '\\"')}"] + stdout: StdioCollector {} + stderr: StdioCollector {} + } + `; + queueUtilityProcess({ + "name": "DownloadProcess_" + cacheKey, + "processString": processString, + "onComplete": function(exitCode) { + if (exitCode !== 0) { + Logger.e("ImageCache", "Failed to download avatar for", username); + notifyCallbacks(cacheKey, "", false); + return ; + } + // Now process with ImageMagick + processCircularAvatar(tempPath, outputPath, cacheKey); + }, + "onError": function() { + notifyCallbacks(cacheKey, "", false); + } + }); + } + + function processCircularAvatar(inputPath, outputPath, cacheKey) { + const srcEsc = inputPath.replace(/'/g, "'\\''"); + const dstEsc = outputPath.replace(/'/g, "'\\''"); + // ImageMagick command for circular crop with alpha + const command = `magick '${srcEsc}' -resize 256x256^ -gravity center -extent 256x256 -alpha set \\( +clone -channel A -evaluate set 0 +channel -fill white -draw 'circle 128,128 128,0' \\) -compose DstIn -composite '${dstEsc}'`; + queueImageMagickProcess({ + "command": command, + "cacheKey": cacheKey, + "onComplete": function(exitCode) { + // Clean up temp file + Quickshell.execDetached(["rm", "-f", inputPath]); + if (exitCode !== 0) { + Logger.e("ImageCache", "Failed to create circular avatar"); + notifyCallbacks(cacheKey, "", false); + } else { + Logger.d("ImageCache", "Circular avatar created:", outputPath); + notifyCallbacks(cacheKey, outputPath, true); + } + }, + "onError": function() { + Quickshell.execDetached(["rm", "-f", inputPath]); + notifyCallbacks(cacheKey, "", false); + } + }); + } + + // Queue an ImageMagick process and run it when a slot is available + function queueImageMagickProcess(request) { + imageMagickQueue.push(request); + processImageMagickQueue(); + } + + // Process queued ImageMagick requests up to the concurrency limit + function processImageMagickQueue() { + while (runningImageMagickProcesses < maxConcurrentImageMagickProcesses && imageMagickQueue.length > 0) { + const request = imageMagickQueue.shift(); + runImageMagickProcess(request); + } + } + + // Actually run an ImageMagick process + function runImageMagickProcess(request) { + runningImageMagickProcesses++; + const processString = ` + import QtQuick + import Quickshell.Io + Process { + command: ["sh", "-c", ""] + stdout: StdioCollector {} + stderr: StdioCollector {} + } + `; + try { + const processObj = Qt.createQmlObject(processString, root, "ImageProcess_" + request.cacheKey); + processObj.command = ["sh", "-c", request.command]; + processObj.exited.connect(function(exitCode) { + processObj.destroy(); + runningImageMagickProcesses--; + request.onComplete(exitCode, processObj); + processImageMagickQueue(); + }); + processObj.running = true; + } catch (e) { + Logger.e("ImageCache", "Failed to create process:", e); + runningImageMagickProcesses--; + request.onError(e); + processImageMagickQueue(); + } + } + + function runProcess(command, cacheKey, outputPath, sourcePath) { + queueImageMagickProcess({ + "command": command, + "cacheKey": cacheKey, + "onComplete": function(exitCode, proc) { + if (exitCode !== 0) { + const stderrText = proc.stderr.text || ""; + Logger.e("ImageCache", "Processing failed:", stderrText); + notifyCallbacks(cacheKey, sourcePath, false); + } else { + Logger.d("ImageCache", "Processing complete:", outputPath); + notifyCallbacks(cacheKey, outputPath, true); + } + }, + "onError": function() { + notifyCallbacks(cacheKey, sourcePath, false); + } + }); + } + + function queueFallbackProcessing(sourcePath, destPath, cacheKey, size) { + fallbackQueue.push({ + "sourcePath": sourcePath, + "destPath": destPath, + "cacheKey": cacheKey, + "size": size + }); + if (!fallbackProcessing) { + fallbackProcessing = true; + const item = fallbackQueue.shift(); + fallbackImage.cacheKey = item.cacheKey; + fallbackImage.destPath = item.destPath; + fallbackImage.targetSize = item.size; + fallbackImage.source = item.sourcePath; + } + } + + // Queue a utility process and run it when a slot is available + function queueUtilityProcess(request) { + utilityProcessQueue.push(request); + processUtilityQueue(); + } + + // Process queued utility requests up to the concurrency limit + function processUtilityQueue() { + while (runningUtilityProcesses < maxConcurrentUtilityProcesses && utilityProcessQueue.length > 0) { + const request = utilityProcessQueue.shift(); + runUtilityProcess(request); + } + } + + // Actually run a utility process + function runUtilityProcess(request) { + runningUtilityProcesses++; + try { + const processObj = Qt.createQmlObject(request.processString, root, request.name); + processObj.exited.connect(function(exitCode) { + processObj.destroy(); + runningUtilityProcesses--; + request.onComplete(exitCode, processObj); + processUtilityQueue(); + }); + processObj.running = true; + } catch (e) { + Logger.e("ImageCache", "Failed to create " + request.name + ":", e); + runningUtilityProcesses--; + request.onError(e); + processUtilityQueue(); + } + } + + function getMtime(filePath, callback) { + const pathEsc = filePath.replace(/'/g, "'\\''"); + const processString = ` + import QtQuick + import Quickshell.Io + Process { + command: ["stat", "-c", "%Y", "${pathEsc}"] + stdout: StdioCollector {} + stderr: StdioCollector {} + } + `; + queueUtilityProcess({ + "name": "MtimeProcess", + "processString": processString, + "onComplete": function(exitCode, proc) { + const mtime = exitCode === 0 ? proc.stdout.text.trim() : ""; + callback(mtime); + }, + "onError": function() { + callback(""); + } + }); + } + + function checkFileExists(filePath, callback) { + const pathEsc = filePath.replace(/'/g, "'\\''"); + const processString = ` + import QtQuick + import Quickshell.Io + Process { + command: ["test", "-f", "${pathEsc}"] + stdout: StdioCollector {} + stderr: StdioCollector {} + } + `; + queueUtilityProcess({ + "name": "FileExistsProcess", + "processString": processString, + "onComplete": function(exitCode) { + callback(exitCode === 0); + }, + "onError": function() { + callback(false); + } + }); + } + + function getImageDimensions(filePath, callback) { + const pathEsc = filePath.replace(/'/g, "'\\''"); + const processString = ` + import QtQuick + import Quickshell.Io + Process { + command: ["identify", "-ping", "-format", "%w %h", "${pathEsc}[0]"] + stdout: StdioCollector {} + stderr: StdioCollector {} + } + `; + queueUtilityProcess({ + "name": "IdentifyProcess", + "processString": processString, + "onComplete": function(exitCode, proc) { + let width = 0, height = 0; + if (exitCode === 0) { + const parts = proc.stdout.text.trim().split(" "); + if (parts.length >= 2) { + width = parseInt(parts[0], 10) || 0; + height = parseInt(parts[1], 10) || 0; + } + } + callback(width, height); + }, + "onError": function() { + callback(0, 0); + } + }); + } + + // ------------------------------------------------- + // Cache Invalidation + // ------------------------------------------------- + function invalidateThumbnail(sourcePath) { + Logger.i("ImageCache", "Invalidating thumbnail for:", sourcePath); + // Since cache keys include hash, we'd need to track mappings + // For simplicity, clear all thumbnails + clearThumbnails(); + } + + function invalidateLarge(sourcePath) { + Logger.i("ImageCache", "Invalidating large for:", sourcePath); + clearLarge(); + } + + function invalidateNotification(imageId) { + const path = notificationsDir + imageId + ".png"; + Quickshell.execDetached(["rm", "-f", path]); + } + + function invalidateAvatar(username) { + const path = contributorsDir + username + "_circular.png"; + Quickshell.execDetached(["rm", "-f", path]); + } + + // ------------------------------------------------- + // Clear Cache Functions + // ------------------------------------------------- + function clearAll() { + Logger.i("ImageCache", "Clearing all cache"); + clearThumbnails(); + clearLarge(); + clearNotifications(); + clearContributors(); + } + + function clearThumbnails() { + Logger.i("ImageCache", "Clearing thumbnails cache"); + Quickshell.execDetached(["rm", "-rf", wpThumbDir]); + Quickshell.execDetached(["mkdir", "-p", wpThumbDir]); + } + + function clearLarge() { + Logger.i("ImageCache", "Clearing large cache"); + Quickshell.execDetached(["rm", "-rf", wpLargeDir]); + Quickshell.execDetached(["mkdir", "-p", wpLargeDir]); + } + + function clearNotifications() { + Logger.i("ImageCache", "Clearing notifications cache"); + Quickshell.execDetached(["rm", "-rf", notificationsDir]); + Quickshell.execDetached(["mkdir", "-p", notificationsDir]); + } + + function clearContributors() { + Logger.i("ImageCache", "Clearing contributors cache"); + Quickshell.execDetached(["rm", "-rf", contributorsDir]); + Quickshell.execDetached(["mkdir", "-p", contributorsDir]); + } + + // ------------------------------------------------- + // Qt Fallback Renderer + // ------------------------------------------------- + PanelWindow { + id: fallbackRenderer + + implicitWidth: 0 + implicitHeight: 0 + WlrLayershell.exclusionMode: ExclusionMode.Ignore + WlrLayershell.namespace: "noctalia-image-cache-renderer" + color: "transparent" + + Image { + id: fallbackImage + + property string cacheKey: "" + property string destPath: "" + property int targetSize: 256 + + function processNextFallback() { + cacheKey = ""; + destPath = ""; + source = ""; + if (fallbackQueue.length > 0) { + const next = fallbackQueue.shift(); + cacheKey = next.cacheKey; + destPath = next.destPath; + targetSize = next.size; + source = next.sourcePath; + } else { + fallbackProcessing = false; + } + } + + width: targetSize + height: targetSize + visible: true + cache: false + asynchronous: true + fillMode: Image.PreserveAspectCrop + mipmap: true + antialiasing: true + onStatusChanged: { + if (!cacheKey) + return ; + + if (status === Image.Ready) { + grabToImage(function(result) { + if (result.saveToFile(destPath)) { + Logger.d("ImageCache", "Fallback cache created:", destPath); + root.notifyCallbacks(cacheKey, destPath, true); + } else { + Logger.e("ImageCache", "Failed to save fallback cache"); + root.notifyCallbacks(cacheKey, "", false); + } + processNextFallback(); + }); + } else if (status === Image.Error) { + Logger.e("ImageCache", "Fallback image load failed"); + root.notifyCallbacks(cacheKey, "", false); + processNextFallback(); + } + } + } + + mask: Region { + } + + } + + // ------------------------------------------------- + // ImageMagick Detection + // ------------------------------------------------- + Process { + id: checkMagickProcess + + command: ["sh", "-c", "command -v magick"] + running: false + onExited: function(exitCode) { + root.imageMagickAvailable = (exitCode === 0); + root.initialized = true; + if (root.imageMagickAvailable) + Logger.i("ImageCache", "ImageMagick available"); + else + Logger.w("ImageCache", "ImageMagick not found, using Qt fallback"); + } + + stdout: StdioCollector { + } + + stderr: StdioCollector { + } + + } + +} diff --git a/config/quickshell/.config/quickshell/Services/Init.qml b/config/quickshell/.config/quickshell/Services/Init.qml new file mode 100644 index 0000000..cd9d248 --- /dev/null +++ b/config/quickshell/.config/quickshell/Services/Init.qml @@ -0,0 +1,36 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Constants +import qs.Utils +pragma Singleton + +Singleton { + id: root + + property bool loaded: false + + Component.onCompleted: { + let mkdirs = ""; + for (const dir of [Paths.cacheDir, Paths.configDir, Paths.recordingDir]) { + mkdirs += `mkdir -p "${dir}" && `; + } + mkdirs += "true"; + Logger.d("Init", `Creating necessary directories with command: ${mkdirs}`); + process.command = ["sh", "-c", mkdirs]; + process.running = true; + } + + Process { + id: process + + running: false + onExited: (code, status) => { + if (code === 0) + root.loaded = true; + else + Logger.e("Init", `Failed to create necessary directories: ${code} (${status})`); + } + } + +} diff --git a/config/quickshell/.config/quickshell/Services/IpService.qml b/config/quickshell/.config/quickshell/Services/IpService.qml index 107dc93..4f489cd 100644 --- a/config/quickshell/.config/quickshell/Services/IpService.qml +++ b/config/quickshell/.config/quickshell/Services/IpService.qml @@ -1,21 +1,23 @@ import QtQuick import Quickshell import Quickshell.Io +import qs.Constants import qs.Services import qs.Utils pragma Singleton Singleton { property alias ip: cacheFileAdapter.ip - readonly property string cacheFilePath: CacheService.ipCacheFile - readonly property string aliasFilePath: Qt.resolvedUrl("../Assets/Config/IpAliases.json") + readonly property string cacheFilePath: Paths.cacheDir + "ip.json" + readonly property string aliasFilePath: Paths.configDir + "ip_alias.json" + readonly property string geoinfoTokenFilePath: Paths.configDir + "geo_token.txt" property string countryCode: "N/A" property string alias: "" property real fetchInterval: 120 // in s property real fetchTimeout: 10 // in s readonly property string ipURL: "https://api.uyanide.com/ip" readonly property string geoURL: "https://api.ipinfo.io/lite/" - property string geoURLToken: "" + property string geoURLToken: SettingsService.geoInfoToken function fetchIP() { curl.fetch(ipURL, function(success, data) { @@ -24,20 +26,20 @@ Singleton { const response = JSON.parse(data); if (response && response.ip) { let newIP = response.ip; - Logger.log("IpService", "Fetched IP: " + newIP); + Logger.d("IpService", "Fetched IP: " + newIP); if (newIP !== ip) { ip = newIP; countryCode = "N/A"; fetchGeoInfo(true); // Fetch geo info only if IP has changed } } else { - Logger.error("IpService", "IP response does not contain 'ip' field"); + Logger.e("IpService", "IP response does not contain 'ip' field"); } } catch (e) { - Logger.error("IpService", "Failed to parse IP response: " + e); + Logger.e("IpService", "Failed to parse IP response: " + e); } } else { - Logger.error("IpService", "Failed to fetch IP"); + Logger.e("IpService", "Failed to fetch IP"); } }, true); } @@ -58,17 +60,17 @@ Singleton { const response = JSON.parse(data); if (response && (response.country_code || response.country)) { let newCountryCode = response.country_code || response.country; - Logger.log("IpService", "Fetched country code: " + newCountryCode); + Logger.d("IpService", "Fetched country code: " + newCountryCode); countryCode = newCountryCode; } else { - Logger.error("IpService", "Geo response does not contain 'country_code' field"); + Logger.e("IpService", "Geo response does not contain 'country_code' field"); } cacheFileAdapter.geoInfo = response; } catch (e) { - Logger.error("IpService", "Failed to parse geo response: " + e); + Logger.e("IpService", "Failed to parse geo response: " + e); } } else { - Logger.error("IpService", "Failed to fetch geo info"); + Logger.e("IpService", "Failed to fetch geo info"); } SendNotification.show("New IP", `IP: ${ip}\nCountry: ${countryCode}${alias ? `\nAlias: ${alias}` : ""}`); cacheFile.writeAdapter(); @@ -76,10 +78,9 @@ Singleton { } function refresh() { - fetchTimer.stop(); ip = "N/A"; - fetchIP(); - fetchTimer.start(); + countryCode = "N/A"; + fetchIPDebouncer.restart(); } function updateAlias() { @@ -88,13 +89,9 @@ Singleton { return ; } alias = ""; - for (let i = 0; i < aliasFileAdapter.aliases.length; i++) { - let entry = aliasFileAdapter.aliases[i]; - if (entry.ip === ip) { - alias = entry.alias; - Logger.log("IpService", "Found alias for IP " + ip + ": " + alias); - break; - } + if (SettingsService.ipAliases[ip]) { + alias = SettingsService.ipAliases[ip]; + Logger.d("IpService", "Found alias for IP " + ip + ": " + alias); } } @@ -118,14 +115,14 @@ Singleton { stdout: SplitParser { splitMarker: "\n" onRead: { - ipMonitorDebounce.restart(); + fetchIPDebouncer.restart(); } } } Timer { - id: ipMonitorDebounce + id: fetchIPDebouncer interval: 1000 repeat: false @@ -142,26 +139,7 @@ Singleton { repeat: true running: true onTriggered: { - fetchTimer.stop(); - fetchIP(); - fetchTimer.start(); - } - } - - FileView { - id: tokenFile - - path: Qt.resolvedUrl("../Assets/Config/GeoInfoToken.txt") - onLoaded: { - geoURLToken = tokenFile.text(); - if (!geoURLToken) - Logger.warn("IpService", "No token found for geoIP service, assuming none is required"); - - if (geoURLToken[geoURLToken.length - 1] === "\n") - geoURLToken = geoURLToken.slice(0, -1); - - fetchIP(); - fetchTimer.start(); + fetchIPDebouncer.restart(); } } @@ -171,10 +149,10 @@ Singleton { path: cacheFilePath watchChanges: false onLoaded: { - Logger.log("IpService", "Loaded IP from cache file: " + cacheFileAdapter.ip); + Logger.d("IpService", "Loaded IP from cache file: " + cacheFileAdapter.ip); if (cacheFileAdapter.geoInfo) { countryCode = cacheFileAdapter.geoInfo.country_code || cacheFileAdapter.country || "N/A"; - Logger.log("IpService", "Loaded country code from cache file: " + countryCode); + Logger.d("IpService", "Loaded country code from cache file: " + countryCode); } } @@ -187,21 +165,4 @@ Singleton { } - FileView { - id: aliasFile - - path: aliasFilePath - watchChanges: true - onLoaded: { - Logger.log("IpService", "Loaded IP aliases from file, total aliases: " + aliasFileAdapter.aliases.length); - } - - JsonAdapter { - id: aliasFileAdapter - - property var aliases: [] - } - - } - } diff --git a/config/quickshell/.config/quickshell/Services/LocationService.qml b/config/quickshell/.config/quickshell/Services/LocationService.qml index a3a2c45..a917ab1 100644 --- a/config/quickshell/.config/quickshell/Services/LocationService.qml +++ b/config/quickshell/.config/quickshell/Services/LocationService.qml @@ -2,27 +2,23 @@ import QtQuick import Quickshell import Quickshell.Io import qs.Constants -import qs.Services import qs.Utils pragma Singleton -// Weather logic and caching with stable UI properties +// Location and weather service with decoupled geocoding and weather fetching. Singleton { - //console.log(JSON.stringify(weatherData)) - id: root - property string locationName: SettingsService.location - property string locationFile: CacheService.locationCacheFile + property string locationFile: Paths.cacheDir + "location.json" property int weatherUpdateFrequency: 30 * 60 // 30 minutes expressed in seconds property bool isFetchingWeather: false - readonly property alias data: adapter // Used to access via LocationService.data.xxx from outside, best to use "adapter" inside the service. - // Stable UI properties - only updated when location is fully resolved + readonly property alias data: adapter + // Stable UI properties - only updated when location is successfully geocoded property bool coordinatesReady: false property string stableLatitude: "" property string stableLongitude: "" property string stableName: "" - // Helper property for UI components (outside JsonAdapter to avoid binding loops) + // Formatted coordinates for UI display readonly property string displayCoordinates: { if (!root.coordinatesReady || root.stableLatitude === "" || root.stableLongitude === "") return ""; @@ -32,127 +28,153 @@ Singleton { return `${lat}, ${lon}`; } - // -------------------------------- function init() { - // does nothing but ensure the singleton is created - // do not remove - Logger.log("Location", "Service started"); + Logger.i("Location", "Service started"); } - // -------------------------------- function resetWeather() { - Logger.log("Location", "Resetting weather data"); - // Mark as changing to prevent UI updates + Logger.i("Location", "Resetting location and weather data"); root.coordinatesReady = false; - // Reset stable properties root.stableLatitude = ""; root.stableLongitude = ""; root.stableName = ""; - // Reset core data adapter.latitude = ""; adapter.longitude = ""; adapter.name = ""; adapter.weatherLastFetch = 0; adapter.weather = null; - // Try to fetch immediately - updateWeather(); + update(); } - // -------------------------------- - function updateWeather() { + // Main update function - geocodes location if needed, then fetches weather if enabled + function update() { + updateLocation(); + updateWeatherData(); + } + + // Runs independently of weather toggle + function updateLocation() { + const locationChanged = adapter.name !== SettingsService.location; + const needsGeocoding = (adapter.latitude === "") || (adapter.longitude === "") || locationChanged; + if (!needsGeocoding) + return ; + if (isFetchingWeather) { - Logger.warn("Location", "Weather is still fetching"); + Logger.w("Location", "Location update already in progress"); return ; } - if ((adapter.weatherLastFetch === "") || (adapter.weather === null) || (adapter.latitude === "") || (adapter.longitude === "") || (adapter.name !== root.locationName) || (Time.timestamp >= adapter.weatherLastFetch + weatherUpdateFrequency)) - getFreshWeather(); - - } - - // -------------------------------- - function getFreshWeather() { isFetchingWeather = true; - // Check if location name has changed - const locationChanged = data.name !== root.locationName; if (locationChanged) { root.coordinatesReady = false; - Logger.log("Location", "Location changed from", adapter.name, "to", root.locationName); + Logger.d("Location", "Location changed from", adapter.name, "to", SettingsService.location); } - if ((adapter.latitude === "") || (adapter.longitude === "") || locationChanged) - _geocodeLocation(root.locationName, function(latitude, longitude, name, country) { - Logger.log("Location", "Geocoded", root.locationName, "to:", latitude, "/", longitude); - // Save location name - adapter.name = root.locationName; - // Save GPS coordinates + geocodeLocation(SettingsService.location, function(latitude, longitude, name, country) { + Logger.d("Location", "Geocoded", SettingsService.location, "to:", latitude, "/", longitude); + adapter.name = SettingsService.location; adapter.latitude = latitude.toString(); adapter.longitude = longitude.toString(); + root.stableLatitude = adapter.latitude; + root.stableLongitude = adapter.longitude; root.stableName = `${name}, ${country}`; - _fetchWeather(latitude, longitude, errorCallback); + root.coordinatesReady = true; + isFetchingWeather = false; + Logger.i("Location", "Coordinates ready"); + if (locationChanged) { + adapter.weatherLastFetch = 0; + updateWeatherData(); + } }, errorCallback); - else - _fetchWeather(adapter.latitude, adapter.longitude, errorCallback); } - // -------------------------------- - function _geocodeLocation(locationName, callback, errorCallback) { - Logger.log("Location", "Geocoding location name"); - var geoUrl = "https://assets.noctalia.dev/geocode.php?city=" + encodeURIComponent(locationName) + "&language=en&format=json"; - curl.fetch(geoUrl, function(success, data) { - if (success) { - try { - var geoData = JSON.parse(data); - if (geoData.lat != null) - callback(geoData.lat, geoData.lng, geoData.name, geoData.country); - else - errorCallback("Location", "could not resolve location name"); - } catch (e) { - errorCallback("Location", "Failed to parse geocoding data: " + e); - } - } else { - errorCallback("Location", "Geocoding error"); - } - }); + // Fetch weather data if enabled and coordinates are available + function updateWeatherData() { + if (isFetchingWeather) { + Logger.w("Location", "Weather is still fetching"); + return ; + } + if (adapter.latitude === "" || adapter.longitude === "") { + Logger.w("Location", "Cannot fetch weather without coordinates"); + return ; + } + const needsWeatherUpdate = (adapter.weatherLastFetch === "") || (adapter.weather === null) || (Time.timestamp >= adapter.weatherLastFetch + weatherUpdateFrequency); + if (needsWeatherUpdate) { + isFetchingWeather = true; + fetchWeatherData(adapter.latitude, adapter.longitude, errorCallback); + } } - // -------------------------------- - function _fetchWeather(latitude, longitude, errorCallback) { - Logger.log("Location", "Fetching weather from api.open-meteo.com"); - var url = "https://api.open-meteo.com/v1/forecast?latitude=" + latitude + "&longitude=" + longitude + "¤t_weather=true¤t=relativehumidity_2m,surface_pressure&daily=temperature_2m_max,temperature_2m_min,weathercode&timezone=auto"; - curl.fetch(url, function(success, fetchedData) { - if (success) { - try { - var weatherData = JSON.parse(fetchedData); - // Save core data - data.weather = weatherData; - data.weatherLastFetch = Time.timestamp; - // Update stable display values only when complete and successful - root.stableLatitude = data.latitude = weatherData.latitude.toString(); - root.stableLongitude = data.longitude = weatherData.longitude.toString(); - root.coordinatesReady = true; - isFetchingWeather = false; - Logger.log("Location", "Cached weather to disk - stable coordinates updated"); - } catch (e) { - errorCallback("Location", "Failed to parse weather data: " + e); + // Query geocoding API to convert location name to coordinates + function geocodeLocation(locationName, callback, errorCallback) { + Logger.d("Location", "Geocoding location name"); + var geoUrl = "https://api.noctalia.dev/geocode?city=" + encodeURIComponent(locationName); + var xhr = new XMLHttpRequest(); + xhr.onreadystatechange = function() { + if (xhr.readyState === XMLHttpRequest.DONE) { + if (xhr.status === 200) { + try { + var geoData = JSON.parse(xhr.responseText); + if (geoData.lat != null) + callback(geoData.lat, geoData.lng, geoData.name, geoData.country); + else + errorCallback("Location", "could not resolve location name"); + } catch (e) { + errorCallback("Location", "Failed to parse geocoding data: " + e); + } + } else { + errorCallback("Location", "Geocoding error: " + xhr.status); } - } else { - errorCallback("Location", "Weather fetch error"); } - }); + }; + xhr.open("GET", geoUrl); + xhr.send(); + } + + // Fetch weather data from Open-Meteo API + function fetchWeatherData(latitude, longitude, errorCallback) { + Logger.d("Location", "Fetching weather from api.open-meteo.com"); + var url = "https://api.open-meteo.com/v1/forecast?latitude=" + latitude + "&longitude=" + longitude + "¤t_weather=true¤t=relativehumidity_2m,surface_pressure,is_day&daily=temperature_2m_max,temperature_2m_min,weathercode,sunset,sunrise&timezone=auto"; + var xhr = new XMLHttpRequest(); + xhr.onreadystatechange = function() { + if (xhr.readyState === XMLHttpRequest.DONE) { + //console.log(JSON.stringify(weatherData)) + + if (xhr.status === 200) { + try { + var weatherData = JSON.parse(xhr.responseText); + // Save core data + data.weather = weatherData; + data.weatherLastFetch = Time.timestamp; + // Update stable display values only when complete and successful + root.stableLatitude = data.latitude = weatherData.latitude.toString(); + root.stableLongitude = data.longitude = weatherData.longitude.toString(); + root.coordinatesReady = true; + isFetchingWeather = false; + Logger.d("Location", "Cached weather to disk - stable coordinates updated"); + } catch (e) { + errorCallback("Location", "Failed to parse weather data"); + } + } else { + errorCallback("Location", "Weather fetch error: " + xhr.status); + } + } + }; + xhr.open("GET", url); + xhr.send(); } // -------------------------------- function errorCallback(module, message) { - Logger.error(module, message); + Logger.e(module, message); isFetchingWeather = false; } // -------------------------------- - function weatherSymbolFromCode(code) { + function weatherSymbolFromCode(code, isDay) { if (code === 0) - return "weather-sun"; + return isDay ? "weather-sun" : "weather-moon"; if (code === 1 || code === 2) - return "weather-cloud-sun"; + return isDay ? "weather-cloud-sun" : "weather-moon-stars"; if (code === 3) return "weather-cloud"; @@ -163,6 +185,9 @@ Singleton { if (code >= 51 && code <= 67) return "weather-cloud-rain"; + if (code >= 80 && code <= 82) + return "weather-cloud-rain"; + if (code >= 71 && code <= 77) return "weather-cloud-snow"; @@ -178,47 +203,6 @@ Singleton { return "weather-cloud"; } - function weatherColorFromCode(code) { - // Clear sky - bright yellow - if (code === 0) - return Colors.yellow; - - // Mainly clear/Partly cloudy - soft peach/rosewater tones - if (code === 1 || code === 2) - return Colors.peach; - - // Overcast - neutral sky blue - if (code === 3) - return Colors.sky; - - // Fog - soft lavender/muted tone - if (code >= 45 && code <= 48) - return Colors.lavender; - - // Drizzle - light blue/sapphire - if (code >= 51 && code <= 67) - return Colors.sapphire; - - // Snow - cool teal - if (code >= 71 && code <= 77) - return Colors.teal; - - // Rain showers - deeper blue - if (code >= 80 && code <= 82) - return Colors.blue; - - // Snow showers - teal - if (code >= 85 && code <= 86) - return Colors.teal; - - // Thunderstorm - dramatic mauve/pink - if (code >= 95 && code <= 99) - return Colors.mauve; - - // Default - sky blue - return Colors.sky; - } - // -------------------------------- function weatherDescriptionFromCode(code) { if (code === 0) @@ -263,25 +247,23 @@ Singleton { printErrors: false onAdapterUpdated: saveTimer.start() onLoaded: { - Logger.log("Location", "Loaded cached data"); - // Initialize stable properties on load + Logger.d("Location", "Loaded cached data"); if (adapter.latitude !== "" && adapter.longitude !== "" && adapter.weatherLastFetch > 0) { root.stableLatitude = adapter.latitude; root.stableLongitude = adapter.longitude; root.stableName = adapter.name; root.coordinatesReady = true; - Logger.log("Location", "Coordinates ready"); + Logger.i("Location", "Coordinates ready"); } - updateWeather(); + update(); } onLoadFailed: function(error) { - updateWeather(); + update(); } JsonAdapter { id: adapter - // Core data properties property string latitude: "" property string longitude: "" property string name: "" @@ -291,7 +273,7 @@ Singleton { } - // Every 20s check if we need to fetch new weather + // Update timer runs when weather is enabled or location-based scheduling is active Timer { id: updateTimer @@ -299,7 +281,7 @@ Singleton { running: true repeat: true onTriggered: { - updateWeather(); + update(); } } @@ -311,8 +293,4 @@ Singleton { onTriggered: locationFileView.writeAdapter() } - NetworkFetch { - id: curl - } - } diff --git a/config/quickshell/.config/quickshell/Services/LyricsService.qml b/config/quickshell/.config/quickshell/Services/LyricsService.qml index e41f73c..c732355 100644 --- a/config/quickshell/.config/quickshell/Services/LyricsService.qml +++ b/config/quickshell/.config/quickshell/Services/LyricsService.qml @@ -1,15 +1,18 @@ import QtQuick import Quickshell import Quickshell.Io +import qs.Constants import qs.Services import qs.Utils pragma Singleton Singleton { + id: root + property int linesCount: 3 property int linesAhead: linesCount / 2 readonly property int currentIndex: linesCount - linesAhead - 1 - readonly property string offsetFile: CacheService.lyricsOffsetCacheFile + readonly property string offsetFile: Paths.cacheDir + "/spotify-lyrics-offset.txt" property int offset: 0 // in ms readonly property int offsetStep: 500 // in ms property int referenceCount: 0 @@ -18,12 +21,19 @@ Singleton { // line 2 <- current line // line 3 property var lyrics: Array(linesCount).fill(" ") + property bool showLyricsBar: ShellState.lyricsState.showLyricsBar || false + + function toggleLyricsBar() { + ShellState.lyricsState = { + "showLyricsBar": !root.showLyricsBar + }; + } function startSyncing() { referenceCount++; - Logger.log("LyricsService", "Reference count:", referenceCount); + Logger.d("LyricsService", "Reference count:", referenceCount); if (referenceCount === 1) { - Logger.log("LyricsService", "Starting lyrics syncing"); + Logger.d("LyricsService", "Starting lyrics syncing"); // fill lyrics with empty lines lyrics = Array(linesCount).fill(" "); listenProcess.exec(["sh", "-c", `pkill -x spotify-lyrics -u $USER; spotify-lyrics listen -l ${linesCount} -a ${linesAhead} -f ${offsetFile}`]); @@ -32,9 +42,9 @@ Singleton { function stopSyncing() { referenceCount--; - Logger.log("LyricsService", "Reference count:", referenceCount); + Logger.d("LyricsService", "Reference count:", referenceCount); if (referenceCount <= 0) { - Logger.log("LyricsService", "Stopping lyrics syncing"); + Logger.d("LyricsService", "Stopping lyrics syncing"); // kinda ugly but works, meanwhile: // listenProcess.signal(9) // listenProcess.signal(15) @@ -51,14 +61,17 @@ Singleton { function increaseOffset() { offset += offsetStep; + saveState(); } function decreaseOffset() { offset -= offsetStep; + saveState(); } function resetOffset() { offset = 0; + saveState(); } function clearCache() { @@ -72,7 +85,7 @@ Singleton { } onOffsetChanged: { - if (SettingsService.showLyricsBar) + if (root.showLyricsBar) SendNotification.show("Lyrics Offset Changed", `Current offset: ${offset} ms`); writeOffset(); @@ -116,7 +129,7 @@ Singleton { const val = parseInt(fileContents); if (!isNaN(val)) { offset = val; - Logger.log("LyricsService", "Loaded offset:", offset); + Logger.d("LyricsService", "Loaded offset:", offset); } else { offset = 0; writeOffset(); @@ -126,14 +139,14 @@ Singleton { writeOffset(); } } catch (e) { - Logger.error("LyricsService", "Error reading offset file:", e); + Logger.e("LyricsService", "Error reading offset file:", e); } } onLoadFailed: { - Logger.error("LyricsService", "Error loading offset file:", errorString); + Logger.e("LyricsService", "Error loading offset file."); } onSaveFailed: { - Logger.error("LyricsService", "Error saving offset file:", errorString); + Logger.e("LyricsService", "Error saving offset file."); } } diff --git a/config/quickshell/.config/quickshell/Services/MediaService.qml b/config/quickshell/.config/quickshell/Services/MediaService.qml new file mode 100644 index 0000000..10dff2b --- /dev/null +++ b/config/quickshell/.config/quickshell/Services/MediaService.qml @@ -0,0 +1,345 @@ +import QtQuick +import Quickshell +import Quickshell.Services.Mpris +import qs.Utils +pragma Singleton + +Singleton { + //Logger.i("Media", "No active player found") + + id: root + + property var currentPlayer: null + property string playerIdentity: currentPlayer ? (currentPlayer.identity || "") : "" + property real currentPosition: 0 + property bool isSeeking: false + property int selectedPlayerIndex: 0 + property bool isPlaying: currentPlayer ? (currentPlayer.playbackState === MprisPlaybackState.Playing || currentPlayer.isPlaying) : false + property string trackTitle: currentPlayer ? (currentPlayer.trackTitle !== undefined ? currentPlayer.trackTitle.replace(/(\r\n|\n|\r)/g, "") : "") : "" + property string trackArtist: currentPlayer ? (currentPlayer.trackArtist || "") : "" + property string trackAlbum: currentPlayer ? (currentPlayer.trackAlbum || "") : "" + property string trackArtUrl: currentPlayer ? (currentPlayer.trackArtUrl || "") : "" + property real trackLength: currentPlayer ? ((currentPlayer.length < infiniteTrackLength) ? currentPlayer.length : 0) : 0 + property bool canPlay: currentPlayer ? currentPlayer.canPlay : false + property bool canPause: currentPlayer ? currentPlayer.canPause : false + property bool canGoNext: currentPlayer ? currentPlayer.canGoNext : false + property bool canGoPrevious: currentPlayer ? currentPlayer.canGoPrevious : false + property bool canSeek: currentPlayer ? currentPlayer.canSeek : false + property string positionString: formatTime(currentPosition) + property string lengthString: formatTime(trackLength) + property real infiniteTrackLength: 9.22337e+11 + property bool autoSwitchingPaused: false + + function formatTime(seconds) { + if (isNaN(seconds) || seconds < 0) + return "0:00"; + + var h = Math.floor(seconds / 3600); + var m = Math.floor((seconds % 3600) / 60); + var s = Math.floor(seconds % 60); + var pad = function pad(n) { + return (n < 10) ? ("0" + n) : n; + }; + if (h > 0) + return h + ":" + pad(m) + ":" + pad(s); + else + return m + ":" + pad(s); + } + + function getAvailablePlayers() { + if (!Mpris.players || !Mpris.players.values) + return []; + + let allPlayers = Mpris.players.values; + let finalPlayers = []; + const genericBrowsers = ["firefox", "chromium", "chrome"]; + const blacklist = []; + // Separate players into specific and generic lists + let specificPlayers = []; + let genericPlayers = []; + for (var i = 0; i < allPlayers.length; i++) { + const identity = String(allPlayers[i].identity || "").toLowerCase(); + const name = String(allPlayers[i].name || "").toLowerCase(); + const match = blacklist.find((b) => { + const s = String(b || "").toLowerCase(); + return s && (identity.includes(s) || name.includes(s)); + }); + if (match) + continue; + + if (genericBrowsers.some((b) => { + return identity.includes(b); + })) + genericPlayers.push(allPlayers[i]); + else + specificPlayers.push(allPlayers[i]); + } + let matchedGenericIndices = { + }; + // For each specific player, try to find and pair it with a generic partner + for (var i = 0; i < specificPlayers.length; i++) { + let specificPlayer = specificPlayers[i]; + let title1 = String(specificPlayer.trackTitle || "").trim(); + let wasMatched = false; + if (title1) { + for (var j = 0; j < genericPlayers.length; j++) { + if (matchedGenericIndices[j]) + continue; + + let genericPlayer = genericPlayers[j]; + let title2 = String(genericPlayer.trackTitle || "").trim(); + if (title2 && (title1.includes(title2) || title2.includes(title1))) { + let dataPlayer = genericPlayer; + let identityPlayer = specificPlayer; + let scoreSpecific = (specificPlayer.trackArtUrl ? 1 : 0); + let scoreGeneric = (genericPlayer.trackArtUrl ? 1 : 0); + if (scoreSpecific > scoreGeneric) + dataPlayer = specificPlayer; + + let virtualPlayer = { + "identity": identityPlayer.identity, + "desktopEntry": identityPlayer.desktopEntry, + "trackTitle": dataPlayer.trackTitle, + "trackArtist": dataPlayer.trackArtist, + "trackAlbum": dataPlayer.trackAlbum, + "trackArtUrl": dataPlayer.trackArtUrl, + "length": dataPlayer.length || 0, + "position": dataPlayer.position || 0, + "playbackState": dataPlayer.playbackState, + "isPlaying": dataPlayer.isPlaying || false, + "canPlay": dataPlayer.canPlay || false, + "canPause": dataPlayer.canPause || false, + "canGoNext": dataPlayer.canGoNext || false, + "canGoPrevious": dataPlayer.canGoPrevious || false, + "canSeek": dataPlayer.canSeek || false, + "canControl": dataPlayer.canControl || false, + "_stateSource": dataPlayer, + "_controlTarget": identityPlayer + }; + finalPlayers.push(virtualPlayer); + matchedGenericIndices[j] = true; + wasMatched = true; + break; + } + } + } + if (!wasMatched) + finalPlayers.push(specificPlayer); + + } + // Add any generic players that were not matched + for (var i = 0; i < genericPlayers.length; i++) { + if (!matchedGenericIndices[i]) + finalPlayers.push(genericPlayers[i]); + + } + // Filter for controllable players + let controllablePlayers = []; + for (var i = 0; i < finalPlayers.length; i++) { + let player = finalPlayers[i]; + if (player && player.canPlay) + controllablePlayers.push(player); + + } + return controllablePlayers; + } + + function findActivePlayer() { + let availablePlayers = getAvailablePlayers(); + if (availablePlayers.length === 0) + return null; + + // Prioritize the actively playing player --- + for (var i = 0; i < availablePlayers.length; i++) { + if (availablePlayers[i] && availablePlayers[i].playbackState === MprisPlaybackState.Playing) { + Logger.d("Media", "Found actively playing player: " + availablePlayers[i].identity); + selectedPlayerIndex = i; + return availablePlayers[i]; + } + } + // fallback if nothing is playing) + if (selectedPlayerIndex < availablePlayers.length) { + return availablePlayers[selectedPlayerIndex]; + } else { + selectedPlayerIndex = 0; + return availablePlayers[0]; + } + } + + function switchToPlayer(index) { + let availablePlayers = getAvailablePlayers(); + if (index >= 0 && index < availablePlayers.length) { + let newPlayer = availablePlayers[index]; + if (newPlayer !== currentPlayer) { + currentPlayer = newPlayer; + selectedPlayerIndex = index; + currentPosition = currentPlayer ? currentPlayer.position : 0; + autoSwitchingPaused = true; + Logger.d("Media", "Manually switched to player " + currentPlayer.identity); + } + } + } + + // Switch to the most recently active player + function updateCurrentPlayer() { + let newPlayer = findActivePlayer(); + if (newPlayer !== currentPlayer) { + currentPlayer = newPlayer; + currentPosition = currentPlayer ? currentPlayer.position : 0; + Logger.d("Media", "Switching player"); + } + } + + function toggleAutoSwitchingPaused() { + if (autoSwitchingPaused) { + autoSwitchingPaused = false; + updateCurrentPlayer(); + } else { + autoSwitchingPaused = true; + } + } + + function playPause() { + if (currentPlayer) { + let stateSource = currentPlayer._stateSource || currentPlayer; + let controlTarget = currentPlayer._controlTarget || currentPlayer; + if (stateSource.playbackState === MprisPlaybackState.Playing) + controlTarget.pause(); + else + controlTarget.play(); + } + } + + function play() { + let target = currentPlayer ? (currentPlayer._controlTarget || currentPlayer) : null; + if (target && target.canPlay) + target.play(); + + } + + function stop() { + let target = currentPlayer ? (currentPlayer._controlTarget || currentPlayer) : null; + if (target) + target.stop(); + + } + + function pause() { + let target = currentPlayer ? (currentPlayer._controlTarget || currentPlayer) : null; + if (target && target.canPause) + target.pause(); + + } + + function next() { + let target = currentPlayer ? (currentPlayer._controlTarget || currentPlayer) : null; + if (target && target.canGoNext) + target.next(); + + } + + function previous() { + let target = currentPlayer ? (currentPlayer._controlTarget || currentPlayer) : null; + if (target && target.canGoPrevious) + target.previous(); + + } + + function seek(position) { + let target = currentPlayer ? (currentPlayer._controlTarget || currentPlayer) : null; + if (target && target.canSeek) { + target.position = position; + currentPosition = position; + } + } + + function seekRelative(offset) { + let target = currentPlayer ? (currentPlayer._controlTarget || currentPlayer) : null; + if (target && target.canSeek && target.length > 0) { + let seekPosition = target.position + offset; + target.position = seekPosition; + currentPosition = seekPosition; + } + } + + // Seek to position based on ratio (0.0 to 1.0) + function seekByRatio(ratio) { + let target = currentPlayer ? (currentPlayer._controlTarget || currentPlayer) : null; + if (target && target.canSeek && target.length > 0) { + let seekPosition = ratio * target.length; + target.position = seekPosition; + currentPosition = seekPosition; + } + } + + Component.onCompleted: { + updateCurrentPlayer(); + } + // Reset position when switching to inactive player + onCurrentPlayerChanged: { + if (!currentPlayer || !currentPlayer.isPlaying || currentPlayer.playbackState !== MprisPlaybackState.Playing) + currentPosition = 0; + + } + + // Update progress bar every second while playing + Timer { + id: positionTimer + + interval: 1000 + running: currentPlayer && !root.isSeeking && currentPlayer.isPlaying && currentPlayer.length > 0 && currentPlayer.playbackState === MprisPlaybackState.Playing + repeat: true + onTriggered: { + if (currentPlayer && !root.isSeeking && currentPlayer.isPlaying && currentPlayer.playbackState === MprisPlaybackState.Playing) + currentPosition = currentPlayer.position; + else + running = false; + } + } + + // Avoid overwriting currentPosition while seeking due to backend position changes + Connections { + function onPositionChanged() { + if (!root.isSeeking && currentPlayer) + currentPosition = currentPlayer.position; + + } + + function onPlaybackStateChanged() { + if (!root.isSeeking && currentPlayer) + currentPosition = currentPlayer.position; + + } + + target: currentPlayer ? (currentPlayer._stateSource || currentPlayer) : null + } + + Timer { + id: playerStateMonitor + + interval: 2000 // Check every 2 seconds + repeat: true + running: true + onTriggered: { + //Logger.d("MediaService", "playerStateMonitor triggered. autoSwitchingPaused: " + root.autoSwitchingPaused) + if (autoSwitchingPaused) + return ; + + // Only update if we don't have a playing player or if current player is paused + if (!currentPlayer || !currentPlayer.isPlaying || currentPlayer.playbackState !== MprisPlaybackState.Playing) + updateCurrentPlayer(); + + } + } + + // Update current player when available players change + Connections { + function onValuesChanged() { + Logger.d("Media", "Players changed"); + updateCurrentPlayer(); + } + + target: Mpris.players + } + +} diff --git a/config/quickshell/.config/quickshell/Services/MusicManager.qml b/config/quickshell/.config/quickshell/Services/MusicManager.qml deleted file mode 100644 index fcbe174..0000000 --- a/config/quickshell/.config/quickshell/Services/MusicManager.qml +++ /dev/null @@ -1,180 +0,0 @@ -import QtQuick -import Quickshell -import Quickshell.Services.Mpris -import qs.Modules.Misc -import qs.Utils -pragma Singleton - -Singleton { - id: manager - - // Properties - property var currentPlayer: null - property real currentPosition: 0 - property bool isPlaying: currentPlayer ? currentPlayer.isPlaying : false - property int selectedPlayerIndex: -1 - property string trackTitle: currentPlayer ? (currentPlayer.trackTitle || "Unknown Track") : "" - property string trackArtist: currentPlayer ? (currentPlayer.trackArtist || "Unknown Artist") : "" - property string trackAlbum: currentPlayer ? (currentPlayer.trackAlbum || "Unknown Album") : "" - property string trackArtUrl: currentPlayer ? (currentPlayer.trackArtUrl || "") : "" - property real trackLength: currentPlayer ? currentPlayer.length : 0 - property bool canPlay: currentPlayer ? currentPlayer.canPlay : false - property bool canPause: currentPlayer ? currentPlayer.canPause : false - property bool canGoNext: currentPlayer ? currentPlayer.canGoNext : false - property bool canGoPrevious: currentPlayer ? currentPlayer.canGoPrevious : false - property bool canSeek: currentPlayer ? currentPlayer.canSeek : false - property bool hasPlayer: getAvailablePlayers().length > 0 - // Expose cava values - property alias cavaValues: cava.values - - // Returns available MPRIS players - function getAvailablePlayers() { - if (!Mpris.players || !Mpris.players.values) - return []; - - let allPlayers = Mpris.players.values; - let controllablePlayers = []; - for (let i = 0; i < allPlayers.length; i++) { - let player = allPlayers[i]; - if (player && player.canControl) - controllablePlayers.push(player); - - } - return controllablePlayers; - } - - // Returns active player or first available - function findActivePlayer() { - let availablePlayers = getAvailablePlayers(); - if (availablePlayers.length === 0) - return null; - - // Get the first playing player - for (let i = availablePlayers.length - 1; i >= 0; i--) { - if (availablePlayers[i].isPlaying) - return availablePlayers[i]; - - } - // Fallback to last player - return availablePlayers[availablePlayers.length - 1]; - } - - // Updates currentPlayer and currentPosition - function updateCurrentPlayer() { - // Use selected player if index is valid - if (selectedPlayerIndex >= 0) { - let availablePlayers = getAvailablePlayers(); - if (selectedPlayerIndex < availablePlayers.length) { - currentPlayer = availablePlayers[selectedPlayerIndex]; - currentPosition = currentPlayer.position; - Logger.log("MusicManager", "Current player set by index:", currentPlayer ? currentPlayer.identity : "None"); - return ; - } else { - selectedPlayerIndex = -1; // Reset if index is out of range - } - } - // Otherwise, find active player - let newPlayer = findActivePlayer(); - if (newPlayer !== currentPlayer) { - currentPlayer = newPlayer; - currentPosition = currentPlayer ? currentPlayer.position : 0; - } - Logger.log("MusicManager", "Current player updated:", currentPlayer ? currentPlayer.identity : "None"); - } - - // Player control functions - function playPause() { - if (currentPlayer) { - if (currentPlayer.isPlaying) - currentPlayer.pause(); - else - currentPlayer.play(); - } - } - - function isAllPaused() { - let availablePlayers = getAvailablePlayers(); - for (let i = 0; i < availablePlayers.length; i++) { - if (availablePlayers[i].isPlaying) - return false; - - } - return true; - } - - function play() { - if (currentPlayer && currentPlayer.canPlay) - currentPlayer.play(); - - } - - function pause() { - if (currentPlayer && currentPlayer.canPause) - currentPlayer.pause(); - - } - - function next() { - if (currentPlayer && currentPlayer.canGoNext) - currentPlayer.next(); - - } - - function previous() { - if (currentPlayer && currentPlayer.canGoPrevious) - currentPlayer.previous(); - - } - - function seek(position) { - if (currentPlayer && currentPlayer.canSeek) { - currentPlayer.position = position; - currentPosition = position; - } - } - - function seekByRatio(ratio) { - if (currentPlayer && currentPlayer.canSeek && currentPlayer.length > 0) { - let seekPosition = ratio * currentPlayer.length; - currentPlayer.position = seekPosition; - currentPosition = seekPosition; - } - } - - // Initialize - Item { - Component.onCompleted: { - updateCurrentPlayer(); - } - } - - // Updates progress bar every second - Timer { - id: positionTimer - - interval: 1000 - running: currentPlayer && currentPlayer.isPlaying && currentPlayer.length > 0 - repeat: true - onTriggered: { - if (currentPlayer && currentPlayer.isPlaying) - currentPosition = currentPlayer.position; - - } - } - - // Reacts to player list changes - Connections { - function onValuesChanged() { - updateCurrentPlayer(); - } - - target: Mpris.players - } - - Cava { - id: cava - - count: 44 - } - -} diff --git a/config/quickshell/.config/quickshell/Services/NetworkFetch.qml b/config/quickshell/.config/quickshell/Services/NetworkFetch.qml index 8173c96..e8716a0 100644 --- a/config/quickshell/.config/quickshell/Services/NetworkFetch.qml +++ b/config/quickshell/.config/quickshell/Services/NetworkFetch.qml @@ -4,17 +4,6 @@ import Quickshell.Io import qs.Utils Item { - // function fakeFetch(resp, callback, forceIPv4 = false) { - // if (curlProcess.running) { - // Logger.warn("NetworkFetch", "A fetch operation is already in progress."); - // return ; - // } - // fetchedData = ""; - // fetchingCallback = callback; - // curlProcess.command = ["echo", resp]; - // curlProcess.running = true; - // } - id: root property real fetchTimeout: 10 // in seconds @@ -23,7 +12,7 @@ Item { function fetch(url, callback, forceIPv4 = false) { if (curlProcess.running) { - Logger.warn("NetworkFetch", "A fetch operation is already in progress."); + Logger.w("NetworkFetch", "A fetch operation is already in progress."); return ; } fetchedData = ""; @@ -41,24 +30,24 @@ Item { running: false onStarted: { - Logger.log("NetworkFetch", "Process started with command: " + curlProcess.command.join(" ")); + Logger.d("NetworkFetch", "Process started with command: " + curlProcess.command.join(" ")); } onExited: function(exitCode, exitStatus) { if (!fetchingCallback) { - Logger.error("NetworkFetch", "No callback defined for fetch operation."); + Logger.e("NetworkFetch", "No callback defined for fetch operation."); return ; } if (exitCode === 0) { - Logger.log("NetworkFetch", "Fetched data: " + fetchedData); + Logger.d("NetworkFetch", "Fetched data: " + fetchedData); fetchingCallback(true, fetchedData); } else { - Logger.error("NetworkFetch", "Fetch failed with exit code: " + exitCode); + Logger.e("NetworkFetch", "Fetch failed with exit code: " + exitCode); fetchingCallback(false, ""); } } stdout: SplitParser { - splitMarker: "" + splitMarker: "\n" onRead: (data) => { fetchedData += data; } diff --git a/config/quickshell/.config/quickshell/Services/NetworkService.qml b/config/quickshell/.config/quickshell/Services/NetworkService.qml index 47f38be..2b1c8ae 100644 --- a/config/quickshell/.config/quickshell/Services/NetworkService.qml +++ b/config/quickshell/.config/quickshell/Services/NetworkService.qml @@ -5,6 +5,7 @@ import Quickshell import Quickshell.Io import qs.Utils import qs.Services +import qs.Constants Singleton { id: root @@ -23,7 +24,7 @@ Singleton { property bool scanPending: false // Persistent cache - property string cacheFile: CacheService.networkCacheFile + property string cacheFile: Paths.cacheDir + "network.json" readonly property string cachedLastConnected: cacheAdapter.lastConnected readonly property var cachedNetworks: cacheAdapter.knownNetworks @@ -45,7 +46,7 @@ Singleton { } Component.onCompleted: { - Logger.log("Network", "Service initialized") + Logger.i("Network", "Service initialized") syncWifiState() scan() } @@ -94,7 +95,7 @@ Singleton { if (scanning) { // Mark current scan results to be ignored and schedule a new scan - Logger.log("Network", "Scan already in progress, will ignore results and rescan") + Logger.i("Network", "Scan already in progress, will ignore results and rescan") ignoreScanResults = true scanPending = true return @@ -106,7 +107,7 @@ Singleton { // Get existing profiles first, then scan profileCheckProcess.running = true - Logger.log("Network", "Wi-Fi scan in progress...") + Logger.i("Network", "Wi-Fi scan in progress...") } function connect(ssid, password = "") { @@ -218,7 +219,7 @@ Singleton { }) if (root.ethernetConnected !== connected) { root.ethernetConnected = connected - Logger.log("Network", "Ethernet connected:", root.ethernetConnected) + Logger.i("Network", "Ethernet connected:", root.ethernetConnected) } } } @@ -234,7 +235,7 @@ Singleton { stdout: StdioCollector { onStreamFinished: { const enabled = text.trim() === "enabled" - Logger.log("Network", "Wi-Fi adapter was detect as enabled:", enabled) + Logger.i("Network", "Wi-Fi adapter was detect as enabled:", enabled) if (SettingsService.wifiEnabled !== enabled) { SettingsService.wifiEnabled = enabled } @@ -250,7 +251,7 @@ Singleton { stdout: StdioCollector { onStreamFinished: { - Logger.log("Network", "Wi-Fi state change command executed.") + Logger.i("Network", "Wi-Fi state change command executed.") // Re-check the state to ensure it's in sync syncWifiState() } @@ -259,7 +260,7 @@ Singleton { stderr: StdioCollector { onStreamFinished: { if (text.trim()) { - Logger.warn("Network", "Error changing Wi-Fi state: " + text) + Logger.w("Network", "Error changing Wi-Fi state: " + text) } } } @@ -274,7 +275,7 @@ Singleton { stdout: StdioCollector { onStreamFinished: { if (root.ignoreScanResults) { - Logger.log("Network", "Ignoring profile check results (new scan requested)") + Logger.i("Network", "Ignoring profile check results (new scan requested)") root.scanning = false // Check if we need to start a new scan @@ -307,7 +308,7 @@ Singleton { stdout: StdioCollector { onStreamFinished: { if (root.ignoreScanResults) { - Logger.log("Network", "Ignoring scan results (new scan requested)") + Logger.i("Network", "Ignoring scan results (new scan requested)") root.scanning = false // Check if we need to start a new scan @@ -333,7 +334,7 @@ Singleton { // We know the last 3 fields, so everything else is SSID const lastColonIdx = line.lastIndexOf(":") if (lastColonIdx === -1) { - Logger.warn("Network", "Malformed nmcli output line:", line) + Logger.w("Network", "Malformed nmcli output line:", line) continue } @@ -342,7 +343,7 @@ Singleton { const secondLastColonIdx = remainingLine.lastIndexOf(":") if (secondLastColonIdx === -1) { - Logger.warn("Network", "Malformed nmcli output line:", line) + Logger.w("Network", "Malformed nmcli output line:", line) continue } @@ -351,7 +352,7 @@ Singleton { const thirdLastColonIdx = remainingLine2.lastIndexOf(":") if (thirdLastColonIdx === -1) { - Logger.warn("Network", "Malformed nmcli output line:", line) + Logger.w("Network", "Malformed nmcli output line:", line) continue } @@ -399,15 +400,15 @@ Singleton { if (newNetworks.length > 0 || lostNetworks.length > 0) { if (newNetworks.length > 0) { - Logger.log("Network", "New Wi-Fi SSID discovered:", newNetworks.join(", ")) + Logger.i("Network", "New Wi-Fi SSID discovered:", newNetworks.join(", ")) } if (lostNetworks.length > 0) { - Logger.log("Network", "Wi-Fi SSID disappeared:", lostNetworks.join(", ")) + Logger.i("Network", "Wi-Fi SSID disappeared:", lostNetworks.join(", ")) } - Logger.log("Network", "Total Wi-Fi SSIDs:", Object.keys(networksMap).length) + Logger.i("Network", "Total Wi-Fi SSIDs:", Object.keys(networksMap).length) } - Logger.log("Network", "Wi-Fi scan completed") + Logger.i("Network", "Wi-Fi scan completed") root.networks = networksMap root.scanning = false @@ -424,7 +425,7 @@ Singleton { onStreamFinished: { root.scanning = false if (text.trim()) { - Logger.warn("Network", "Scan error: " + text) + Logger.w("Network", "Scan error: " + text) // If scan fails, retry delayedScanTimer.interval = 5000 @@ -480,7 +481,7 @@ Singleton { root.connecting = false root.connectingTo = "" - Logger.log("Network", `Connected to network: '${connectProcess.ssid}'`) + Logger.i("Network", `Connected to network: '${connectProcess.ssid}'`) ToastService.showNotice(I18n.tr("wifi.panel.title"), I18n.tr("toast.wifi.connected", { "ssid": connectProcess.ssid })) @@ -509,7 +510,7 @@ Singleton { root.lastError = text.split("\n")[0].trim() } - Logger.warn("Network", "Connect error: " + text) + Logger.w("Network", "Connect error: " + text) } } } @@ -523,7 +524,7 @@ Singleton { stdout: StdioCollector { onStreamFinished: { - Logger.log("Network", `Disconnected from network: '${disconnectProcess.ssid}'`) + Logger.i("Network", `Disconnected from network: '${disconnectProcess.ssid}'`) ToastService.showNotice(I18n.tr("wifi.panel.title"), I18n.tr("toast.wifi.disconnected", { "ssid": disconnectProcess.ssid })) @@ -542,7 +543,7 @@ Singleton { onStreamFinished: { root.disconnectingFrom = "" if (text.trim()) { - Logger.warn("Network", "Disconnect error: " + text) + Logger.w("Network", "Disconnect error: " + text) } // Still trigger a scan even on error delayedScanTimer.interval = 5000 @@ -588,8 +589,8 @@ Singleton { stdout: StdioCollector { onStreamFinished: { - Logger.log("Network", `Forget network: "${forgetProcess.ssid}"`) - Logger.log("Network", text.trim().replace(/[\r\n]/g, " ")) + Logger.i("Network", `Forget network: "${forgetProcess.ssid}"`) + Logger.i("Network", text.trim().replace(/[\r\n]/g, " ")) // Update both cached and existing status immediately let nets = root.networks @@ -613,7 +614,7 @@ Singleton { onStreamFinished: { root.forgettingNetwork = "" if (text.trim() && !text.includes("No profiles found")) { - Logger.warn("Network", "Forget error: " + text) + Logger.w("Network", "Forget error: " + text) } // Still Trigger a scan even on error delayedScanTimer.interval = 5000 diff --git a/config/quickshell/.config/quickshell/Services/Niri.qml b/config/quickshell/.config/quickshell/Services/Niri.qml index 24ef8c5..282f882 100644 --- a/config/quickshell/.config/quickshell/Services/Niri.qml +++ b/config/quickshell/.config/quickshell/Services/Niri.qml @@ -1,208 +1,519 @@ import QtQuick import Quickshell import Quickshell.Io +import Quickshell.Wayland import qs.Utils pragma Singleton -pragma ComponentBehavior: Bound Singleton { id: root - property var workspaces: [] - property var windows: {} - property int focusedWindowId: -1 - property bool noFocus: focusedWindowId === -1 - property bool inOverview: false - property string focusedWindowTitle: "" - property string focusedWindowAppId: "" - property var onScreenshotCaptured: null + property int floatingWindowPosition: Number.MAX_SAFE_INTEGER + property ListModel workspaces + property var windows: [] + property bool hasFocusedWindow: focusedWindowIndex >= 0 && focusedWindowIndex < windows.length + property int focusedWindowIndex: -1 + property string focusedWindowAppId: hasFocusedWindow ? windows[focusedWindowIndex].appId : "" + property string focusedWindowTitle: hasFocusedWindow ? windows[focusedWindowIndex].title : "" + property string focusedOutput: "" + property ShellScreen focusedScreen: { + if (!focusedOutput) + return null; - function updateFocusedWindowTitle() { - if (windows && windows[focusedWindowId]) { - focusedWindowTitle = windows[focusedWindowId].title || ""; - focusedWindowAppId = windows[focusedWindowId].appId || ""; - } else { - focusedWindowTitle = ""; - focusedWindowAppId = ""; + for (var i = 0; i < Quickshell.screens.length; i++) { + if (Quickshell.screens[i].name === focusedOutput) + return Quickshell.screens[i]; + + } + return null; + } + property bool overviewActive: false + property var outputCache: ({ + }) + property var workspaceCache: ({ + }) + + signal workspaceChanged() + signal activeWindowChanged() + signal windowListChanged() + signal displayScalesChanged() + + function initialize() { + niriEventStream.connected = true; + niriCommandSocket.connected = true; + startEventStream(); + updateOutputs(); + updateWorkspaces(); + updateWindows(); + _queryDisplayScales(); + Logger.i("NiriService", "Service started"); + } + + // command from https://yalter.github.io/niri/niri_ipc/enum.Request.html + function sendSocketCommand(sock, command) { + sock.write(JSON.stringify(command) + "\n"); + sock.flush(); + } + + function startEventStream() { + sendSocketCommand(niriEventStream, "EventStream"); + } + + function updateOutputs() { + sendSocketCommand(niriCommandSocket, "Outputs"); + } + + function updateWorkspaces() { + sendSocketCommand(niriCommandSocket, "Workspaces"); + } + + function updateWindows() { + sendSocketCommand(niriCommandSocket, "Windows"); + } + + function _queryDisplayScales() { + sendSocketCommand(niriCommandSocket, "Outputs"); + } + + function recollectOutputs(outputsData) { + const scales = { + }; + outputCache = { + }; + for (const outputName in outputsData) { + const output = outputsData[outputName]; + if (output && output.name) { + const isConnected = output.logical !== null && output.current_mode !== null; + const logical = output.logical || { + }; + const currentModeIdx = output.current_mode ?? 0; + const modes = output.modes || []; + const currentMode = modes[currentModeIdx] || { + }; + const outputData = { + "name": output.name, + "connected": isConnected, + "scale": logical.scale || 1, + "width": logical.width || 0, + "height": logical.height || 0, + "x": logical.x || 0, + "y": logical.y || 0, + "physical_width": (output.physical_size && output.physical_size[0]) || 0, + "physical_height": (output.physical_size && output.physical_size[1]) || 0, + "refresh_rate": currentMode.refresh_rate || 0, + "vrr_supported": output.vrr_supported || false, + "vrr_enabled": output.vrr_enabled || false, + "transform": logical.transform || "Normal" + }; + outputCache[output.name] = outputData; + scales[output.name] = outputData; + } } } - function getFocusedWindow() { - return (windows && windows[focusedWindowId]) || null; + function _recollectWorkspaces(workspacesData) { + const workspacesList = []; + workspaceCache = { + }; + for (const ws of workspacesData) { + const wsData = { + "id": ws.id, + "idx": ws.idx, + "name": ws.name || "", + "output": ws.output || "", + "isFocused": ws.is_focused === true, + "isActive": ws.is_active === true, + "isUrgent": ws.is_urgent === true, + "isOccupied": ws.active_window_id ? true : false + }; + workspacesList.push(wsData); + workspaceCache[ws.id] = wsData; + if (wsData.isFocused) + focusedOutput = wsData.output || ""; + + } + workspacesList.sort((a, b) => { + if (a.output !== b.output) + return a.output.localeCompare(b.output); + + return a.idx - b.idx; + }); + workspaces.clear(); + for (var i = 0; i < workspacesList.length; i++) { + workspaces.append(workspacesList[i]); + } + workspaceChanged(); } - Component.onCompleted: { - eventStream.running = true; + function getWindowPosition(layout) { + if (layout.pos_in_scrolling_layout) + return { + "x": layout.pos_in_scrolling_layout[0], + "y": layout.pos_in_scrolling_layout[1] + }; + else + return { + "x": floatingWindowPosition, + "y": floatingWindowPosition + }; } - Process { - id: workspaceProcess + function getWindowOutput(win) { + for (var i = 0; i < workspaces.count; i++) { + if (workspaces.get(i).id === win.workspace_id) + return workspaces.get(i).output; - running: false - command: ["niri", "msg", "--json", "workspaces"] + } + return null; + } - stdout: SplitParser { + function getWindowData(win) { + return { + "id": win.id, + "title": win.title || "", + "appId": win.app_id || "", + "workspaceId": win.workspace_id || -1, + "isFocused": win.is_focused === true, + "output": getWindowOutput(win) || "", + "position": getWindowPosition(win.layout) + }; + } + + function toSortedWindowList(windowList) { + return windowList.map((win) => { + const workspace = workspaceCache[win.workspaceId]; + const output = (workspace && workspace.output) ? outputCache[workspace.output] : null; + return { + "window": win, + "workspaceIdx": workspace ? workspace.idx : 0, + "outputX": output ? output.x : 0, + "outputY": output ? output.y : 0 + }; + }).sort((a, b) => { + // Sort by output position first + if (a.outputX !== b.outputX) + return a.outputX - b.outputX; + + if (a.outputY !== b.outputY) + return a.outputY - b.outputY; + + // Then by workspace index + if (a.workspaceIdx !== b.workspaceIdx) + return a.workspaceIdx - b.workspaceIdx; + + // Then by window position + if (a.window.position.x !== b.window.position.x) + return a.window.position.x - b.window.position.x; + + if (a.window.position.y !== b.window.position.y) + return a.window.position.y - b.window.position.y; + + // Finally by window ID to ensure consistent ordering + return a.window.id - b.window.id; + }).map((info) => { + return info.window; + }); + } + + function recollectWindows(windowsData) { + const windowsList = []; + for (const win of windowsData) { + windowsList.push(getWindowData(win)); + } + windows = toSortedWindowList(windowsList); + windowListChanged(); + // Find focused window index in the SORTED windows array + focusedWindowIndex = -1; + for (var i = 0; i < windows.length; i++) { + if (windows[i].isFocused) { + focusedWindowIndex = i; + break; + } + } + activeWindowChanged(); + } + + function _handleWindowOpenedOrChanged(eventData) { + try { + const windowData = eventData.window; + const existingIndex = windows.findIndex((w) => { + return w.id === windowData.id; + }); + const newWindow = getWindowData(windowData); + // Find the previously focused window ID before any modifications + const previouslyFocusedId = focusedWindowIndex >= 0 && focusedWindowIndex < windows.length ? windows[focusedWindowIndex].id : null; + if (existingIndex >= 0) + windows[existingIndex] = newWindow; + else + windows.push(newWindow); + windows = toSortedWindowList(windows); + if (newWindow.isFocused) { + focusedWindowIndex = windows.findIndex((w) => { + return w.id === windowData.id; + }); + // Clear focus on the previously focused window by ID (not index, since list was re-sorted) + if (previouslyFocusedId !== null && previouslyFocusedId !== windowData.id) { + const oldFocusedWindow = windows.find((w) => { + return w.id === previouslyFocusedId; + }); + if (oldFocusedWindow) + oldFocusedWindow.isFocused = false; + + } + activeWindowChanged(); + } + windowListChanged(); + workspaceUpdateTimer.restart(); + } catch (e) { + Logger.e("NiriService", "Error handling WindowOpenedOrChanged:", e); + } + } + + function _handleWindowClosed(eventData) { + try { + const windowId = eventData.id; + const windowIndex = windows.findIndex((w) => { + return w.id === windowId; + }); + if (windowIndex >= 0) { + if (windowIndex === focusedWindowIndex) { + focusedWindowIndex = -1; + activeWindowChanged(); + } else if (focusedWindowIndex > windowIndex) { + focusedWindowIndex--; + } + windows.splice(windowIndex, 1); + windowListChanged(); + workspaceUpdateTimer.restart(); + } + } catch (e) { + Logger.e("NiriService", "Error handling WindowClosed:", e); + } + } + + function _handleWindowsChanged(eventData) { + try { + const windowsData = eventData.windows; + recollectWindows(windowsData); + } catch (e) { + Logger.e("NiriService", "Error handling WindowsChanged:", e); + } + } + + function _handleWindowFocusChanged(eventData) { + try { + const focusedId = eventData.id; + if (windows[focusedWindowIndex]) + windows[focusedWindowIndex].isFocused = false; + + if (focusedId) { + const newIndex = windows.findIndex((w) => { + return w.id === focusedId; + }); + if (newIndex >= 0 && newIndex < windows.length) + windows[newIndex].isFocused = true; + + focusedWindowIndex = newIndex >= 0 ? newIndex : -1; + } else { + focusedWindowIndex = -1; + } + activeWindowChanged(); + } catch (e) { + Logger.e("NiriService", "Error handling WindowFocusChanged:", e); + } + } + + function _handleWindowLayoutsChanged(eventData) { + try { + for (const change of eventData.changes) { + const windowId = change[0]; + const layout = change[1]; + const window = windows.find((w) => { + return w.id === windowId; + }); + if (window) + window.position = getWindowPosition(layout); + + } + windows = toSortedWindowList(windows); + windowListChanged(); + } catch (e) { + Logger.e("NiriService", "Error handling WindowLayoutChanged:", e); + } + } + + function _handleOverviewOpenedOrClosed(eventData) { + try { + overviewActive = eventData.is_open; + } catch (e) { + Logger.e("NiriService", "Error handling OverviewOpenedOrClosed:", e); + } + } + + function _handleScreenshotCaptured(eventData) { + try { + const filePath = eventData.path; + if (filePath) + Quickshell.execDetached(["screenshot-script", "edit", filePath]); + + } catch (e) { + Logger.e("NiriService", "Error handling ScreenshotCaptured:", e); + } + } + + function switchToWorkspace(workspace) { + try { + Quickshell.execDetached(["niri", "msg", "action", "focus-workspace", workspace.idx.toString()]); + } catch (e) { + Logger.e("NiriService", "Failed to switch workspace:", e); + } + } + + function scrollWorkspaceContent(direction) { + try { + var action = direction < 0 ? "focus-column-left" : "focus-column-right"; + Quickshell.execDetached(["niri", "msg", "action", action]); + } catch (e) { + Logger.e("NiriService", "Failed to scroll workspace content:", e); + } + } + + function focusWindow(window) { + try { + Quickshell.execDetached(["niri", "msg", "action", "focus-window", "--id", window.id.toString()]); + } catch (e) { + Logger.e("NiriService", "Failed to switch window:", e); + } + } + + function closeWindow(window) { + try { + Quickshell.execDetached(["niri", "msg", "action", "close-window", "--id", window.id.toString()]); + } catch (e) { + Logger.e("NiriService", "Failed to close window:", e); + } + } + + function turnOffMonitors() { + try { + Quickshell.execDetached(["niri", "msg", "action", "power-off-monitors"]); + } catch (e) { + Logger.e("NiriService", "Failed to turn off monitors:", e); + } + } + + function turnOnMonitors() { + try { + Quickshell.execDetached(["niri", "msg", "action", "power-on-monitors"]); + } catch (e) { + Logger.e("NiriService", "Failed to turn on monitors:", e); + } + } + + function logout() { + try { + Quickshell.execDetached(["niri", "msg", "action", "quit", "--skip-confirmation"]); + } catch (e) { + Logger.e("NiriService", "Failed to logout:", e); + } + } + + function getFocusedScreen() { + // On niri the code below only works when you have an actual app selected on that screen. + return null; + } + + function spawn(command) { + try { + const niriCommand = ["niri", "msg", "action", "spawn", "--"].concat(command); + Logger.d("NiriService", "Calling niri spawn: " + niriCommand.join(" ")); + Quickshell.execDetached(niriCommand); + } catch (e) { + Logger.e("NiriService", "Failed to spawn command:", e); + } + } + + Component.onCompleted: initialize() + + Timer { + id: workspaceUpdateTimer + + interval: 50 + repeat: false + onTriggered: updateWorkspaces() + } + + Socket { + id: niriCommandSocket + + path: Quickshell.env("NIRI_SOCKET") + connected: false + + parser: SplitParser { onRead: function(line) { try { - const workspacesData = JSON.parse(line); - const workspacesList = []; - for (const ws of workspacesData) { - workspacesList.push({ - "id": ws.id, - "idx": ws.idx, - "name": ws.name || "", - "output": ws.output || "", - "isFocused": ws.is_focused === true, - "isActive": ws.is_active === true, - "isUrgent": ws.is_urgent === true, - "activeWindowId": ws.active_window_id - }); + const data = JSON.parse(line); + if (data && data.Ok) { + const res = data.Ok; + if (res.Windows) + recollectWindows(res.Windows); + else if (res.Outputs) + recollectOutputs(res.Outputs); + else if (res.Workspaces) + _recollectWorkspaces(res.Workspaces); + } else { + Logger.e("NiriService", "Niri returned an error:", data.Err, line); } - workspacesList.sort((a, b) => { - if (a.output !== b.output) - return a.output.localeCompare(b.output); - - return a.id - b.id; - }); - root.workspaces = workspacesList; } catch (e) { - Logger.error("Niri", "Failed to parse workspaces:", e, line); + Logger.e("NiriService", "Failed to parse data from socket:", e, line); + return ; } } } } - Process { - id: eventStream + Socket { + id: niriEventStream - running: false - command: ["niri", "msg", "--json", "event-stream"] + path: Quickshell.env("NIRI_SOCKET") + connected: false - stdout: SplitParser { + parser: SplitParser { onRead: (data) => { try { const event = JSON.parse(data.trim()); - if (event.WorkspacesChanged) { - workspaceProcess.running = true; - } - if (event.WindowsChanged) { - try { - const windowsData = event.WindowsChanged.windows; - const windowsMap = {}; - for (const win of windowsData) { - if (win.is_focused === true) { - root.focusedWindowId = win.id; - } - windowsMap[win.id] = { - "title": win.title || "", - "appId": win.app_id || "", - "workspaceId": win.workspace_id || null, - "isFocused": win.is_focused === true - }; - } - root.windows = windowsMap; - root.updateFocusedWindowTitle(); - } catch (e) { - Logger.error("Niri", "Error parsing windows event:", e); - } - } - if (event.WorkspaceActivated) { - workspaceProcess.running = true; - } - if (event.WindowFocusChanged) { - try { - const focusedId = event.WindowFocusChanged.id; - if (focusedId) { - if (root.windows[focusedId]) { - root.focusedWindowId = focusedId; - } else { - root.focusedWindowId = -1; - } - - } else { - root.focusedWindowId = -1; - } - root.updateFocusedWindowTitle(); - } catch (e) { - Logger.error("Niri", "Error parsing window focus event:", e); - } - } - if (event.OverviewOpenedOrClosed) { - try { - root.inOverview = event.OverviewOpenedOrClosed.is_open === true; - } catch (e) { - Logger.error("Niri", "Error parsing overview state:", e); - } - } - if (event.WindowOpenedOrChanged) { - try { - const targetWin = event.WindowOpenedOrChanged.window; - const id = targetWin.id; - const isFocused = targetWin.is_focused === true; - let needUpdateTitle = false; - if (id) { - if (root.windows && root.windows[id]) { - const win = root.windows[id]; - // Update existing window - needUpdateTitle = win.title !== targetWin.title; - win.title = targetWin.title || win.title; - win.appId = targetWin.app_id || win.appId; - win.workspaceId = targetWin.workspace_id || win.workspaceId; - win.isFocused = isFocused; - } else { - // New window - const newWin = { - "title": targetWin.title || "", - "appId": targetWin.app_id || "", - "workspaceId": targetWin.workspace_id || null, - "isFocused": isFocused - }; - root.windows[id] = targetWin; - } - if (isFocused) { - if (root.focusedWindowId !== id || needUpdateTitle){ - root.focusedWindowId = id; - root.updateFocusedWindowTitle(); - } - } - - } - } catch (e) { - Logger.error("Niri", "Error parsing window opened/changed event:", e); - } - } - if (event.WindowClosed) { - try { - const closedId = event.WindowClosed.id; - if (closedId && (root.windows && root.windows[closedId])) { - delete root.windows[closedId]; - if (root.focusedWindowId === closedId) { - root.focusedWindowId = -1; - root.updateFocusedWindowTitle(); - } - } - } catch (e) { - Logger.error("Niri", "Error parsing window closed event:", e); - } - } - if (event.ScreenshotCaptured) { - try { - const path = event.ScreenshotCaptured.path || ""; - if (!path) return; - if (root.onScreenshotCaptured && typeof root.onScreenshotCaptured === "function") { - root.onScreenshotCaptured(path); - } - } catch (e) { - Logger.error("Niri", "Error parsing screenshot captured event:", e); - } - } + if (event.WorkspacesChanged) + _recollectWorkspaces(event.WorkspacesChanged.workspaces); + else if (event.WindowOpenedOrChanged) + _handleWindowOpenedOrChanged(event.WindowOpenedOrChanged); + else if (event.WindowClosed) + _handleWindowClosed(event.WindowClosed); + else if (event.WindowsChanged) + _handleWindowsChanged(event.WindowsChanged); + else if (event.WorkspaceActivated) + workspaceUpdateTimer.restart(); + else if (event.WindowFocusChanged) + _handleWindowFocusChanged(event.WindowFocusChanged); + else if (event.WindowLayoutsChanged) + _handleWindowLayoutsChanged(event.WindowLayoutsChanged); + else if (event.OverviewOpenedOrClosed) + _handleOverviewOpenedOrClosed(event.OverviewOpenedOrClosed); + else if (event.OutputsChanged) + _queryDisplayScales(); + else if (event.ConfigLoaded) + _queryDisplayScales(); + else if (event.ScreenshotCaptured) + _handleScreenshotCaptured(event.ScreenshotCaptured); } catch (e) { - Logger.error("Niri", "Error parsing event stream:", e, data); + Logger.e("NiriService", "Error parsing event stream:", e, data); } } } } + workspaces: ListModel { + } + } diff --git a/config/quickshell/.config/quickshell/Services/NotificationService.qml b/config/quickshell/.config/quickshell/Services/NotificationService.qml index 3204ed5..48c2860 100644 --- a/config/quickshell/.config/quickshell/Services/NotificationService.qml +++ b/config/quickshell/.config/quickshell/Services/NotificationService.qml @@ -5,10 +5,11 @@ import QtQuick.Window import Quickshell import Quickshell.Io import Quickshell.Services.Notifications +import Quickshell.Wayland +import "./sha256.js" as Checksum import qs.Utils import qs.Services import qs.Constants -import "../Utils/sha256.js" as Checksum Singleton { id: root @@ -16,254 +17,469 @@ Singleton { // Configuration property int maxVisible: 5 property int maxHistory: 100 - property string historyFile: CacheService.notificationsCacheFile - property string cacheDirImagesNotifications: Quickshell.env("HOME") + "/.cache/quickshell/notifications/" - property real lowUrgencyDuration: 3 - property real normalUrgencyDuration: 8 - property real criticalUrgencyDuration: 15 + property string historyFile: Paths.cacheDir + "notifications.json" + + // State + property real lastSeenTs: ShellState.notificationsState.lastSeenTs || 0 + property bool doNotDisturb: ShellState.notificationsState.doNotDisturb || false // Models property ListModel activeList: ListModel {} property ListModel historyList: ListModel {} // Internal state - property var activeMap: ({}) - property var imageQueue: [] + property var activeNotifications: ({}) // Maps internal ID to {notification, watcher, metadata} + property var quickshellIdToInternalId: ({}) - // Performance optimization: Track notification metadata separately - property var notificationMetadata: ({}) // Stores timestamp and duration for each notification + // Rate limiting for notification sounds (minimum 100ms between sounds) + property var lastSoundTime: 0 + readonly property int minSoundInterval: 100 - PanelWindow { - implicitHeight: 1 - implicitWidth: 1 - color: Color.transparent - mask: Region {} + // Notification server + property var notificationServerLoader: null - Image { - id: cacher - width: 64 - height: 64 - visible: true - cache: false - asynchronous: true - mipmap: true - antialiasing: true + // Setting + property bool notificationEnabled: true + property bool saveToHistory: true + property int lowUrgencyDuration: 3 + property int normalUrgencyDuration: 8 + property int criticalUrgencyDuration: 15 + property bool respectExpireTimeout: true - onStatusChanged: { - if (imageQueue.length === 0) - return - const req = imageQueue[0] + Component { + id: notificationServerComponent + NotificationServer { + keepOnReload: false + imageSupported: true + actionsSupported: true + onNotification: notification => handleNotification(notification) + } + } - if (status === Image.Ready) { - Quickshell.execDetached(["mkdir", "-p", cacheDirImagesNotifications]) - grabToImage(result => { - if (result.saveToFile(req.dest)) - updateImagePath(req.imageId, req.dest) - processNextImage() - }) - } else if (status === Image.Error) { - processNextImage() - } + Component { + id: notificationWatcherComponent + Connections { + property var targetNotification + property var targetDataId + target: targetNotification + + function onSummaryChanged() { + updateNotificationFromObject(targetDataId); } - - function processNextImage() { - imageQueue.shift() - if (imageQueue.length > 0) { - source = imageQueue[0].src - } else { - source = "" - } + function onBodyChanged() { + updateNotificationFromObject(targetDataId); + } + function onAppNameChanged() { + updateNotificationFromObject(targetDataId); + } + function onUrgencyChanged() { + updateNotificationFromObject(targetDataId); + } + function onAppIconChanged() { + updateNotificationFromObject(targetDataId); + } + function onImageChanged() { + updateNotificationFromObject(targetDataId); + } + function onActionsChanged() { + updateNotificationFromObject(targetDataId); } } } - // Notification server - NotificationServer { - keepOnReload: false - imageSupported: true - actionsSupported: true - onNotification: notification => handleNotification(notification) + function updateNotificationServer() { + if (notificationServerLoader) { + notificationServerLoader.destroy(); + notificationServerLoader = null; + } + + if (root.notificationEnabled !== false) { + notificationServerLoader = notificationServerComponent.createObject(root); + } + } + + Component.onCompleted: { + // Start the notification server + updateNotificationServer(); + } + + // Helper function to generate content-based ID for deduplication + function getContentId(summary, body, appName) { + return Checksum.sha256(JSON.stringify({ + "summary": summary || "", + "body": body || "", + "app": appName || "" + })); } // Main handler function handleNotification(notification) { - const data = createData(notification) - addToHistory(data) + const quickshellId = notification.id; + const data = createData(notification); - if (SettingsService.notifications.doNotDisturb) - return - - activeMap[data.id] = notification - notification.tracked = true - notification.closed.connect(() => removeActive(data.id)) - - // Store metadata for efficient progress calculation - const durations = [lowUrgencyDuration * 1000, normalUrgencyDuration * 1000, criticalUrgencyDuration * 1000] - - let expire = 0 - if (data.expireTimeout === 0) { - expire = -1 // Never expire - } else if (data.expireTimeout > 0) { - expire = data.expireTimeout - } else { - expire = durations[data.urgency] + // Check if we should save to history based on urgency + const saveToHistorySettings = root.saveToHistory; + if (saveToHistorySettings && !notification.transient) { + let shouldSave = true; + switch (data.urgency) { + case 0: // low + shouldSave = saveToHistorySettings.low !== false; + break; + case 1: // normal + shouldSave = saveToHistorySettings.normal !== false; + break; + case 2: // critical + shouldSave = saveToHistorySettings.critical !== false; + break; + } + if (shouldSave) { + addToHistory(data); + } + } else if (!notification.transient) { + // Default behavior: save all if settings not configured + addToHistory(data); } - notificationMetadata[data.id] = { - "timestamp": data.timestamp.getTime(), - "duration": expire, - "urgency": data.urgency + if (root.doNotDisturb) + return; + + // Check if this is a replacement notification + const existingInternalId = quickshellIdToInternalId[quickshellId]; + if (existingInternalId && activeNotifications[existingInternalId]) { + updateExistingNotification(existingInternalId, notification, data); + return; } - activeList.insert(0, data) + // Check for duplicate content + const duplicateId = findDuplicateNotification(data); + if (duplicateId) { + removeNotification(duplicateId); + } + + // Add new notification + addNewNotification(quickshellId, notification, data); + } + + function updateExistingNotification(internalId, notification, data) { + const index = findNotificationIndex(internalId); + if (index < 0) + return; + const existing = activeList.get(index); + const oldTimestamp = existing.timestamp; + const oldProgress = existing.progress; + + // Update properties (keeping original timestamp and progress) + activeList.setProperty(index, "summary", data.summary); + activeList.setProperty(index, "summaryMarkdown", data.summaryMarkdown); + activeList.setProperty(index, "body", data.body); + activeList.setProperty(index, "bodyMarkdown", data.bodyMarkdown); + activeList.setProperty(index, "appName", data.appName); + activeList.setProperty(index, "urgency", data.urgency); + activeList.setProperty(index, "expireTimeout", data.expireTimeout); + activeList.setProperty(index, "originalImage", data.originalImage); + activeList.setProperty(index, "cachedImage", data.cachedImage); + activeList.setProperty(index, "actionsJson", data.actionsJson); + activeList.setProperty(index, "timestamp", oldTimestamp); + activeList.setProperty(index, "progress", oldProgress); + + // Update stored notification object + const notifData = activeNotifications[internalId]; + notifData.notification = notification; + + // Deep copy actions to preserve them even if QML object clears list + var safeActions = []; + if (notification.actions) { + for (var i = 0; i < notification.actions.length; i++) { + safeActions.push({ + "identifier": notification.actions[i].identifier, + "actionObject": notification.actions[i] + }); + } + } + notifData.cachedActions = safeActions; + notifData.metadata.originalId = data.originalId; + + notification.tracked = true; + + function onClosed() { + userDismissNotification(internalId); + } + notification.closed.connect(onClosed); + notifData.onClosed = onClosed; + + // Update metadata + notifData.metadata.urgency = data.urgency; + notifData.metadata.duration = calculateDuration(data); + } + + function addNewNotification(quickshellId, notification, data) { + // Map IDs + quickshellIdToInternalId[quickshellId] = data.id; + + // Create watcher + const watcher = notificationWatcherComponent.createObject(root, { + "targetNotification": notification, + "targetDataId": data.id + }); + + // Deep copy actions + var safeActions = []; + if (notification.actions) { + for (var i = 0; i < notification.actions.length; i++) { + safeActions.push({ + "identifier": notification.actions[i].identifier, + "actionObject": notification.actions[i] + }); + } + } + + // Store notification data + activeNotifications[data.id] = { + "notification": notification, + "watcher": watcher, + "cachedActions": safeActions // Cache actions + , + "metadata": { + "originalId": data.originalId // Store original ID + , + "timestamp": data.timestamp.getTime(), + "duration": calculateDuration(data), + "urgency": data.urgency, + "paused": false, + "pauseTime": 0 + } + }; + + notification.tracked = true; + + function onClosed() { + userDismissNotification(data.id); + } + notification.closed.connect(onClosed); + activeNotifications[data.id].onClosed = onClosed; + + // Add to list + activeList.insert(0, data); + + // Remove overflow while (activeList.count > maxVisible) { - const last = activeList.get(activeList.count - 1) - activeMap[last.id]?.dismiss() - activeList.remove(activeList.count - 1) - delete notificationMetadata[last.id] + const last = activeList.get(activeList.count - 1); + // Overflow only removes from ACTIVE view, but keeps it for history + activeNotifications[last.id]?.notification?.dismiss(); // Visually dismiss + activeList.remove(activeList.count - 1); + // DO NOT call cleanupNotification here, we want to keep it for history actions } } + function findDuplicateNotification(data) { + const contentId = getContentId(data.summary, data.body, data.appName); + + for (var i = 0; i < activeList.count; i++) { + const existing = activeList.get(i); + const existingContentId = getContentId(existing.summary, existing.body, existing.appName); + if (existingContentId === contentId) { + return existing.id; + } + } + return null; + } + + function calculateDuration(data) { + const durations = [root.lowUrgencyDuration * 1000 || 3000, root.normalUrgencyDuration * 1000 || 8000, root.criticalUrgencyDuration * 1000 || 15000]; + + if (root.respectExpireTimeout) { + if (data.expireTimeout === 0) + return -1; // Never expire + if (data.expireTimeout > 0) + return data.expireTimeout; + } + + return durations[data.urgency]; + } + function createData(n) { - const time = new Date() + const time = new Date(); const id = Checksum.sha256(JSON.stringify({ "summary": n.summary, "body": n.body, "app": n.appName, "time": time.getTime() - })) + })); - const image = n.image || getIcon(n.appIcon) - const imageId = generateImageId(n, image) - queueImage(image, imageId) + const image = n.image || getIcon(n.appIcon); + const imageId = generateImageId(n, image); + queueImage(image, n.appName || "", n.summary || "", id); return { "id": id, - "summary": (n.summary || ""), - "body": stripTags(n.body || ""), - "appName": getAppName(n.appName), + "summary": processNotificationText(n.summary || ""), + "summaryMarkdown": processNotificationMarkdown(n.summary || ""), + "body": processNotificationText(n.body || ""), + "bodyMarkdown": processNotificationMarkdown(n.body || ""), + "appName": getAppName(n.appName || n.desktopEntry || ""), "urgency": n.urgency < 0 || n.urgency > 2 ? 1 : n.urgency, "expireTimeout": n.expireTimeout, "timestamp": time, "progress": 1.0, "originalImage": image, - "cachedImage": imageId ? (cacheDirImagesNotifications + imageId + ".png") : image, + "cachedImage": image // Start with original, update when cached + , + "originalId": n.originalId || n.id || 0 // Ensure originalId is passed through + , "actionsJson": JSON.stringify((n.actions || []).map(a => ({ - "text": a.text || "Action", + "text": (a.text || "").trim() || "Action", "identifier": a.identifier || "" }))) - } + }; } - function queueImage(path, imageId) { - if (!path || !path.startsWith("image://") || !imageId) - return - - const dest = cacheDirImagesNotifications + imageId + ".png" - - for (const req of imageQueue) { - if (req.imageId === imageId) - return - } - - imageQueue.push({ - "src": path, - "dest": dest, - "imageId": imageId - }) - - if (imageQueue.length === 1) - cacher.source = path - } - - function updateImagePath(id, path) { - updateModel(activeList, id, "cachedImage", path) - updateModel(historyList, id, "cachedImage", path) - saveHistory() - } - - function updateModel(model, id, prop, value) { - for (var i = 0; i < model.count; i++) { - if (model.get(i).id === id) { - model.setProperty(i, prop, value) - break - } - } - } - - function removeActive(id) { + function findNotificationIndex(internalId) { for (var i = 0; i < activeList.count; i++) { - if (activeList.get(i).id === id) { - activeList.remove(i) - delete activeMap[id] - delete notificationMetadata[id] - break + if (activeList.get(i).id === internalId) { + return i; + } + } + return -1; + } + + function updateNotificationFromObject(internalId) { + const notifData = activeNotifications[internalId]; + if (!notifData) + return; + const index = findNotificationIndex(internalId); + if (index < 0) + return; + const data = createData(notifData.notification); + const existing = activeList.get(index); + + // Update properties (keeping timestamp and progress) + activeList.setProperty(index, "summary", data.summary); + activeList.setProperty(index, "summaryMarkdown", data.summaryMarkdown); + activeList.setProperty(index, "body", data.body); + activeList.setProperty(index, "bodyMarkdown", data.bodyMarkdown); + activeList.setProperty(index, "appName", data.appName); + activeList.setProperty(index, "urgency", data.urgency); + activeList.setProperty(index, "expireTimeout", data.expireTimeout); + activeList.setProperty(index, "originalImage", data.originalImage); + activeList.setProperty(index, "cachedImage", data.cachedImage); + activeList.setProperty(index, "actionsJson", data.actionsJson); + + // Update metadata + notifData.metadata.urgency = data.urgency; + notifData.metadata.duration = calculateDuration(data); + } + + function removeNotification(id) { + const index = findNotificationIndex(id); + if (index >= 0) { + activeList.remove(index); + } + cleanupNotification(id); + } + + function cleanupNotification(id) { + const notifData = activeNotifications[id]; + if (notifData) { + notifData.watcher?.destroy(); + delete activeNotifications[id]; + } + + // Clean up quickshell ID mapping + for (const qsId in quickshellIdToInternalId) { + if (quickshellIdToInternalId[qsId] === id) { + delete quickshellIdToInternalId[qsId]; + break; } } } - // Optimized batch progress update + // Progress updates Timer { - interval: 50 // Reduced from 10ms to 50ms (20 updates/sec instead of 100) + interval: 50 repeat: true running: activeList.count > 0 onTriggered: updateAllProgress() } function updateAllProgress() { - const now = Date.now() - const toRemove = [] - const updates = [] // Batch updates + const now = Date.now(); + const toRemove = []; - // Collect all updates first for (var i = 0; i < activeList.count; i++) { - const notif = activeList.get(i) - const meta = notificationMetadata[notif.id] - - if (!meta || meta.duration === -1) - continue - - // Skip infinite notifications - const elapsed = now - meta.timestamp - const progress = Math.max(1.0 - (elapsed / meta.duration), 0.0) + const notif = activeList.get(i); + const notifData = activeNotifications[notif.id]; + if (!notifData) + continue; + const meta = notifData.metadata; + if (meta.duration === -1 || meta.paused) + continue; + const elapsed = now - meta.timestamp; + const progress = Math.max(1.0 - (elapsed / meta.duration), 0.0); if (progress <= 0) { - toRemove.push(notif.id) + toRemove.push(notif.id); } else if (Math.abs(notif.progress - progress) > 0.005) { - // Only update if change is significant - updates.push({ - "index": i, - "progress": progress - }) + activeList.setProperty(i, "progress", progress); } } - // Apply batch updates - for (const update of updates) { - activeList.setProperty(update.index, "progress", update.progress) - } - - // Remove expired notifications (one at a time to allow animation) if (toRemove.length > 0) { - animateAndRemove(toRemove[0]) + animateAndRemove(toRemove[0]); + } + } + + // Image handling + function queueImage(path, appName, summary, notificationId) { + if (!path || !notificationId) + return; + + // Cache image:// URIs and temporary file paths (e.g. /tmp/ from Chromium) + const filePath = path.startsWith("file://") ? path.substring(7) : path; + const isImageUri = path.startsWith("image://"); + const isTempFile = path.startsWith("/") && filePath.startsWith("/tmp/"); + + if (!isImageUri && !isTempFile) + return; + + ImageCacheService.getNotificationIcon(path, appName, summary, function (cachedPath, success) { + if (success && cachedPath) { + updateImagePath(notificationId, "file://" + cachedPath); + } + }); + } + + function updateImagePath(notificationId, path) { + updateModel(activeList, notificationId, "cachedImage", path); + updateModel(historyList, notificationId, "cachedImage", path); + saveHistory(); + } + + function updateModel(model, notificationId, prop, value) { + for (var i = 0; i < model.count; i++) { + if (model.get(i).id === notificationId) { + model.setProperty(i, prop, value); + break; + } } } // History management function addToHistory(data) { - historyList.insert(0, data) + historyList.insert(0, data); while (historyList.count > maxHistory) { - const old = historyList.get(historyList.count - 1) - if (old.cachedImage && !old.cachedImage.startsWith("image://")) { - Quickshell.execDetached(["rm", "-f", old.cachedImage]) + const old = historyList.get(historyList.count - 1); + // Only delete cached images that are in our cache directory + const cachedPath = old.cachedImage ? old.cachedImage.replace(/^file:\/\//, "") : ""; + if (cachedPath && cachedPath.startsWith(ImageCacheService.notificationsDir)) { + Quickshell.execDetached(["rm", "-f", cachedPath]); } - historyList.remove(historyList.count - 1) + historyList.remove(historyList.count - 1); } - saveHistory() + saveHistory(); } - // Persistence + // Persistence - History FileView { id: historyFileView path: historyFile @@ -271,7 +487,7 @@ Singleton { onLoaded: loadHistory() onLoadFailed: error => { if (error === 2) - writeAdapter() + writeAdapter(); } JsonAdapter { @@ -287,188 +503,473 @@ Singleton { } function saveHistory() { - saveTimer.restart() + saveTimer.restart(); } function performSaveHistory() { try { - const items = [] + const items = []; for (var i = 0; i < historyList.count; i++) { - const n = historyList.get(i) - const copy = Object.assign({}, n) - copy.timestamp = n.timestamp.getTime() - items.push(copy) + const n = historyList.get(i); + const copy = Object.assign({}, n); + copy.timestamp = n.timestamp.getTime(); + items.push(copy); } - adapter.notifications = items - historyFileView.writeAdapter() + adapter.notifications = items; + historyFileView.writeAdapter(); } catch (e) { - Logger.error("Notifications", "Save history failed:", e) + Logger.e("Notifications", "Save history failed:", e); } } function loadHistory() { try { - historyList.clear() + historyList.clear(); for (const item of adapter.notifications || []) { - const time = new Date(item.timestamp) + const time = new Date(item.timestamp); - let cachedImage = item.cachedImage || "" - if (item.originalImage && item.originalImage.startsWith("image://") && !cachedImage) { - const imageId = generateImageId(item, item.originalImage) - if (imageId) { - cachedImage = cacheDirImagesNotifications + imageId + ".png" - } + // Use the cached image if it exists and starts with file://, otherwise use originalImage + let cachedImage = item.cachedImage || ""; + if (!cachedImage || (!cachedImage.startsWith("file://") && !cachedImage.startsWith("/"))) { + cachedImage = item.originalImage || ""; } historyList.append({ "id": item.id || "", "summary": item.summary || "", + "summaryMarkdown": processNotificationMarkdown(item.summary || ""), "body": item.body || "", + "bodyMarkdown": processNotificationMarkdown(item.body || ""), "appName": item.appName || "", "urgency": item.urgency < 0 || item.urgency > 2 ? 1 : item.urgency, "timestamp": time, "originalImage": item.originalImage || "", - "cachedImage": cachedImage - }) + "cachedImage": cachedImage, + "actionsJson": item.actionsJson || "[]", + "originalId": item.originalId || 0 + }); } } catch (e) { - Logger.error("Notifications", "Load failed:", e) + Logger.e("Notifications", "Load failed:", e); } } + function updateLastSeenTs() { + ShellState.notificationsState = { + lastSeenTs: Time.timestamp * 1000, + doNotDisturb: root.doNotDisturb + }; + } + + function toggleDoNotDisturb() { + ShellState.notificationsState = { + lastSeenTs: root.lastSeenTs, + doNotDisturb: !root.doNotDisturb + }; + } + + // Utility functions function getAppName(name) { if (!name || name.trim() === "") - return "Unknown" - - name = name.trim() + return "Unknown"; + name = name.trim(); if (name.includes(".") && (name.startsWith("com.") || name.startsWith("org.") || name.startsWith("io.") || name.startsWith("net."))) { - const parts = name.split(".") - let appPart = parts[parts.length - 1] + const parts = name.split("."); + let appPart = parts[parts.length - 1]; if (!appPart || appPart === "app" || appPart === "desktop") { - appPart = parts[parts.length - 2] || parts[0] + appPart = parts[parts.length - 2] || parts[0]; } - if (appPart) { - name = appPart - } + if (appPart) + name = appPart; } if (name.includes(".")) { - const parts = name.split(".") - let displayName = parts[parts.length - 1] + const parts = name.split("."); + let displayName = parts[parts.length - 1]; if (!displayName || /^\d+$/.test(displayName)) { - displayName = parts[parts.length - 2] || parts[0] + displayName = parts[parts.length - 2] || parts[0]; } if (displayName) { - displayName = displayName.charAt(0).toUpperCase() + displayName.slice(1) - displayName = displayName.replace(/([a-z])([A-Z])/g, '$1 $2') - displayName = displayName.replace(/app$/i, '').trim() - displayName = displayName.replace(/desktop$/i, '').trim() - displayName = displayName.replace(/flatpak$/i, '').trim() + displayName = displayName.charAt(0).toUpperCase() + displayName.slice(1); + displayName = displayName.replace(/([a-z])([A-Z])/g, '$1 $2'); + displayName = displayName.replace(/app$/i, '').trim(); + displayName = displayName.replace(/desktop$/i, '').trim(); + displayName = displayName.replace(/flatpak$/i, '').trim(); if (!displayName) { - displayName = parts[parts.length - 1].charAt(0).toUpperCase() + parts[parts.length - 1].slice(1) + displayName = parts[parts.length - 1].charAt(0).toUpperCase() + parts[parts.length - 1].slice(1); } } - return displayName || name + return displayName || name; } - let displayName = name.charAt(0).toUpperCase() + name.slice(1) - displayName = displayName.replace(/([a-z])([A-Z])/g, '$1 $2') - displayName = displayName.replace(/app$/i, '').trim() - displayName = displayName.replace(/desktop$/i, '').trim() + let displayName = name.charAt(0).toUpperCase() + name.slice(1); + displayName = displayName.replace(/([a-z])([A-Z])/g, '$1 $2'); + displayName = displayName.replace(/app$/i, '').trim(); + displayName = displayName.replace(/desktop$/i, '').trim(); - return displayName || name + return displayName || name; } function getIcon(icon) { if (!icon) - return "" + return ""; if (icon.startsWith("/") || icon.startsWith("file://")) - return icon - return ThemeIcons.iconFromName(icon) + return icon; + return ThemeIcons.iconFromName(icon); } - function stripTags(text) { - return text.replace(/<[^>]*>?/gm, '') + function escapeHtml(text) { + if (!text) + return ""; + return text.replace(/&/g, "&").replace(//g, ">"); + } + + function sanitizeMarkdownUrl(url) { + if (!url) + return ""; + const trimmed = url.trim(); + if (trimmed === "") + return ""; + const lower = trimmed.toLowerCase(); + if (lower.startsWith("http://") || lower.startsWith("https://") || lower.startsWith("mailto:")) { + return encodeURI(trimmed); + } + return ""; + } + + function sanitizeMarkdown(text) { + if (!text) + return ""; + + let input = String(text); + + // Strip images entirely + input = input.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function (match, alt) { + return alt ? alt : ""; + }); + + // Extract links into placeholders + const links = []; + input = input.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function (match, label, urlAndTitle) { + const urlPart = (urlAndTitle || "").trim().split(/\s+/)[0] || ""; + const safeUrl = sanitizeMarkdownUrl(urlPart); + const safeLabel = escapeHtml(label); + if (!safeUrl) + return safeLabel; + const token = "__MDLINK_" + links.length + "__"; + links.push({ + "label": safeLabel, + "url": safeUrl + }); + return token; + }); + + // Escape any remaining HTML + input = escapeHtml(input); + + // Restore sanitized links + for (let i = 0; i < links.length; i++) { + const token = "__MDLINK_" + i + "__"; + const link = links[i]; + input = input.split(token).join("[" + link.label + "](" + link.url + ")"); + } + + return input; + } + + function processNotificationText(text) { + if (!text) + return ""; + + // Split by tags to process segments separately + const parts = text.split(/(<[^>]+>)/); + let result = ""; + const allowedTags = ["b", "i", "u", "a", "br"]; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + if (part.startsWith("<") && part.endsWith(">")) { + const content = part.substring(1, part.length - 1); + const firstWord = content.split(/[\s/]/).filter(s => s.length > 0)[0]?.toLowerCase(); + + if (allowedTags.includes(firstWord)) { + // Preserve valid HTML tag + result += part; + } else { + // Unknown tag: drop tag without leaking attributes + result += ""; + } + } else { + // Normal text: escape everything + result += escapeHtml(part); + } + } + return result; + } + + function processNotificationMarkdown(text) { + return sanitizeMarkdown(text); } function generateImageId(notification, image) { if (image && image.startsWith("image://")) { if (image.startsWith("image://qsimage/")) { - const key = (notification.appName || "") + "|" + (notification.summary || "") - return Checksum.sha256(key) + const key = (notification.appName || "") + "|" + (notification.summary || ""); + return Checksum.sha256(key); } - return Checksum.sha256(image) + return Checksum.sha256(image); + } + return ""; + } + + function pauseTimeout(id) { + const notifData = activeNotifications[id]; + if (notifData && !notifData.metadata.paused) { + notifData.metadata.paused = true; + notifData.metadata.pauseTime = Date.now(); + } + } + + function resumeTimeout(id) { + const notifData = activeNotifications[id]; + if (notifData && notifData.metadata.paused) { + notifData.metadata.timestamp += Date.now() - notifData.metadata.pauseTime; + notifData.metadata.paused = false; } - return "" } // Public API function dismissActiveNotification(id) { - activeMap[id]?.dismiss() - removeActive(id) + userDismissNotification(id); + } + + // User dismissed from active view (e.g. clicked close, or swipe) + // This behaves like "overflow" - removes from active list but KEEPS data for history + function userDismissNotification(id) { + const index = findNotificationIndex(id); + if (index >= 0) { + activeList.remove(index); + } + } + + function dismissOldestActive() { + if (activeList.count > 0) { + const lastNotif = activeList.get(activeList.count - 1); + dismissActiveNotification(lastNotif.id); + } } function dismissAllActive() { - Object.values(activeMap).forEach(n => n.dismiss()) - activeList.clear() - activeMap = {} - notificationMetadata = {} + for (const id in activeNotifications) { + activeNotifications[id].notification?.dismiss(); + activeNotifications[id].watcher?.destroy(); + } + activeList.clear(); + activeNotifications = {}; + quickshellIdToInternalId = {}; + } + + function invokeActionAndSuppressClose(id, actionId) { + const notifData = activeNotifications[id]; + if (notifData && notifData.notification && notifData.onClosed) { + try { + notifData.notification.closed.disconnect(notifData.onClosed); + } catch (e) {} + } + + return invokeAction(id, actionId); } function invokeAction(id, actionId) { - const n = activeMap[id] - if (!n?.actions) - return false + let invoked = false; + const notifData = activeNotifications[id]; - for (const action of n.actions) { - if (action.identifier === actionId && action.invoke) { - action.invoke() - return true + if (notifData && notifData.notification) { + const actionsToUse = (notifData.notification.actions && notifData.notification.actions.length > 0) ? notifData.notification.actions : (notifData.cachedActions || []); + + if (actionsToUse && actionsToUse.length > 0) { + for (const item of actionsToUse) { + const itemId = item.identifier; + const actionObj = item.actionObject ? item.actionObject : item; + + if (itemId === actionId) { + if (actionObj.invoke) { + try { + actionObj.invoke(); + invoked = true; + } catch (e) { + Logger.w("NotificationService", "invoke() failed, trying manual fallback: " + e); + if (manualInvoke(notifData.metadata.originalId, itemId)) { + invoked = true; + } + } + } else { + if (manualInvoke(notifData.metadata.originalId, itemId)) { + invoked = true; + } + } + break; + } + } + } + + if (!invoked && notifData.metadata.originalId) { + Logger.w("NotificationService", "Action objects exhausted, trying manual invoke for id=" + id + " action=" + actionId); + invoked = manualInvoke(notifData.metadata.originalId, actionId); + } + } else if (!notifData) { + Logger.w("NotificationService", "No active notification data for id=" + id + ", searching history for manual invoke"); + for (var i = 0; i < historyList.count; i++) { + if (historyList.get(i).id === id) { + const histEntry = historyList.get(i); + if (histEntry.originalId) { + invoked = manualInvoke(histEntry.originalId, actionId); + } + break; + } } } - return false + + if (!invoked) { + Logger.w("NotificationService", "Failed to invoke action '" + actionId + "' for notification " + id); + return false; + } + + // Clear actions after use + updateModel(activeList, id, "actionsJson", "[]"); + updateModel(historyList, id, "actionsJson", "[]"); + saveHistory(); + + return true; + } + + function manualInvoke(originalId, actionId) { + if (!originalId) { + return false; + } + + try { + // Construct the signal emission using dbus-send + // dbus-send --session --type=signal /org/freedesktop/Notifications org.freedesktop.Notifications.ActionInvoked uint32:ID string:"KEY" + const args = ["dbus-send", "--session", "--type=signal", "/org/freedesktop/Notifications", "org.freedesktop.Notifications.ActionInvoked", "uint32:" + originalId, "string:" + actionId]; + + Quickshell.execDetached(args); + return true; + } catch (e) { + Logger.e("NotificationService", "Manual invoke failed: " + e); + return false; + } + } + + function focusSenderWindow(appName) { + if (!appName || appName === "" || appName === "Unknown") + return false; + + const normalizedName = appName.toLowerCase().replace(/\s+/g, ""); + + for (var i = 0; i < Niri.windows.count; i++) { + const win = Niri.windows.get(i); + const winAppId = (win.appId || "").toLowerCase(); + + const segments = winAppId.split("."); + const lastSegment = segments[segments.length - 1] || ""; + + if (winAppId === normalizedName || lastSegment === normalizedName || winAppId.includes(normalizedName) || normalizedName.includes(lastSegment)) { + Niri.focusWindow(win); + return true; + } + } + + Logger.d("NotificationService", "No window found for app: " + appName); + return false; } function removeFromHistory(notificationId) { for (var i = 0; i < historyList.count; i++) { - const notif = historyList.get(i) + const notif = historyList.get(i); if (notif.id === notificationId) { - if (notif.cachedImage && !notif.cachedImage.startsWith("image://")) { - Quickshell.execDetached(["rm", "-f", notif.cachedImage]) + // Only delete cached images that are in our cache directory + const cachedPath = notif.cachedImage ? notif.cachedImage.replace(/^file:\/\//, "") : ""; + if (cachedPath && cachedPath.startsWith(ImageCacheService.notificationsDir)) { + Quickshell.execDetached(["rm", "-f", cachedPath]); } - historyList.remove(i) - saveHistory() - return true + historyList.remove(i); + saveHistory(); + return true; } } - return false + return false; + } + + function removeOldestHistory() { + if (historyList.count > 0) { + const oldest = historyList.get(historyList.count - 1); + // Only delete cached images that are in our cache directory + const cachedPath = oldest.cachedImage ? oldest.cachedImage.replace(/^file:\/\//, "") : ""; + if (cachedPath && cachedPath.startsWith(ImageCacheService.notificationsDir)) { + Quickshell.execDetached(["rm", "-f", cachedPath]); + } + historyList.remove(historyList.count - 1); + saveHistory(); + return true; + } + return false; } function clearHistory() { try { - Quickshell.execDetached(["sh", "-c", `rm -rf "${cacheDirImagesNotifications}"*`]) + Quickshell.execDetached(["sh", "-c", `rm -rf "${ImageCacheService.notificationsDir}"*`]); } catch (e) { - Logger.error("Notifications", "Failed to clear cache directory:", e) + Logger.e("Notifications", "Failed to clear cache directory:", e); } - historyList.clear() - saveHistory() + historyList.clear(); + saveHistory(); } - // Signals & connections + function getHistorySnapshot() { + const items = []; + for (var i = 0; i < historyList.count; i++) { + const entry = historyList.get(i); + items.push({ + "id": entry.id, + "summary": entry.summary, + "body": entry.body, + "appName": entry.appName, + "urgency": entry.urgency, + "timestamp": entry.timestamp instanceof Date ? entry.timestamp.getTime() : entry.timestamp, + "originalImage": entry.originalImage, + "cachedImage": entry.cachedImage + }); + } + return items; + } + + // Signals signal animateAndRemove(string notificationId) - Connections { - target: SettingsService.notifications - function onDoNotDisturbChanged() { - const enabled = SettingsService.notifications.doNotDisturb + // Media toast functionality + property string previousMediaTitle: "" + property string previousMediaArtist: "" + property bool previousMediaIsPlaying: false + property bool mediaToastInitialized: false + + Timer { + id: mediaToastInitTimer + interval: 3000 // Wait 3 seconds after startup to avoid initial toast + running: true + onTriggered: { + root.mediaToastInitialized = true; + root.previousMediaTitle = MediaService.trackTitle; + root.previousMediaArtist = MediaService.trackArtist; + root.previousMediaIsPlaying = MediaService.isPlaying; } } } diff --git a/config/quickshell/.config/quickshell/Services/NukeKded6.qml b/config/quickshell/.config/quickshell/Services/NukeKded6.qml index 7669e3e..9e502f4 100644 --- a/config/quickshell/.config/quickshell/Services/NukeKded6.qml +++ b/config/quickshell/.config/quickshell/Services/NukeKded6.qml @@ -1,7 +1,6 @@ import QtQuick import Quickshell import Quickshell.Io -import qs.Utils pragma Singleton Singleton { @@ -11,11 +10,8 @@ Singleton { id: process running: true - command: ["sh", "-c", "which kquitapp6 && kquitapp6 kded6"] + command: ["sh", "-c", "pgrep -x kded6 && { { type kquitapp6 && kquitapp6 kded6 || killall -9 kded6; }; sleep 0.5; } >/dev/null 2>&1"] onExited: (code, status) => { - if (code !== 0) - Logger.warn("NukeKded6", `Failed to kill kded6: ${code}`); - done = true; } } diff --git a/config/quickshell/.config/quickshell/Services/PanelService.qml b/config/quickshell/.config/quickshell/Services/PanelService.qml index 0506d49..b811ee1 100644 --- a/config/quickshell/.config/quickshell/Services/PanelService.qml +++ b/config/quickshell/.config/quickshell/Services/PanelService.qml @@ -1,67 +1,424 @@ pragma Singleton +import QtQuick import Quickshell +import qs.Utils +import qs.Services Singleton { id: root - // A ref. to the lockScreen, so it's accessible from anywhere - // This is not a panel... + // A ref. to the lockScreen, so it's accessible from anywhere. property var lockScreen: null // Panels property var registeredPanels: ({}) property var openedPanel: null + property var closingPanel: null + property bool closedImmediately: false + + // Overlay launcher state (separate from normal panels) + property bool overlayLauncherOpen: false + property var overlayLauncherScreen: null + property var overlayLauncherCore: null // Reference to LauncherCore when overlay is active + // Brief window after panel opens where Exclusive keyboard is allowed on Hyprland + // This allows text inputs to receive focus, then switches to OnDemand for click-to-close + property bool isInitializingKeyboard: false + + // Global state for keybind recording components to block global shortcuts + property bool isKeybindRecording: false + + property var restrictedMonitors: [] + property bool allowPanelsOnScreenWithoutBar: true + property bool overviewLayer: false + signal willOpen + signal didClose - // Currently opened popups, can have more than one. - // ex: when opening an NIconPicker from a widget setting. - property var openedPopups: [] - property bool hasOpenedPopup: false - signal popupChanged + // Background slot assignments for dynamic panel background rendering + // Slot 0: currently opening/open panel, Slot 1: closing panel + property var backgroundSlotAssignments: [null, null] + signal slotAssignmentChanged(int slotIndex, var panel) - // Register this panel - function registerPanel(panel) { - registeredPanels[panel.objectName] = panel + function assignToSlot(slotIndex, panel) { + if (backgroundSlotAssignments[slotIndex] !== panel) { + var newAssignments = backgroundSlotAssignments.slice(); + newAssignments[slotIndex] = panel; + backgroundSlotAssignments = newAssignments; + slotAssignmentChanged(slotIndex, panel); + } } - // Returns a panel - function getPanel(name) { - return registeredPanels[name] || null + // Popup menu windows (one per screen) - used for both tray menus and context menus + property var popupMenuWindows: ({}) + signal popupMenuWindowRegistered(var screen) + + // Register this panel (called after panel is loaded) + function registerPanel(panel) { + registeredPanels[panel.objectName] = panel; + Logger.d("PanelService", "Registered panel:", panel.objectName); + } + + // Register popup menu window for a screen + function registerPopupMenuWindow(screen, window) { + if (!screen || !window) + return; + var key = screen.name; + popupMenuWindows[key] = window; + Logger.d("PanelService", "Registered popup menu window for screen:", key); + popupMenuWindowRegistered(screen); + } + + // Unregister popup menu window for a screen (called on destruction) + function unregisterPopupMenuWindow(screen) { + if (!screen) + return; + var key = screen.name; + delete popupMenuWindows[key]; + Logger.d("PanelService", "Unregistered popup menu window for screen:", key); + } + + // Get popup menu window for a screen + function getPopupMenuWindow(screen) { + if (!screen) + return null; + return popupMenuWindows[screen.name] || null; + } + + // Show a context menu with proper handling for all compositors + // Optional targetItem: if provided, menu will be horizontally centered on this item instead of anchorItem + function showContextMenu(contextMenu, anchorItem, screen, targetItem) { + if (!contextMenu || !anchorItem) + return; + + // Close any previously opened context menu first + closeContextMenu(screen); + + var popupMenuWindow = getPopupMenuWindow(screen); + if (popupMenuWindow) { + popupMenuWindow.showContextMenu(contextMenu); + contextMenu.openAtItem(anchorItem, screen, targetItem); + } + } + + // Close any open context menu or popup menu window + function closeContextMenu(screen) { + var popupMenuWindow = getPopupMenuWindow(screen); + if (popupMenuWindow && popupMenuWindow.visible) { + popupMenuWindow.close(); + } + } + + // Show a tray menu with proper handling for all compositors + // Returns true if menu was shown successfully + function showTrayMenu(screen, trayItem, trayMenu, anchorItem, menuX, menuY, widgetSection, widgetIndex) { + if (!trayItem || !trayMenu || !anchorItem) + return false; + + // Close any previously opened menu first + closeContextMenu(screen); + + trayMenu.trayItem = trayItem; + trayMenu.widgetSection = widgetSection; + trayMenu.widgetIndex = widgetIndex; + + var popupMenuWindow = getPopupMenuWindow(screen); + if (popupMenuWindow) { + popupMenuWindow.open(); + trayMenu.showAt(anchorItem, menuX, menuY); + } else { + return false; + } + return true; + } + + // Close tray menu + function closeTrayMenu(screen) { + var popupMenuWindow = getPopupMenuWindow(screen); + if (popupMenuWindow) { + // This closes both the window and calls hideMenu on the tray menu + popupMenuWindow.close(); + } + } + + // Find a fallback screen, prioritizing 0x0 position (primary) + function findFallbackScreen() { + let primaryCandidate = null; + let firstScreen = null; + + for (let i = 0; i < Quickshell.screens.length; i++) { + const s = Quickshell.screens[i]; + if (s.x === 0 && s.y === 0) { + primaryCandidate = s; + } + if (!firstScreen) { + firstScreen = s; + } + } + + return primaryCandidate || firstScreen || null; + } + + // Returns a panel (loads it on-demand if not yet loaded) + // By default, if panel not found on screen, tries other screens (favoring 0x0) + // Pass fallback=false to disable this behavior + function getPanel(name, screen, fallback = true) { + if (!screen) { + Logger.d("PanelService", "missing screen for getPanel:", name); + // If no screen specified, return the first matching panel + for (var key in registeredPanels) { + if (key.startsWith(name + "-")) { + return registeredPanels[key]; + } + } + return null; + } + + var panelKey = `${name}-${screen.name}`; + + // Check if panel is already loaded + if (registeredPanels[panelKey]) { + return registeredPanels[panelKey]; + } + + // If fallback enabled, try to find panel on another screen + if (fallback) { + // First try the primary screen (0x0) + var fallbackScreen = findFallbackScreen(); + if (fallbackScreen && fallbackScreen.name !== screen.name) { + var fallbackKey = `${name}-${fallbackScreen.name}`; + if (registeredPanels[fallbackKey]) { + Logger.d("PanelService", "Panel fallback from", screen.name, "to", fallbackScreen.name); + return registeredPanels[fallbackKey]; + } + } + + // Try any other screen + for (var key in registeredPanels) { + if (key.startsWith(name + "-")) { + Logger.d("PanelService", "Panel fallback to first available:", key); + return registeredPanels[key]; + } + } + } + + Logger.w("PanelService", "Panel not found:", panelKey); + return null; } // Check if a panel exists function hasPanel(name) { - return name in registeredPanels + return name in registeredPanels; + } + + // Check if panels can be shown on a given screen (has bar enabled or allowPanelsOnScreenWithoutBar) + function canShowPanelsOnScreen(screen) { + const name = screen?.name || ""; + const monitors = root.restrictedMonitors || []; + const allowPanelsOnScreenWithoutBar = root.allowPanelsOnScreenWithoutBar; + return allowPanelsOnScreenWithoutBar || monitors.length === 0 || monitors.includes(name); + } + + // Find a screen that can show panels + function findScreenForPanels() { + for (let i = 0; i < Quickshell.screens.length; i++) { + if (canShowPanelsOnScreen(Quickshell.screens[i])) { + return Quickshell.screens[i]; + } + } + return null; + } + + // Timer to switch from Exclusive to OnDemand keyboard focus on Hyprland + Timer { + id: keyboardInitTimer + interval: 100 + repeat: false + onTriggered: { + root.isInitializingKeyboard = false; + } } // Helper to keep only one panel open at any time function willOpenPanel(panel) { - if (openedPanel && openedPanel !== panel) { - openedPanel.close() + // Close overlay launcher if open + if (overlayLauncherOpen) { + overlayLauncherOpen = false; + overlayLauncherScreen = null; + } + + if (openedPanel && openedPanel !== panel) { + // Move current panel to closing slot before closing it + closingPanel = openedPanel; + assignToSlot(1, closingPanel); + openedPanel.close(); + } + + // Assign new panel to open slot + openedPanel = panel; + assignToSlot(0, panel); + + // Start keyboard initialization period (for Hyprland workaround) + if (panel && panel.exclusiveKeyboard) { + isInitializingKeyboard = true; + keyboardInitTimer.restart(); } - openedPanel = panel // emit signal - willOpen() + willOpen(); + } + + // Open launcher panel (handles both normal and overlay mode) + function openLauncher(screen) { + if (root.overviewLayer) { + // Close any regular panel first + if (openedPanel) { + closingPanel = openedPanel; + assignToSlot(1, closingPanel); + openedPanel.close(); + openedPanel = null; + } + // Open overlay launcher + overlayLauncherOpen = true; + overlayLauncherScreen = screen; + willOpen(); + } else { + // Normal mode - use the SmartPanel + var panel = getPanel("launcherPanel", screen); + if (panel) + panel.open(); + } + } + + // Toggle launcher panel + function toggleLauncher(screen) { + if (root.overviewLayer) { + if (overlayLauncherOpen && overlayLauncherScreen === screen) { + closeOverlayLauncher(); + } else { + openLauncher(screen); + } + } else { + var panel = getPanel("launcherPanel", screen); + if (panel) + panel.toggle(); + } + } + + // Close overlay launcher + function closeOverlayLauncher() { + if (overlayLauncherOpen) { + overlayLauncherOpen = false; + overlayLauncherScreen = null; + didClose(); + } + } + + // Close overlay launcher immediately (for app launches) + function closeOverlayLauncherImmediately() { + if (overlayLauncherOpen) { + closedImmediately = true; + overlayLauncherOpen = false; + overlayLauncherScreen = null; + didClose(); + } + } + + // ==================== Unified Launcher API ==================== + // These methods work for both normal (SmartPanel) and overlay modes + + function isLauncherOpen(screen) { + if (root.overviewLayer) { + return overlayLauncherOpen && overlayLauncherScreen === screen; + } else { + var panel = getPanel("launcherPanel", screen); + return panel ? panel.isPanelOpen : false; + } + } + + function getLauncherSearchText(screen) { + if (root.overviewLayer) { + return overlayLauncherCore ? overlayLauncherCore.searchText : ""; + } else { + var panel = getPanel("launcherPanel", screen); + return panel ? panel.searchText : ""; + } + } + + function setLauncherSearchText(screen, text) { + if (root.overviewLayer) { + if (overlayLauncherCore) + overlayLauncherCore.setSearchText(text); + } else { + var panel = getPanel("launcherPanel", screen); + if (panel) + panel.setSearchText(text); + } + } + + function openLauncherWithSearch(screen, searchText) { + if (root.overviewLayer) { + openLauncher(screen); + // Set search text after core is ready + Qt.callLater(() => { + if (overlayLauncherCore) + overlayLauncherCore.setSearchText(searchText); + }); + } else { + var panel = getPanel("launcherPanel", screen); + if (panel) { + panel.open(); + panel.setSearchText(searchText); + } + } + } + + function closeLauncher(screen) { + if (root.overviewLayer) { + closeOverlayLauncher(); + } else { + var panel = getPanel("launcherPanel", screen); + if (panel) + panel.close(); + } + } + + // Close any open panel (for general use) + function closePanel() { + if (overlayLauncherOpen) { + closeOverlayLauncher(); + } else if (openedPanel && openedPanel.close) { + openedPanel.close(); + } } function closedPanel(panel) { if (openedPanel && openedPanel === panel) { - openedPanel = null + openedPanel = null; + assignToSlot(0, null); + } + + if (closingPanel && closingPanel === panel) { + closingPanel = null; + assignToSlot(1, null); + } + + // Reset keyboard init state + isInitializingKeyboard = false; + keyboardInitTimer.stop(); + + // emit signal + didClose(); + } + + // Close panels when compositor overview opens (if setting is enabled) + Connections { + target: Niri + + function onOverviewActiveChanged() { + if (Niri.overviewActive && root.openedPanel) { + root.openedPanel.close(); + } } } - - // Popups - function willOpenPopup(popup) { - openedPopups.push(popup) - hasOpenedPopup = (openedPopups.length !== 0) - popupChanged() - } - - function willClosePopup(popup) { - openedPopups = openedPopups.filter(p => p !== popup) - hasOpenedPopup = (openedPopups.length !== 0) - popupChanged() - } } diff --git a/config/quickshell/.config/quickshell/Services/PowerProfileService.qml b/config/quickshell/.config/quickshell/Services/PowerService.qml similarity index 77% rename from config/quickshell/.config/quickshell/Services/PowerProfileService.qml rename to config/quickshell/.config/quickshell/Services/PowerService.qml index 422a1d6..ae58b5d 100644 --- a/config/quickshell/.config/quickshell/Services/PowerProfileService.qml +++ b/config/quickshell/.config/quickshell/Services/PowerService.qml @@ -53,7 +53,7 @@ Singleton { try { powerProfiles.profile = p; } catch (e) { - Logger.error("PowerProfileService", "Failed to set profile:", e); + Logger.e("PowerProfileService", "Failed to set profile:", e); } } @@ -70,16 +70,35 @@ Singleton { setProfile(PowerProfile.Balanced); } + function shutdown() { + Quickshell.execDetached(["systemctl", "poweroff"]); + } + + function lock() { + Quickshell.execDetached(["hyprlock"]); + } + + function hibernate() { + Quickshell.execDetached(["systemctl", "hibernate"]); + } + + function logout() { + Quickshell.execDetached(["niri", "msg", "action", "quit"]); + } + + function suspend() { + Quickshell.execDetached(["systemctl", "suspend"]); + } + + function reboot() { + Quickshell.execDetached(["systemctl", "reboot"]); + } + Connections { function onProfileChanged() { root.profile = powerProfiles.profile; // Only show toast if we have a valid profile name (not "Unknown") const profileName = root.getName(); - if (profileName !== "Unknown") - ToastService.showNotice(I18n.tr("toast.power-profile.changed"), I18n.tr("toast.power-profile.profile-name", { - "profile": profileName - })); - } target: powerProfiles diff --git a/config/quickshell/.config/quickshell/Services/RecordService.qml b/config/quickshell/.config/quickshell/Services/RecordService.qml index 7f9ea03..d54d09f 100644 --- a/config/quickshell/.config/quickshell/Services/RecordService.qml +++ b/config/quickshell/.config/quickshell/Services/RecordService.qml @@ -1,12 +1,13 @@ import QtQuick import Quickshell import Quickshell.Io +import qs.Constants import qs.Services import qs.Utils pragma Singleton Singleton { - readonly property string recordingDir: CacheService.recordingDir + readonly property string recordingDir: Paths.recordingDir property bool isRecording: false property bool isStopping: false readonly property string codec: "libx264" @@ -14,10 +15,7 @@ Singleton { readonly property string pixelFormat: "yuv420p" property string recordingDisplay: "" readonly property int framerate: 60 - readonly property var codecParams: Object.freeze([ - "preset=ultrafast", "crf=15", "tune=zerolatency", - "color_range=tv" - ]) + readonly property var codecParams: Object.freeze(["preset=ultrafast", "crf=15", "tune=zerolatency", "color_range=tv"]) readonly property var filterArgs: "" function getFilename() { @@ -36,12 +34,7 @@ Singleton { } function getVideoSource(callback) { - if (niriFocusedOutputProcess.running) { - Logger.warn("RecordService", "Already fetching focused output, returning null."); - callback(null); - } - niriFocusedOutputProcess.onGetName = callback; - niriFocusedOutputProcess.running = true; + return Niri.focusedOutput || null; } function startOrStop() { @@ -53,11 +46,11 @@ Singleton { function stop() { if (!isRecording) { - Logger.warn("RecordService", "Not currently recording, cannot stop."); + Logger.w("RecordService", "Not currently recording, cannot stop."); return ; } if (isStopping) { - Logger.warn("RecordService", "Already stopping, please wait."); + Logger.w("RecordService", "Already stopping, please wait."); return ; } isStopping = true; @@ -66,41 +59,44 @@ Singleton { function start() { if (isRecording || isStopping) { - Logger.warn("RecordService", "Already recording, cannot start."); + Logger.w("RecordService", "Already recording, cannot start."); return ; } isRecording = true; - getVideoSource((source) => { - if (!source) { - SendNotification.show("Recording failed", "Could not determine which display to record from."); - return ; - } - recordingDisplay = source; - const audioSink = getAudioSink(); - if (!audioSink) { - SendNotification.show("Recording failed", "No audio sink available to record from."); - return ; - } - recordProcess.filePath = recordingDir + getFilename(); - recordProcess.command = ["wf-recorder", "--audio=" + audioSink, "-o", source, "--codec", codec, "--pixel-format", pixelFormat, "--framerate", framerate.toString(), "-f", recordProcess.filePath]; - for (const param of codecParams) { - recordProcess.command.push("-p"); - recordProcess.command.push(param); - } - if (filterArgs !== "") { - recordProcess.command.push("-F"); - recordProcess.command.push(filterArgs); - } - Logger.log("RecordService", "Starting recording with command: " + recordProcess.command.join(" ")); - recordProcess.onErrorExit = function() { - SendNotification.show("Recording failed", "An error occurred while trying to record the screen."); - }; - recordProcess.onNormalExit = function() { - SendNotification.show("Recording stopped", recordProcess.filePath); - }; - recordProcess.running = true; - SendNotification.show("Recording started", "Recording to " + recordProcess.filePath); - }); + const source = getVideoSource(); + if (!source) { + SendNotification.show("Recording failed", "Could not determine which display to record from."); + Logger.e("RecordService", "No recording source available."); + return ; + } + recordingDisplay = source; + const audioSink = getAudioSink(); + if (!audioSink) { + SendNotification.show("Recording failed", "No audio sink available to record from."); + Logger.e("RecordService", "No audio sink available."); + return ; + } + recordProcess.filePath = recordingDir + getFilename(); + recordProcess.command = ["wf-recorder", "--audio=" + audioSink, "-o", source, "--codec", codec, "--pixel-format", pixelFormat, "--framerate", framerate.toString(), "-f", recordProcess.filePath]; + for (const param of codecParams) { + recordProcess.command.push("-p"); + recordProcess.command.push(param); + } + if (filterArgs !== "") { + recordProcess.command.push("-F"); + recordProcess.command.push(filterArgs); + } + Logger.i("RecordService", "Starting recording with command: " + recordProcess.command.join(" ")); + recordProcess.onErrorExit = function() { + Logger.e("RecordService", "Recording process exited with an error."); + SendNotification.show("Recording failed", "An error occurred while trying to record the screen."); + }; + recordProcess.onNormalExit = function() { + Logger.i("RecordService", "Recording stopped, file saved to: " + recordProcess.filePath); + SendNotification.show("Recording stopped", recordProcess.filePath); + }; + recordProcess.running = true; + SendNotification.show("Recording started", "Recording to " + recordProcess.filePath); } Process { @@ -113,13 +109,13 @@ Singleton { running: false onExited: function(exitCode, exitStatus) { if (exitCode === 0) { - Logger.log("RecordService", "Recording stopped successfully."); + Logger.i("RecordService", "Recording stopped successfully."); if (onNormalExit) { onNormalExit(); onNormalExit = null; } } else { - Logger.error("RecordService", "Recording process exited with error code: " + exitCode); + Logger.e("RecordService", "Recording process exited with error code: " + exitCode); if (onErrorExit) { onErrorExit(); onErrorExit = null; @@ -131,36 +127,4 @@ Singleton { } } - Process { - id: niriFocusedOutputProcess - - property var onGetName: null - - running: false - command: ["niri", "msg", "focused-output"] - onExited: function(exitCode, exitStatus) { - if (exitCode !== 0) { - Logger.error("RecordService", "Failed to get focused output via niri."); - if (niriFocusedOutputProcess.onGetName) { - niriFocusedOutputProcess.onGetName(null); - niriFocusedOutputProcess.onGetName = null; - } - } - } - - stdout: SplitParser { - splitMarker: "\n" - onRead: (data) => { - if (niriFocusedOutputProcess.onGetName) { - const parts = data.split(' '); - const name = parts.length > 0 ? parts[parts.length - 1].slice(1)?.slice(0, -1) : null; - name ? Logger.log("RecordService", "Focused output is: " + name) : Logger.warn("RecordService", "No focused output found."); - niriFocusedOutputProcess.onGetName(name); - niriFocusedOutputProcess.onGetName = null; - } - } - } - - } - } diff --git a/config/quickshell/.config/quickshell/Services/Screenshot.qml b/config/quickshell/.config/quickshell/Services/Screenshot.qml deleted file mode 100644 index 81351e4..0000000 --- a/config/quickshell/.config/quickshell/Services/Screenshot.qml +++ /dev/null @@ -1,16 +0,0 @@ -import QtQuick -import Quickshell -import Quickshell.Io -pragma Singleton - -Singleton { - id: root - - function onScreenshotCaptured(path) { - if (!path || typeof path !== "string") - return ; - - Quickshell.execDetached(["screenshot-script", "edit", path]); - } - -} diff --git a/config/quickshell/.config/quickshell/Services/SettingsService.qml b/config/quickshell/.config/quickshell/Services/SettingsService.qml index fb7f18d..9b44df1 100644 --- a/config/quickshell/.config/quickshell/Services/SettingsService.qml +++ b/config/quickshell/.config/quickshell/Services/SettingsService.qml @@ -2,40 +2,38 @@ import QtQuick import Quickshell import Quickshell.Io import qs.Constants -import qs.Services pragma Singleton Singleton { - property alias primaryColor: adapter.primaryColor - property alias showLyricsBar: adapter.showLyricsBar - property alias notifications: adapter.notifications + id: root + + property string settingsFilePath: Paths.configDir + "settings.json" + property alias geoInfoToken: adapter.geoInfoToken + property alias ipAliases: adapter.ipAliases property alias location: adapter.location + property alias backgroundPath: adapter.backgroundPath property alias wifiEnabled: adapter.wifiEnabled - property alias sunsetDefaultEnabled: adapter.sunsetDefaultEnabled - property string settingsFilePath: Qt.resolvedUrl("../Assets/Config/Settings.json") FileView { - id: settingsFile + id: settingFile path: settingsFilePath watchChanges: true - onFileChanged: reload() + onFileChanged: { + reload(); + } onAdapterUpdated: writeAdapter() JsonAdapter { id: adapter - property string primaryColor: "#89b4fa" - property bool showLyricsBar: false - property JsonObject notifications - property string location: "New York" - property bool wifiEnabled: true - property bool sunsetDefaultEnabled: true - - notifications: JsonObject { - property bool doNotDisturb: false + property string geoInfoToken: "" + property var ipAliases: { + "127.0.0.1": "localhost" } - + property string location: "New York" + property string backgroundPath: "" + property bool wifiEnabled: true } } diff --git a/config/quickshell/.config/quickshell/Services/ShellState.qml b/config/quickshell/.config/quickshell/Services/ShellState.qml new file mode 100644 index 0000000..344265d --- /dev/null +++ b/config/quickshell/.config/quickshell/Services/ShellState.qml @@ -0,0 +1,72 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Constants +import qs.Utils +pragma Singleton + +Singleton { + id: root + + property string stateFile: Paths.cacheDir + "shell-state.json" + property bool isLoaded: false + property alias notificationsState: adapter.notificationsState + property alias lyricsState: adapter.lyricsState + property alias sunsetState: adapter.sunsetState + + function save() { + saveTimer.restart(); + } + + onNotificationsStateChanged: save() + onLyricsStateChanged: save() + onSunsetStateChanged: save() + Component.onCompleted: { + stateFileView.path = stateFile; + } + + FileView { + id: stateFileView + + printErrors: false + watchChanges: false + onLoaded: { + root.isLoaded = true; + Logger.d("ShellState", "Loaded state file"); + } + onLoadFailed: (error) => { + root.isLoaded = true; + } + + adapter: JsonAdapter { + id: adapter + + property var notificationsState: ({ + "lastSeenTs": 0, + "doNotDisturb": false + }) + property var lyricsState: ({ + "showLyricsBar": false + }) + property var sunsetState: ({ + "enabled": true + }) + } + + } + + Timer { + id: saveTimer + + interval: 500 + onTriggered: { + if (stateFile) { + try { + stateFileView.writeAdapter(); + } catch (e) { + } + } + } + } + +} diff --git a/config/quickshell/.config/quickshell/Services/SunsetService.qml b/config/quickshell/.config/quickshell/Services/SunsetService.qml index f4c4d9d..6348ba2 100644 --- a/config/quickshell/.config/quickshell/Services/SunsetService.qml +++ b/config/quickshell/.config/quickshell/Services/SunsetService.qml @@ -8,64 +8,71 @@ pragma Singleton Singleton { id: root - property bool defaultRunning: SettingsService.sunsetDefaultEnabled property double _latitude: -1 property double _longitude: -1 - property alias isRunning: sunsetProcess.running property int temperature: 0 - - function startSunset() { - if (isRunning) - return ; - - if (_latitude == -1 || _longitude == -1) { - Logger.warn("Sunset", "Cannot start sunset process, invalid coordinates"); - return ; - } - sunsetProcess.command = ["wlsunset", "-l", _latitude.toString(), "-L", _longitude.toString()]; - sunsetProcess.running = true; - } - - function stopSunset() { - if (!isRunning) - return ; - - sunsetProcess.running = false; - } + property bool isEnabled: ShellState.sunsetState.enabled || false function toggleSunset() { - if (isRunning) - stopSunset(); - else - startSunset(); + ShellState.sunsetState = { + "enabled": !root.isEnabled + }; } function setLat(lat) { _latitude = lat; - Logger.log("Sunset", "Updated latitude to " + lat); + Logger.i("Sunset", "Updated latitude to " + lat); checkStart(); } function setLong(lng) { _longitude = lng; - Logger.log("Sunset", "Updated longitude to " + lng); + Logger.i("Sunset", "Updated longitude to " + lng); checkStart(); } function checkStart() { - if (_latitude != -1 && _longitude != -1 && defaultRunning && !isRunning) - startSunset(); - + if (_latitude !== -1 && _longitude !== -1 && root.isEnabled) { + sunsetProcess.command = ["wlsunset", "-l", _latitude.toString(), "-L", _longitude.toString()]; + sunsetProcess.running = true; + } } Connections { - target: LocationService.data - onLatitudeChanged: { + function onLatitudeChanged() { + Logger.d(""); setLat(LocationService.data.latitude); } - onLongitudeChanged: { + + function onLongitudeChanged() { setLong(LocationService.data.longitude); } + + target: LocationService.data + } + + Connections { + function onIsEnabledChanged() { + if (root.isEnabled) + checkStart(); + else + sunsetProcess.running = false; + } + + target: root + } + + Connections { + function onRunningChanged() { + if (!sunsetProcess.running) { + temperature = 0; + Logger.i("Sunset", "Stopped sunset process"); + } else { + Logger.i("Sunset", "Started sunset process"); + } + } + + target: sunsetProcess } Process { @@ -80,15 +87,11 @@ Singleton { var tempMatch = line.match(/setting temperature to (\d+) K/); if (tempMatch && tempMatch.length == 2) { temperature = parseInt(tempMatch[1]); - Logger.log("Sunset", "Updated temperature to " + temperature + " K"); + Logger.d("Sunset", "Updated temperature to " + temperature + " K"); } } } } - NetworkFetch { - id: curl - } - } diff --git a/config/quickshell/.config/quickshell/Services/SystemStatService.qml b/config/quickshell/.config/quickshell/Services/SystemStatService.qml index bc571df..0a9e3c3 100644 --- a/config/quickshell/.config/quickshell/Services/SystemStatService.qml +++ b/config/quickshell/.config/quickshell/Services/SystemStatService.qml @@ -1,392 +1,861 @@ +pragma Singleton import Qt.labs.folderlistmodel + import QtQuick import Quickshell import Quickshell.Io import qs.Utils -pragma Singleton +import qs.Constants Singleton { - // For Intel coretemp, start averaging all available sensors/cores + id: root - id: root + // Component registration - only poll when something needs system stat data + function registerComponent(componentId) { + root._registered[componentId] = true; + root._registered = Object.assign({}, root._registered); + Logger.d("SystemStat", "Component registered:", componentId, "- total:", root._registeredCount); + } - // Public values - property real cpuUsage: 0 - property real cpuTemp: 0 - property real memGb: 0 - property real memPercent: 0 - property real diskPercent: 0 - property real rxSpeed: 0 - property real txSpeed: 0 - // Configuration - property int sleepDuration: 3000 - property int fasterSleepDuration: 1000 - // Internal state for CPU calculation - property var prevCpuStats: null - // Internal state for network speed calculation - // Previous Bytes need to be stored as 'real' as they represent the total of bytes transfered - // since the computer started, so their value will easily overlfow a 32bit int. - property real prevRxBytes: 0 - property real prevTxBytes: 0 - property real prevTime: 0 - // Cpu temperature is the most complex - readonly property var supportedTempCpuSensorNames: ["coretemp", "k10temp", "zenpower"] - property string cpuTempSensorName: "" - property string cpuTempHwmonPath: "" - // For Intel coretemp averaging of all cores/sensors - property var intelTempValues: [] - property int intelTempFilesChecked: 0 - property int intelTempMaxFiles: 20 // Will test up to temp20_input + function unregisterComponent(componentId) { + delete root._registered[componentId]; + root._registered = Object.assign({}, root._registered); + Logger.d("SystemStat", "Component unregistered:", componentId, "- total:", root._registeredCount); + } - // ------------------------------------------------------- - // ------------------------------------------------------- - // Parse memory info from /proc/meminfo - function parseMemoryInfo(text) { - if (!text) - return ; + property var _registered: ({}) + readonly property int _registeredCount: Object.keys(_registered).length + readonly property bool _lockScreenActive: PanelService.lockScreen?.active ?? false + readonly property bool shouldRun: _registeredCount > 0 && !_lockScreenActive - const lines = text.split('\n'); - let memTotal = 0; - let memAvailable = 0; - for (const line of lines) { - if (line.startsWith('MemTotal:')) - memTotal = parseInt(line.split(/\s+/)[1]) || 0; - else if (line.startsWith('MemAvailable:')) - memAvailable = parseInt(line.split(/\s+/)[1]) || 0; + // Polling intervals (hardcoded to sensible values per stat type) + readonly property int cpuUsageIntervalMs: 1000 + readonly property int cpuFreqIntervalMs: 3000 + readonly property int memIntervalMs: 5000 + readonly property int networkIntervalMs: 3000 + readonly property int loadAvgIntervalMs: 10000 + + // Public values + property real cpuUsage: 0 + property real cpuTemp: 0 + property string cpuFreq: "0.0GHz" + property real cpuFreqRatio: 0 + property real cpuGlobalMaxFreq: 3.5 + property real memGb: 0 + property real memPercent: 0 + property real memTotalGb: 0 + property real rxSpeed: 0 + property real txSpeed: 0 + property real zfsArcSizeKb: 0 // ZFS ARC cache size in KB + property real zfsArcCminKb: 0 // ZFS ARC minimum (non-reclaimable) size in KB + property real loadAvg1: 0 + property real loadAvg5: 0 + property real loadAvg15: 0 + property int nproc: 0 // Number of cpu cores + + // History arrays (1 minute of data, length computed from polling interval) + // Pre-filled with zeros so the graph scrolls smoothly from the start + readonly property int historyDurationMs: (1 * 60 * 1000) // 1 minute + + // Computed history lengths based on polling intervals + readonly property int cpuHistoryLength: Math.ceil(historyDurationMs / cpuUsageIntervalMs) + readonly property int memHistoryLength: Math.ceil(historyDurationMs / memIntervalMs) + readonly property int networkHistoryLength: Math.ceil(historyDurationMs / networkIntervalMs) + + property var cpuHistory: new Array(cpuHistoryLength).fill(0) + property var cpuTempHistory: new Array(cpuHistoryLength).fill(40) // Reasonable default temp + property var memHistory: new Array(memHistoryLength).fill(0) + property var rxSpeedHistory: new Array(networkHistoryLength).fill(0) + property var txSpeedHistory: new Array(networkHistoryLength).fill(0) + + // Historical min/max tracking (since shell started) for consistent graph scaling + // Temperature defaults create a valid 30-80°C range that expands as real data comes in + property real cpuTempHistoryMin: 30 + property real cpuTempHistoryMax: 80 + // Network uses autoscaling from current history window + + // History management - called from update functions, not change handlers + // (change handlers don't fire when value stays the same) + function pushCpuHistory() { + let h = cpuHistory.slice(); + h.push(cpuUsage); + if (h.length > cpuHistoryLength) + h.shift(); + cpuHistory = h; + } + + function pushCpuTempHistory() { + if (cpuTemp > 0) { + if (cpuTemp < cpuTempHistoryMin) + cpuTempHistoryMin = cpuTemp; + if (cpuTemp > cpuTempHistoryMax) + cpuTempHistoryMax = cpuTemp; + } + let h = cpuTempHistory.slice(); + h.push(cpuTemp); + if (h.length > cpuHistoryLength) + h.shift(); + cpuTempHistory = h; + } + + function pushMemHistory() { + let h = memHistory.slice(); + h.push(memPercent); + if (h.length > memHistoryLength) + h.shift(); + memHistory = h; + } + + function pushNetworkHistory() { + let rxH = rxSpeedHistory.slice(); + rxH.push(rxSpeed); + if (rxH.length > networkHistoryLength) + rxH.shift(); + rxSpeedHistory = rxH; + + let txH = txSpeedHistory.slice(); + txH.push(txSpeed); + if (txH.length > networkHistoryLength) + txH.shift(); + txSpeedHistory = txH; + } + + // Network max speed tracking (autoscales from current history window) + // Minimum floor of 1 MB/s so graph doesn't fluctuate at low speeds + readonly property real rxMaxSpeed: { + const max = Math.max(...rxSpeedHistory); + return Math.max(max, 1000000); // 1 MB/s floor + } + readonly property real txMaxSpeed: { + const max = Math.max(...txSpeedHistory); + return Math.max(max, 512000); // 512 KB/s floor + } + + // Ready-to-use ratios based on current maximums (0..1 range) + readonly property real rxRatio: rxMaxSpeed > 0 ? Math.min(1, rxSpeed / rxMaxSpeed) : 0 + readonly property real txRatio: txMaxSpeed > 0 ? Math.min(1, txSpeed / txMaxSpeed) : 0 + + // Internal state for CPU calculation + property var prevCpuStats: null + + // Internal state for network speed calculation + // Previous Bytes need to be stored as 'real' as they represent the total of bytes transfered + // since the computer started, so their value will easily overlfow a 32bit int. + property real prevRxBytes: 0 + property real prevTxBytes: 0 + property real prevTime: 0 + + // Cpu temperature is the most complex + readonly property var supportedTempCpuSensorNames: ["coretemp", "k10temp", "zenpower"] + property string cpuTempSensorName: "" + property string cpuTempHwmonPath: "" + // For Intel coretemp averaging of all cores/sensors + property var intelTempValues: [] + property int intelTempFilesChecked: 0 + property int intelTempMaxFiles: 20 // Will test up to temp20_input + + // Thermal zone fallback (for ARM SoCs with SCMI sensors, etc.) + // Matches thermal zone types containing "cpu" and picks the hottest big-core zone. + readonly property var thermalZoneCpuPatterns: ["cpu-b", "cpu-m", "cpu"] + property string cpuThermalZonePath: "" + property var cpuThermalZonePaths: [] // All matching CPU zones for averaging + + // -------------------------------------------- + Component.onCompleted: { + Logger.i("SystemStat", "Service started (polling deferred until a consumer registers)."); + + // Kickoff the cpu name detection for temperature (one-time probes, not polling) + cpuTempNameReader.checkNext(); + + // Get nproc on startup (one-time) + nprocProcess.running = true; + } + + onShouldRunChanged: { + if (shouldRun) { + // Reset differential state so first readings after resume are clean + root.prevCpuStats = null; + root.prevTime = 0; + + // Trigger initial reads + zfsArcStatsFile.reload(); + loadAvgFile.reload(); + } + } + + // Reset differential state after suspend so the first reading is treated as fresh + Connections { + target: Time + function onResumed() { + Logger.i("SystemStat", "System resumed - resetting differential state"); + root.prevCpuStats = null; + root.prevTime = 0; + } + } + + // -------------------------------------------- + // Timer for CPU usage and temperature + Timer { + id: cpuTimer + interval: root.cpuUsageIntervalMs + repeat: true + running: root.shouldRun + triggeredOnStart: true + onTriggered: { + cpuStatFile.reload(); + updateCpuTemperature(); + } + } + + // Timer for CPU frequency (slower — /proc/cpuinfo is large and freq changes infrequently) + Timer { + id: cpuFreqTimer + interval: root.cpuFreqIntervalMs + repeat: true + running: root.shouldRun + triggeredOnStart: true + onTriggered: cpuInfoFile.reload() + } + + // Timer for load average + Timer { + id: loadAvgTimer + interval: root.loadAvgIntervalMs + repeat: true + running: root.shouldRun + triggeredOnStart: true + onTriggered: loadAvgFile.reload() + } + + // Timer for memory stats + Timer { + id: memoryTimer + interval: root.memIntervalMs + repeat: true + running: root.shouldRun + triggeredOnStart: true + onTriggered: { + memInfoFile.reload(); + zfsArcStatsFile.reload(); + } + } + + // Timer for network speeds + Timer { + id: networkTimer + interval: root.networkIntervalMs + repeat: true + running: root.shouldRun + triggeredOnStart: true + onTriggered: netDevFile.reload() + } + + // -------------------------------------------- + // FileView components for reading system files + FileView { + id: memInfoFile + path: "/proc/meminfo" + onLoaded: parseMemoryInfo(text()) + } + + FileView { + id: cpuStatFile + path: "/proc/stat" + onLoaded: calculateCpuUsage(text()) + } + + FileView { + id: netDevFile + path: "/proc/net/dev" + onLoaded: calculateNetworkSpeed(text()) + } + + FileView { + id: loadAvgFile + path: "/proc/loadavg" + onLoaded: parseLoadAverage(text()) + } + + // ZFS ARC stats file (only exists on ZFS systems) + FileView { + id: zfsArcStatsFile + path: "/proc/spl/kstat/zfs/arcstats" + printErrors: false + onLoaded: parseZfsArcStats(text()) + onLoadFailed: { + // File doesn't exist (non-ZFS system), set ARC values to 0 + root.zfsArcSizeKb = 0; + root.zfsArcCminKb = 0; + } + } + + // Process to get number of processors + Process { + id: nprocProcess + command: ["nproc"] + running: false + stdout: StdioCollector { + onStreamFinished: { + root.nproc = parseInt(text.trim()); + } + } + } + + // FileView to get avg cpu frequency (replaces subprocess spawn of `cat /proc/cpuinfo`) + FileView { + id: cpuInfoFile + path: "/proc/cpuinfo" + onLoaded: { + let txt = text(); + let matches = txt.match(/cpu MHz\s+:\s+([0-9.]+)/g); + if (matches && matches.length > 0) { + let totalFreq = 0.0; + for (let i = 0; i < matches.length; i++) { + totalFreq += parseFloat(matches[i].split(":")[1]); } - if (memTotal > 0) { - const usageKb = memTotal - memAvailable; - root.memGb = (usageKb / 1e+06).toFixed(1); - root.memPercent = Math.round((usageKb / memTotal) * 100); + let avgFreq = (totalFreq / matches.length) / 1000.0; + root.cpuFreq = avgFreq.toFixed(1) + "GHz"; + cpuMaxFreqFile.reload(); + if (avgFreq > root.cpuGlobalMaxFreq) + root.cpuGlobalMaxFreq = avgFreq; + if (root.cpuGlobalMaxFreq > 0) { + root.cpuFreqRatio = Math.min(1.0, avgFreq / root.cpuGlobalMaxFreq); } + } + } + } + + // FileView to get maximum CPU frequency limit (replaces subprocess spawn) + // Reads cpu0's scaling_max_freq as representative value + FileView { + id: cpuMaxFreqFile + path: "/sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq" + printErrors: false + onLoaded: { + let maxKHz = parseInt(text().trim()); + if (!isNaN(maxKHz) && maxKHz > 0) { + let newMaxFreq = maxKHz / 1000000.0; + if (Math.abs(root.cpuGlobalMaxFreq - newMaxFreq) > 0.01) { + root.cpuGlobalMaxFreq = newMaxFreq; + } + } + } + } + + // -------------------------------------------- + // CPU Temperature + // It's more complex. + // ---- + // #1 - Find a common cpu sensor name ie: "coretemp", "k10temp", "zenpower" + FileView { + id: cpuTempNameReader + property int currentIndex: 0 + printErrors: false + + function checkNext() { + if (currentIndex >= 16) { + // No hwmon sensor found, try thermal_zone fallback (ARM SoCs, SCMI, etc.) + thermalZoneScanner.startScan(); + return; + } + + cpuTempNameReader.path = `/sys/class/hwmon/hwmon${currentIndex}/name`; + cpuTempNameReader.reload(); } - // ------------------------------------------------------- - // Calculate CPU usage from /proc/stat - function calculateCpuUsage(text) { - if (!text) - return ; - - const lines = text.split('\n'); - const cpuLine = lines[0]; - // First line is total CPU - if (!cpuLine.startsWith('cpu ')) - return ; - - const parts = cpuLine.split(/\s+/); - const stats = { - "user": parseInt(parts[1]) || 0, - "nice": parseInt(parts[2]) || 0, - "system": parseInt(parts[3]) || 0, - "idle": parseInt(parts[4]) || 0, - "iowait": parseInt(parts[5]) || 0, - "irq": parseInt(parts[6]) || 0, - "softirq": parseInt(parts[7]) || 0, - "steal": parseInt(parts[8]) || 0, - "guest": parseInt(parts[9]) || 0, - "guestNice": parseInt(parts[10]) || 0 - }; - const totalIdle = stats.idle + stats.iowait; - const total = Object.values(stats).reduce((sum, val) => { - return sum + val; - }, 0); - if (root.prevCpuStats) { - const prevTotalIdle = root.prevCpuStats.idle + root.prevCpuStats.iowait; - const prevTotal = Object.values(root.prevCpuStats).reduce((sum, val) => { - return sum + val; - }, 0); - const diffTotal = total - prevTotal; - const diffIdle = totalIdle - prevTotalIdle; - if (diffTotal > 0) - root.cpuUsage = (((diffTotal - diffIdle) / diffTotal) * 100).toFixed(1); - - } - root.prevCpuStats = stats; + onLoaded: { + const name = text().trim(); + if (root.supportedTempCpuSensorNames.includes(name)) { + root.cpuTempSensorName = name; + root.cpuTempHwmonPath = `/sys/class/hwmon/hwmon${currentIndex}`; + Logger.i("SystemStat", `Found ${root.cpuTempSensorName} CPU thermal sensor at ${root.cpuTempHwmonPath}`); + } else { + currentIndex++; + Qt.callLater(() => { + // Qt.callLater is mandatory + checkNext(); + }); + } } - // ------------------------------------------------------- - // Calculate RX and TX speed from /proc/net/dev - // Average speed of all interfaces excepted 'lo' - function calculateNetworkSpeed(text) { - if (!text) - return ; + onLoadFailed: function (error) { + currentIndex++; + Qt.callLater(() => { + // Qt.callLater is mandatory + checkNext(); + }); + } + } - const currentTime = Date.now() / 1000; - const lines = text.split('\n'); - let totalRx = 0; - let totalTx = 0; - for (var i = 2; i < lines.length; i++) { - const line = lines[i].trim(); - if (!line) - continue; + // ---- + // #2 - Read sensor value + FileView { + id: cpuTempReader + printErrors: false - const colonIndex = line.indexOf(':'); - if (colonIndex === -1) - continue; + onLoaded: { + const data = text().trim(); + if (root.cpuTempSensorName === "coretemp") { + // For Intel, collect all temperature values + const temp = parseInt(data) / 1000.0; + root.intelTempValues.push(temp); + Qt.callLater(() => { + // Qt.callLater is mandatory + checkNextIntelTemp(); + }); + } else { + // For AMD sensors (k10temp and zenpower), directly set the temperature + root.cpuTemp = Math.round(parseInt(data) / 1000.0); + root.pushCpuTempHistory(); + } + } + onLoadFailed: function (error) { + Qt.callLater(() => { + // Qt.callLater is mandatory + checkNextIntelTemp(); + }); + } + } - const iface = line.substring(0, colonIndex).trim(); - if (iface === 'lo') - continue; + // -------------------------------------------- + // Thermal zone fallback for CPU temperature + // Used on ARM SoCs (e.g., SCMI sensors) where hwmon doesn't expose + // coretemp/k10temp/zenpower. Scans /sys/class/thermal/thermal_zoneN/type + // for CPU zone names, then reads temp from all matching zones. + // + // CPU: reads all cpu-*-thermal zones and reports the hottest core. - const statsLine = line.substring(colonIndex + 1).trim(); - const stats = statsLine.split(/\s+/); - const rxBytes = parseInt(stats[0], 10) || 0; - const txBytes = parseInt(stats[8], 10) || 0; - totalRx += rxBytes; - totalTx += txBytes; - } - // Compute only if we have a previous run to compare to. - if (root.prevTime > 0) { - const timeDiff = currentTime - root.prevTime; - // Avoid division by zero if time hasn't passed. - if (timeDiff > 0) { - let rxDiff = totalRx - root.prevRxBytes; - let txDiff = totalTx - root.prevTxBytes; - // Handle counter resets (e.g., WiFi reconnect), which would cause a negative value. - if (rxDiff < 0) - rxDiff = 0; + FileView { + id: thermalZoneScanner + property int currentIndex: 0 + property var cpuZones: [] + printErrors: false - if (txDiff < 0) - txDiff = 0; - - root.rxSpeed = Math.round(rxDiff / timeDiff); // Speed in Bytes/s - root.txSpeed = Math.round(txDiff / timeDiff); - } - } - root.prevRxBytes = totalRx; - root.prevTxBytes = totalTx; - root.prevTime = currentTime; + function startScan() { + currentIndex = 0; + cpuZones = []; + checkNext(); } - // ------------------------------------------------------- - // Helper function to format network speeds - function formatSpeed(bytesPerSecond) { - if (bytesPerSecond < 1024 * 1024) { - const kb = bytesPerSecond / 1024; - if (kb < 10) - return kb.toFixed(1) + "KB"; - else - return Math.round(kb) + "KB"; - } else if (bytesPerSecond < 1024 * 1024 * 1024) { - return (bytesPerSecond / (1024 * 1024)).toFixed(1) + "MB"; - } else { - return (bytesPerSecond / (1024 * 1024 * 1024)).toFixed(1) + "GB"; - } + function checkNext() { + if (currentIndex >= 20) { + finishScan(); + return; + } + thermalZoneScanner.path = `/sys/class/thermal/thermal_zone${currentIndex}/type`; + thermalZoneScanner.reload(); } - // ------------------------------------------------------- - // Compact speed formatter for vertical bar display - function formatCompactSpeed(bytesPerSecond) { - if (!bytesPerSecond || bytesPerSecond <= 0) - return "0"; - - const units = ["", "K", "M", "G"]; - let value = bytesPerSecond; - let unitIndex = 0; - while (value >= 1024 && unitIndex < units.length - 1) { - value = value / 1024; - unitIndex++; - } - // Promote at ~100 of current unit (e.g., 100k -> ~0.1M shown as 0.1M or 0M if rounded) - if (unitIndex < units.length - 1 && value >= 100) { - value = value / 1024; - unitIndex++; - } - const display = Math.round(value).toString(); - return display + units[unitIndex]; + onLoaded: { + const name = text().trim(); + const zonePath = `/sys/class/thermal/thermal_zone${currentIndex}`; + if (name.startsWith("cpu") && name.endsWith("thermal")) { + cpuZones.push({ + "type": name, + "path": zonePath + "/temp" + }); + } + currentIndex++; + Qt.callLater(() => { + checkNext(); + }); } - // ------------------------------------------------------- - // Function to start fetching and computing the cpu temperature - function updateCpuTemperature() { - // For AMD sensors (k10temp and zenpower), only use Tctl sensor - // temp1_input corresponds to Tctl (Temperature Control) on these sensors - if (root.cpuTempSensorName === "k10temp" || root.cpuTempSensorName === "zenpower") { - cpuTempReader.path = `${root.cpuTempHwmonPath}/temp1_input`; - cpuTempReader.reload(); - } else if (root.cpuTempSensorName === "coretemp") { - root.intelTempValues = []; - root.intelTempFilesChecked = 0; - checkNextIntelTemp(); - } + onLoadFailed: function (error) { + currentIndex++; + Qt.callLater(() => { + checkNext(); + }); } - // ------------------------------------------------------- - // Function to check next Intel temperature sensor - function checkNextIntelTemp() { - if (root.intelTempFilesChecked >= root.intelTempMaxFiles) { - // Calculate average of all found temperatures - if (root.intelTempValues.length > 0) { - let sum = 0; - for (var i = 0; i < root.intelTempValues.length; i++) { - sum += root.intelTempValues[i]; - } - root.cpuTemp = Math.round(sum / root.intelTempValues.length); - } else { - Logger.warn("SystemStatService", "No temperature sensors found for coretemp"); - root.cpuTemp = 0; - } - return ; - } - // Check next temperature file - root.intelTempFilesChecked++; - cpuTempReader.path = `${root.cpuTempHwmonPath}/temp${root.intelTempFilesChecked}_input`; - cpuTempReader.reload(); + function finishScan() { + if (cpuZones.length > 0) { + root.cpuTempSensorName = "thermal_zone"; + root.cpuThermalZonePaths = cpuZones.map(z => z.path); + const types = cpuZones.map(z => z.type).join(", "); + Logger.i("SystemStat", `Found ${cpuZones.length} CPU thermal zone(s): ${types}`); + } else if (root.cpuTempHwmonPath === "") { + Logger.w("SystemStat", "No supported temperature sensor found"); + } + } + } + + // Thermal zone reader for CPU: reads all zones, reports max (hottest core) + FileView { + id: cpuThermalZoneReader + property int currentZoneIndex: 0 + property var collectedTemps: [] + printErrors: false + + onLoaded: { + const temp = parseInt(text().trim()) / 1000.0; + if (!isNaN(temp) && temp > 0) + collectedTemps.push(temp); + currentZoneIndex++; + Qt.callLater(() => { + readNextCpuThermalZone(); + }); } - // -------------------------------------------- - Component.onCompleted: { - // Kickoff the cpu name detection for temperature - cpuTempNameReader.checkNext(); + onLoadFailed: function (error) { + currentZoneIndex++; + Qt.callLater(() => { + readNextCpuThermalZone(); + }); } + } - // -------------------------------------------- - // Timer for periodic updates - Timer { - id: updateTimer - - interval: root.sleepDuration - repeat: true - running: true - triggeredOnStart: true - onTriggered: { - // Trigger all direct system files reads - memInfoFile.reload(); - cpuStatFile.reload(); - // Run df (disk free) one time - dfProcess.running = true; - updateCpuTemperature(); - } + function readNextCpuThermalZone() { + if (cpuThermalZoneReader.currentZoneIndex >= root.cpuThermalZonePaths.length) { + if (cpuThermalZoneReader.collectedTemps.length > 0) { + root.cpuTemp = Math.round(Math.max(...cpuThermalZoneReader.collectedTemps)); + } else { + root.cpuTemp = 0; + } + root.pushCpuTempHistory(); + return; } + cpuThermalZoneReader.path = root.cpuThermalZonePaths[cpuThermalZoneReader.currentZoneIndex]; + cpuThermalZoneReader.reload(); + } - Timer { - id: fasterUpdateTimer + // -------------------------------------------- + // Parse ZFS ARC stats from /proc/spl/kstat/zfs/arcstats + function parseZfsArcStats(text) { + if (!text) + return; + const lines = text.split('\n'); - interval: root.fasterSleepDuration - repeat: true - running: true - triggeredOnStart: true - onTriggered: { - netDevFile.reload(); - } - } + // The file format is: name type data + // We need to find the lines with "size" and "c_min" and extract the values (third column) + let foundSize = false; + let foundCmin = false; - // -------------------------------------------- - // FileView components for reading system files - FileView { - id: memInfoFile - - path: "/proc/meminfo" - onLoaded: parseMemoryInfo(text()) - } - - FileView { - id: cpuStatFile - - path: "/proc/stat" - onLoaded: calculateCpuUsage(text()) - } - - FileView { - id: netDevFile - - path: "/proc/net/dev" - onLoaded: calculateNetworkSpeed(text()) - } - - // -------------------------------------------- - // Process to fetch disk usage in percent - // Uses 'df' aka 'disk free' - Process { - id: dfProcess - - command: ["df", "--output=pcent", "/"] - running: false - - stdout: StdioCollector { - onStreamFinished: { - const lines = text.trim().split('\n'); - if (lines.length >= 2) { - const percent = lines[1].replace(/[^0-9]/g, ''); - root.diskPercent = parseInt(percent) || 0; - } - } + for (const line of lines) { + const parts = line.trim().split(/\s+/); + if (parts.length >= 3) { + if (parts[0] === 'size') { + // The value is in bytes, convert to KB + const arcSizeBytes = parseInt(parts[2]) || 0; + root.zfsArcSizeKb = Math.floor(arcSizeBytes / 1024); + foundSize = true; + } else if (parts[0] === 'c_min') { + // The value is in bytes, convert to KB + const arcCminBytes = parseInt(parts[2]) || 0; + root.zfsArcCminKb = Math.floor(arcCminBytes / 1024); + foundCmin = true; } + // If we found both, we can return early + if (foundSize && foundCmin) { + return; + } + } } - // -------------------------------------------- - // -------------------------------------------- - // CPU Temperature - // It's more complex. - // ---- - // #1 - Find a common cpu sensor name ie: "coretemp", "k10temp", "zenpower" - FileView { - id: cpuTempNameReader + // If fields not found, set to 0 + if (!foundSize) { + root.zfsArcSizeKb = 0; + } + if (!foundCmin) { + root.zfsArcCminKb = 0; + } + } - property int currentIndex: 0 + // -------------------------------------------- + // Parse load average from /proc/loadavg + function parseLoadAverage(text) { + if (!text) + return; + const parts = text.trim().split(/\s+/); + if (parts.length >= 3) { + root.loadAvg1 = parseFloat(parts[0]); + root.loadAvg5 = parseFloat(parts[1]); + root.loadAvg15 = parseFloat(parts[2]); + } + } - function checkNext() { - if (currentIndex >= 16) { - // Check up to hwmon10 - Logger.warn("SystemStatService", "No supported temperature sensor found"); - return ; - } - cpuTempNameReader.path = `/sys/class/hwmon/hwmon${currentIndex}/name`; - cpuTempNameReader.reload(); - } + // -------------------------------------------- + // Parse memory info from /proc/meminfo + function parseMemoryInfo(text) { + if (!text) + return; + const lines = text.split('\n'); + let memTotal = 0; + let memAvailable = 0; - printErrors: false - onLoaded: { - const name = text().trim(); - if (root.supportedTempCpuSensorNames.includes(name)) { - root.cpuTempSensorName = name; - root.cpuTempHwmonPath = `/sys/class/hwmon/hwmon${currentIndex}`; - Logger.log("SystemStatService", `Found ${root.cpuTempSensorName} CPU thermal sensor at ${root.cpuTempHwmonPath}`); - } else { - currentIndex++; - Qt.callLater(() => { - // Qt.callLater is mandatory - checkNext(); - }); - } - } - onLoadFailed: function(error) { - currentIndex++; - Qt.callLater(() => { - // Qt.callLater is mandatory - checkNext(); - }); - } + for (const line of lines) { + if (line.startsWith('MemTotal:')) { + memTotal = parseInt(line.split(/\s+/)[1]) || 0; + } else if (line.startsWith('MemAvailable:')) { + memAvailable = parseInt(line.split(/\s+/)[1]) || 0; + } } - // ---- - // #2 - Read sensor value - FileView { - id: cpuTempReader + if (memTotal > 0) { + // Calculate usage, adjusting for ZFS ARC cache if present + let usageKb = memTotal - memAvailable; + if (root.zfsArcSizeKb > 0) { + usageKb = Math.max(0, usageKb - root.zfsArcSizeKb + root.zfsArcCminKb); + } + root.memGb = (usageKb / 1048576).toFixed(1); // 1024*1024 = 1048576 + root.memPercent = Math.round((usageKb / memTotal) * 100); + root.memTotalGb = (memTotal / 1048576).toFixed(1); + root.pushMemHistory(); + } + } - printErrors: false - onLoaded: { - const data = text().trim(); - if (root.cpuTempSensorName === "coretemp") { - // For Intel, collect all temperature values - const temp = parseInt(data) / 1000; - root.intelTempValues.push(temp); - Qt.callLater(() => { - // Qt.callLater is mandatory - checkNextIntelTemp(); - }); - } else { - // For AMD sensors (k10temp and zenpower), directly set the temperature - root.cpuTemp = Math.round(parseInt(data) / 1000); - } - } - onLoadFailed: function(error) { - Qt.callLater(() => { - // Qt.callLater is mandatory - checkNextIntelTemp(); - }); - } + // -------------------------------------------- + // Calculate CPU usage from /proc/stat + function calculateCpuUsage(text) { + if (!text) + return; + const lines = text.split('\n'); + const cpuLine = lines[0]; + + // First line is total CPU + if (!cpuLine.startsWith('cpu ')) + return; + const parts = cpuLine.split(/\s+/); + const stats = { + "user": parseInt(parts[1]) || 0, + "nice": parseInt(parts[2]) || 0, + "system": parseInt(parts[3]) || 0, + "idle": parseInt(parts[4]) || 0, + "iowait": parseInt(parts[5]) || 0, + "irq": parseInt(parts[6]) || 0, + "softirq": parseInt(parts[7]) || 0, + "steal": parseInt(parts[8]) || 0, + "guest": parseInt(parts[9]) || 0, + "guestNice": parseInt(parts[10]) || 0 + }; + const totalIdle = stats.idle + stats.iowait; + const total = Object.values(stats).reduce((sum, val) => sum + val, 0); + + if (root.prevCpuStats) { + const prevTotalIdle = root.prevCpuStats.idle + root.prevCpuStats.iowait; + const prevTotal = Object.values(root.prevCpuStats).reduce((sum, val) => sum + val, 0); + + const diffTotal = total - prevTotal; + const diffIdle = totalIdle - prevTotalIdle; + + if (diffTotal > 0) { + root.cpuUsage = (((diffTotal - diffIdle) / diffTotal) * 100).toFixed(1); + } + root.pushCpuHistory(); } + root.prevCpuStats = stats; + } + + // -------------------------------------------- + // Check whether a network interface is virtual/tunnel/bridge. + // Only physical interfaces (eth*, en*, wl*, ww*) are kept so + // that traffic routed through VPNs, Docker bridges, etc. is + // not double-counted. + readonly property var _virtualPrefixes: ["lo", "docker", "veth", "br-", "virbr", "vnet", "tun", "tap", "wg", "tailscale", "nordlynx", "proton", "mullvad", "flannel", "cni", "cali", "vxlan", "genev", "gre", "sit", "ip6tnl", "dummy", "ifb", "nlmon", "bond"] + + function isVirtualInterface(name) { + for (let i = 0; i < _virtualPrefixes.length; ++i) { + if (name.startsWith(_virtualPrefixes[i])) + return true; + } + return false; + } + + // -------------------------------------------- + // Calculate RX and TX speed from /proc/net/dev + // Sums speeds of all physical interfaces + function calculateNetworkSpeed(text) { + if (!text) { + return; + } + + const currentTime = Date.now() / 1000; + const lines = text.split('\n'); + + let totalRx = 0; + let totalTx = 0; + + for (var i = 2; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line) { + continue; + } + + const colonIndex = line.indexOf(':'); + if (colonIndex === -1) { + continue; + } + + const iface = line.substring(0, colonIndex).trim(); + if (isVirtualInterface(iface)) { + continue; + } + + const statsLine = line.substring(colonIndex + 1).trim(); + const stats = statsLine.split(/\s+/); + + const rxBytes = parseInt(stats[0], 10) || 0; + const txBytes = parseInt(stats[8], 10) || 0; + + totalRx += rxBytes; + totalTx += txBytes; + } + + // Compute only if we have a previous run to compare to. + if (root.prevTime > 0) { + const timeDiff = currentTime - root.prevTime; + + // Avoid division by zero if time hasn't passed. + if (timeDiff > 0) { + let rxDiff = totalRx - root.prevRxBytes; + let txDiff = totalTx - root.prevTxBytes; + + // Handle counter resets (e.g., WiFi reconnect), which would cause a negative value. + if (rxDiff < 0) { + rxDiff = 0; + } + if (txDiff < 0) { + txDiff = 0; + } + + root.rxSpeed = Math.round(rxDiff / timeDiff); // Speed in Bytes/s + root.txSpeed = Math.round(txDiff / timeDiff); + } + } + + root.prevRxBytes = totalRx; + root.prevTxBytes = totalTx; + root.prevTime = currentTime; + + // Update network history after speeds are computed + root.pushNetworkHistory(); + } + + // -------------------------------------------- + // Helper function to format network speeds + function formatSpeed(bytesPerSecond) { + const units = ["KB", "MB", "GB"]; + let value = bytesPerSecond / 1000; + let unitIndex = 0; + + while (value >= 1000 && unitIndex < units.length - 1) { + value /= 1000; + unitIndex++; + } + + const unit = units[unitIndex]; + const shortUnit = unit[0]; + const numStr = value < 10 ? value.toFixed(1) : Math.round(value).toString(); + + return (numStr + unit).length > 5 ? numStr + shortUnit : numStr + unit; + } + + // Compact speed formatter for vertical bar display + function formatCompactSpeed(bytesPerSecond) { + if (!bytesPerSecond || bytesPerSecond <= 0) + return "0"; + const units = ["", "K", "M", "G"]; + let value = bytesPerSecond; + let unitIndex = 0; + while (value >= 1000 && unitIndex < units.length - 1) { + value = value / 1000.0; + unitIndex++; + } + // Promote at ~100 of current unit (e.g., 100k -> ~0.1M shown as 0.1M or 0M if rounded) + if (unitIndex < units.length - 1 && value >= 100) { + value = value / 1000.0; + unitIndex++; + } + const display = Math.round(value).toString(); + return display + units[unitIndex]; + } + + // Smart formatter for memory values (GB) - max 4 chars + // Uses decimal for < 10GB, integer otherwise + function formatGigabytes(memGb) { + const value = parseFloat(memGb); + if (isNaN(value)) + return "0G"; + + if (value < 10) + return value.toFixed(1) + "G"; // "0.0G" to "9.9G" + return Math.round(value) + "G"; // "10G" to "999G" + } + + // Formatting gigabytes with optional padding + function formatGigabytesDisplay(memGb, maxGb = null) { + const value = formatGigabytes(memGb === null ? 0 : memGb); + if (maxGb !== null) { + const padding = Math.max(4, formatGigabytes(maxGb).length); + return value.padStart(padding, " "); + } + return value; + } + + // Formatting percentage with optional padding + function formatPercentageDisplay(value, padding = false) { + return `${Math.round(value === null ? 0 : value)}%`.padStart(padding ? 4 : 0, " "); + } + + // Formatting ram usage + function formatRamDisplay({ + percent = false, + padding = false +} = {}) { + if (percent) { + return formatPercentageDisplay(memPercent, padding); + } else { + const maxGb = padding ? memTotalGb : null; + return formatGigabytesDisplay(memGb, maxGb); + } + } + + // -------------------------------------------- + // Function to start fetching and computing the cpu temperature + function updateCpuTemperature() { + // For AMD sensors (k10temp and zenpower), only use Tctl sensor + // temp1_input corresponds to Tctl (Temperature Control) on these sensors + if (root.cpuTempSensorName === "k10temp" || root.cpuTempSensorName === "zenpower") { + cpuTempReader.path = `${root.cpuTempHwmonPath}/temp1_input`; + cpuTempReader.reload(); + } // For Intel coretemp, start averaging all available sensors/cores + else if (root.cpuTempSensorName === "coretemp") { + root.intelTempValues = []; + root.intelTempFilesChecked = 0; + checkNextIntelTemp(); + } // For thermal_zone fallback (ARM SoCs, SCMI, etc.), read all CPU zones and take max + else if (root.cpuTempSensorName === "thermal_zone") { + cpuThermalZoneReader.currentZoneIndex = 0; + cpuThermalZoneReader.collectedTemps = []; + readNextCpuThermalZone(); + } + } + + // -------------------------------------------- + // Function to check next Intel temperature sensor + function checkNextIntelTemp() { + if (root.intelTempFilesChecked >= root.intelTempMaxFiles) { + // Calculate average of all found temperatures + if (root.intelTempValues.length > 0) { + let sum = 0; + for (var i = 0; i < root.intelTempValues.length; i++) { + sum += root.intelTempValues[i]; + } + root.cpuTemp = Math.round(sum / root.intelTempValues.length); + root.pushCpuTempHistory(); + } else { + Logger.w("SystemStat", "No temperature sensors found for coretemp"); + root.cpuTemp = 0; + root.pushCpuTempHistory(); + } + return; + } + + // Check next temperature file + root.intelTempFilesChecked++; + cpuTempReader.path = `${root.cpuTempHwmonPath}/temp${root.intelTempFilesChecked}_input`; + cpuTempReader.reload(); + } } diff --git a/config/quickshell/.config/quickshell/Services/ThemeIcons.qml b/config/quickshell/.config/quickshell/Services/ThemeIcons.qml index ba3203c..c4e861b 100644 --- a/config/quickshell/.config/quickshell/Services/ThemeIcons.qml +++ b/config/quickshell/.config/quickshell/Services/ThemeIcons.qml @@ -1,46 +1,289 @@ -import QtQuick -import Quickshell pragma Singleton +import QtQuick +import Quickshell +import qs.Utils + Singleton { - // ignore and fall back + id: root - id: root + property real scoreThreshold: 0.2 - function iconFromName(iconName, fallbackName) { - const fallback = fallbackName || "application-x-executable"; - try { - if (iconName && typeof Quickshell !== 'undefined' && Quickshell.iconPath) { - const p = Quickshell.iconPath(iconName, fallback); - if (p && p !== "") - return p; + // Manual overrides for tricky apps + property var substitutions: ({ + "code-url-handler": "visual-studio-code", + "Code": "visual-studio-code", + "gnome-tweaks": "org.gnome.tweaks", + "pavucontrol-qt": "pavucontrol", + "wps": "wps-office2019-kprometheus", + "wpsoffice": "wps-office2019-kprometheus", + "footclient": "foot" + }) - } - } catch (e) { - } - try { - return Quickshell.iconPath ? (Quickshell.iconPath(fallback, true) || "") : ""; - } catch (e2) { - return ""; - } + // Dynamic fixups + property var regexSubstitutions: [ + { + "regex": /^steam_app_(\d+)$/, + "replace": "steam_icon_$1" + }, + { + "regex": /Minecraft.*/, + "replace": "minecraft-launcher" + }, + { + "regex": /.*polkit.*/, + "replace": "system-lock-screen" + }, + { + "regex": /gcr.prompter/, + "replace": "system-lock-screen" + } + ] + + property list entryList: [] + property var preppedNames: [] + property var preppedIcons: [] + property var preppedIds: [] + + Component.onCompleted: refreshEntries() + + Connections { + target: DesktopEntries.applications + function onValuesChanged() { + refreshEntries(); + } + } + + function refreshEntries() { + if (typeof DesktopEntries === 'undefined') + return; + + const values = Array.from(DesktopEntries.applications.values); + if (values) { + entryList = values.sort((a, b) => a.name.localeCompare(b.name)); + updatePreppedData(); + } + } + + function updatePreppedData() { + if (typeof FuzzySort === 'undefined') + return; + + const list = Array.from(entryList); + preppedNames = list.map(a => ({ + name: FuzzySort.prepare(`${a.name} `), + entry: a + })); + preppedIcons = list.map(a => ({ + name: FuzzySort.prepare(`${a.icon} `), + entry: a + })); + preppedIds = list.map(a => ({ + name: FuzzySort.prepare(`${a.id} `), + entry: a + })); + } + + function iconForAppId(appId, fallbackName) { + const fallback = fallbackName || "application-x-executable"; + if (!appId) + return iconFromName(fallback, fallback); + + const entry = findAppEntry(appId); + if (entry) { + return iconFromName(entry.icon, fallback); } - // Resolve icon path for a DesktopEntries appId - safe on missing entries - function iconForAppId(appId, fallbackName) { - const fallback = fallbackName || "application-x-executable"; - if (!appId) - return iconFromName(fallback, fallback); + return iconFromName(appId, fallback); + } - try { - if (typeof DesktopEntries === 'undefined' || !DesktopEntries.byId) - return iconFromName(fallback, fallback); + // Robust lookup strategy + function findAppEntry(str) { + if (!str || str.length === 0) + return null; - const entry = (DesktopEntries.heuristicLookup) ? DesktopEntries.heuristicLookup(appId) : DesktopEntries.byId(appId); - const name = entry && entry.icon ? entry.icon : ""; - return iconFromName(name || fallback, fallback); - } catch (e) { - return iconFromName(fallback, fallback); - } + let result = null; + + if (result = checkHeuristic(str)) + return result; + if (result = checkSubstitutions(str)) + return result; + if (result = checkRegex(str)) + return result; + if (result = checkSimpleTransforms(str)) + return result; + if (result = checkFuzzySearch(str)) + return result; + if (result = checkCleanMatch(str)) + return result; + + return null; + } + + function iconFromName(iconName, fallbackName) { + const fallback = fallbackName || "application-x-executable"; + try { + if (iconName && typeof Quickshell !== 'undefined' && Quickshell.iconPath) { + const p = Quickshell.iconPath(iconName, fallback); + if (p && p !== "") + return p; + } + } catch (e) {} + + try { + return Quickshell.iconPath ? (Quickshell.iconPath(fallback, true) || "") : ""; + } catch (e2) { + return ""; + } + } + + function distroLogoPath() { + try { + return (typeof OSInfo !== 'undefined' && OSInfo.distroIconPath) ? OSInfo.distroIconPath : ""; + } catch (e) { + return ""; + } + } + + // --- Lookup Helpers --- + + function checkHeuristic(str) { + if (typeof DesktopEntries !== 'undefined' && DesktopEntries.heuristicLookup) { + const entry = DesktopEntries.heuristicLookup(str); + if (entry) + return entry; + } + return null; + } + + function checkSubstitutions(str) { + let effectiveStr = substitutions[str]; + if (!effectiveStr) + effectiveStr = substitutions[str.toLowerCase()]; + + if (effectiveStr && effectiveStr !== str) { + return findAppEntry(effectiveStr); + } + return null; + } + + function checkRegex(str) { + for (let i = 0; i < regexSubstitutions.length; i++) { + const sub = regexSubstitutions[i]; + const replaced = str.replace(sub.regex, sub.replace); + if (replaced !== str) { + return findAppEntry(replaced); + } + } + return null; + } + + function checkSimpleTransforms(str) { + if (typeof DesktopEntries === 'undefined' || !DesktopEntries.byId) + return null; + + const lower = str.toLowerCase(); + + const variants = [str, lower, getFromReverseDomain(str), getFromReverseDomain(str)?.toLowerCase(), normalizeWithHyphens(str), str.replace(/_/g, '-').toLowerCase(), str.replace(/-/g, '_').toLowerCase()]; + + for (let i = 0; i < variants.length; i++) { + const variant = variants[i]; + if (variant) { + const entry = DesktopEntries.byId(variant); + if (entry) + return entry; + } + } + return null; + } + + function checkFuzzySearch(str) { + if (typeof FuzzySort === 'undefined') + return null; + + // Check filenames (IDs) first + if (preppedIds.length > 0) { + let results = fuzzyQuery(str, preppedIds); + if (results.length === 0) { + const underscored = str.replace(/-/g, '_').toLowerCase(); + if (underscored !== str) + results = fuzzyQuery(underscored, preppedIds); + } + if (results.length > 0) + return results[0]; } + // Then icons + if (preppedIcons.length > 0) { + const results = fuzzyQuery(str, preppedIcons); + if (results.length > 0) + return results[0]; + } + + // Then names + if (preppedNames.length > 0) { + const results = fuzzyQuery(str, preppedNames); + if (results.length > 0) + return results[0]; + } + + return null; + } + + function checkCleanMatch(str) { + if (!str || str.length <= 3) + return null; + if (typeof DesktopEntries === 'undefined' || !DesktopEntries.byId) + return null; + + // Aggressive fallback: strip all separators + const cleanStr = str.toLowerCase().replace(/[\.\-_]/g, ''); + const list = Array.from(entryList); + + for (let i = 0; i < list.length; i++) { + const entry = list[i]; + const cleanId = (entry.id || "").toLowerCase().replace(/[\.\-_]/g, ''); + if (cleanId.includes(cleanStr) || cleanStr.includes(cleanId)) { + return entry; + } + } + return null; + } + + function fuzzyQuery(search, preppedData) { + if (!search || !preppedData || preppedData.length === 0) + return []; + return FuzzySort.go(search, preppedData, { + all: true, + key: "name" + }).map(r => r.obj.entry); + } + + function iconExists(iconName) { + if (!iconName || iconName.length === 0) + return false; + if (iconName.startsWith("/")) + return true; + + const path = Quickshell.iconPath(iconName, true); + return path && path.length > 0 && !path.includes("image-missing"); + } + + function getFromReverseDomain(str) { + if (!str) + return ""; + return str.split('.').slice(-1)[0]; + } + + function normalizeWithHyphens(str) { + if (!str) + return ""; + return str.toLowerCase().replace(/\s+/g, "-"); + } + + // Deprecated shim + function guessIcon(str) { + const entry = findAppEntry(str); + return entry ? entry.icon : "image-missing"; + } } diff --git a/config/quickshell/.config/quickshell/Services/WorkspaceManager.qml b/config/quickshell/.config/quickshell/Services/WorkspaceManager.qml deleted file mode 100644 index 7d34859..0000000 --- a/config/quickshell/.config/quickshell/Services/WorkspaceManager.qml +++ /dev/null @@ -1,56 +0,0 @@ -pragma Singleton -pragma ComponentBehavior: Bound - -import QtQuick -import Quickshell -import Quickshell.Io -import qs.Services -import qs.Utils - -Singleton { - id: root - - property ListModel workspaces - - workspaces: ListModel { - } - - function initNiri() { - updateNiriWorkspaces(); - } - - function updateNiriWorkspaces() { - const niriWorkspaces = Niri.workspaces || []; - workspaces.clear(); - for (let i = 0; i < niriWorkspaces.length; i++) { - const ws = niriWorkspaces[i]; - workspaces.append({ - "id": ws.id, - "idx": ws.idx || 1, - "name": ws.name || "", - "output": ws.output || "", - "isFocused": ws.isFocused === true, - "isActive": ws.isActive === true, - "isUrgent": ws.isUrgent === true - }); - } - workspacesChanged(); - } - - function switchToWorkspace(workspaceId) { - try { - Quickshell.execDetached(["niri", "msg", "action", "focus-workspace", workspaceId.toString()]); - } catch (e) { - Logger.error("WorkspaceManager", "Error switching Niri workspace:", e); - } - } - - Connections { - function onWorkspacesChanged() { - updateNiriWorkspaces(); - } - - target: Niri - } - -} diff --git a/config/quickshell/.config/quickshell/Utils/sha256.js b/config/quickshell/.config/quickshell/Services/sha256.js similarity index 100% rename from config/quickshell/.config/quickshell/Utils/sha256.js rename to config/quickshell/.config/quickshell/Services/sha256.js diff --git a/config/quickshell/.config/quickshell/Shaders/frag/rounded_image.frag b/config/quickshell/.config/quickshell/Shaders/frag/rounded_image.frag new file mode 100644 index 0000000..164906d --- /dev/null +++ b/config/quickshell/.config/quickshell/Shaders/frag/rounded_image.frag @@ -0,0 +1,98 @@ +#version 450 + +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; + +layout(binding = 1) uniform sampler2D source; + +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + float qt_Opacity; + // Custom properties with non-conflicting names + float itemWidth; + float itemHeight; + float sourceWidth; + float sourceHeight; + float cornerRadius; + float imageOpacity; + int fillMode; +} ubuf; + +// Function to calculate the signed distance from a point to a rounded box +float roundedBoxSDF(vec2 centerPos, vec2 boxSize, float radius) { + vec2 d = abs(centerPos) - boxSize + radius; + return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0) - radius; +} + +void main() { + // Get size from uniforms + vec2 itemSize = vec2(ubuf.itemWidth, ubuf.itemHeight); + vec2 sourceSize = vec2(ubuf.sourceWidth, ubuf.sourceHeight); + float cornerRadius = ubuf.cornerRadius; + float itemOpacity = ubuf.imageOpacity; + int fillMode = ubuf.fillMode; + + // Work in pixel space for accurate rounded rectangle calculation + vec2 pixelPos = qt_TexCoord0 * itemSize; + + // Calculate UV coordinates based on fill mode + vec2 imageUV = qt_TexCoord0; + + // fillMode constants from Qt: + // Image.Stretch = 0 + // Image.PreserveAspectFit = 1 + // Image.PreserveAspectCrop = 2 + // Image.Tile = 3 + // Image.TileVertically = 4 + // Image.TileHorizontally = 5 + // Image.Pad = 6 + + // Rounded corners always apply to full item bounds + vec2 roundedSize = itemSize; + vec2 roundedCenter = itemSize * 0.5; + + // Track if pixel is in letterbox area (for PreserveAspectFit) + bool inLetterbox = false; + + if (fillMode == 1) { // PreserveAspectFit + float itemAspect = itemSize.x / itemSize.y; + float sourceAspect = sourceSize.x / sourceSize.y; + + if (sourceAspect > itemAspect) { + // Image is wider than item, letterbox top/bottom + imageUV.y = (qt_TexCoord0.y - 0.5) * (sourceAspect / itemAspect) + 0.5; + } else { + // Image is taller than item, letterbox left/right + imageUV.x = (qt_TexCoord0.x - 0.5) * (itemAspect / sourceAspect) + 0.5; + } + + // Check if in letterbox area + inLetterbox = (imageUV.x < 0.0 || imageUV.x > 1.0 || imageUV.y < 0.0 || imageUV.y > 1.0); + } else if (fillMode == 2) { // PreserveAspectCrop + float itemAspect = itemSize.x / itemSize.y; + float sourceAspect = sourceSize.x / sourceSize.y; + + if (sourceAspect > itemAspect) { + // Image is wider than item, crop left/right. + imageUV.x = (qt_TexCoord0.x - 0.5) * (itemAspect / sourceAspect) + 0.5; + } else { + // Image is taller than item, crop top/bottom. + imageUV.y = (qt_TexCoord0.y - 0.5) * (sourceAspect / itemAspect) + 0.5; + } + } + // For Stretch (0) or other modes, use qt_TexCoord0 as-is + + // Calculate distance to rounded rectangle edge using the correct bounds + vec2 centerOffset = pixelPos - roundedCenter; + float distance = roundedBoxSDF(centerOffset, roundedSize * 0.5, cornerRadius); + + // Create smooth alpha mask for edge with anti-aliasing + float alpha = 1.0 - smoothstep(-0.5, 0.5, distance); + + // Sample the texture (or use transparent for letterbox) + vec4 color = inLetterbox ? vec4(0.0) : texture(source, imageUV); + + // Apply the rounded mask and opacity + float finalAlpha = color.a * alpha * itemOpacity * ubuf.qt_Opacity; + fragColor = vec4(color.rgb * finalAlpha, finalAlpha); +} \ No newline at end of file diff --git a/config/quickshell/.config/quickshell/Shaders/frag/weather_cloud.frag b/config/quickshell/.config/quickshell/Shaders/frag/weather_cloud.frag new file mode 100644 index 0000000..1991ce3 --- /dev/null +++ b/config/quickshell/.config/quickshell/Shaders/frag/weather_cloud.frag @@ -0,0 +1,123 @@ +#version 450 +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + float qt_Opacity; + float time; + float itemWidth; + float itemHeight; + vec4 bgColor; + float cornerRadius; + float alternative; +} ubuf; + +// Signed distance function for rounded rectangle +float roundedBoxSDF(vec2 center, vec2 size, float radius) { + vec2 q = abs(center) - size + radius; + return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - radius; +} + +float hash(vec2 p) { + p = fract(p * vec2(234.34, 435.345)); + p += dot(p, p + 34.23); + return fract(p.x * p.y); +} + +// Perlin-like noise +float noise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + f = f * f * (3.0 - 2.0 * f); // Smooth interpolation + float a = hash(i); + float b = hash(i + vec2(1.0, 0.0)); + float c = hash(i + vec2(0.0, 1.0)); + float d = hash(i + vec2(1.0, 1.0)); + return mix(mix(a, b, f.x), mix(c, d, f.x), f.y); +} + +// Turbulent noise for natural fog +float turbulence(vec2 p, float iTime) { + float t = 0.0; + float scale = 1.0; + for(int i = 0; i < 5; i++) { + t += abs(noise(p * scale + iTime * 0.1 * scale)) / scale; + scale *= 2.0; + } + return t; +} + +void main() { + vec2 uv = qt_TexCoord0; + + vec4 col = vec4(ubuf.bgColor.rgb, 1.0); + + // Different parameters for fog vs clouds + float timeSpeed, layerScale1, layerScale2, layerScale3; + float flowSpeed1, flowSpeed2; + float densityMin, densityMax; + float baseOpacity; + float pulseAmount; + + if (ubuf.alternative > 0.5) { + // Fog: slower, larger scale, more uniform + timeSpeed = 0.03; + layerScale1 = 1.0; + layerScale2 = 2.5; + layerScale3 = 2.0; + flowSpeed1 = 0.00; + flowSpeed2 = 0.02; + densityMin = 0.1; + densityMax = 0.9; + baseOpacity = 0.75; + pulseAmount = 0.05; + } else { + // Clouds: faster, smaller scale, puffier + timeSpeed = 0.08; + layerScale1 = 2.0; + layerScale2 = 4.0; + layerScale3 = 6.0; + flowSpeed1 = 0.03; + flowSpeed2 = 0.04; + densityMin = 0.35; + densityMax = 0.75; + baseOpacity = 0.4; + pulseAmount = 0.15; + } + + float iTime = ubuf.time * timeSpeed; + + // Create flowing patterns with multiple layers + vec2 flow1 = vec2(iTime * flowSpeed1, iTime * flowSpeed1 * 0.7); + vec2 flow2 = vec2(-iTime * flowSpeed2, iTime * flowSpeed2 * 0.8); + + float fog1 = noise(uv * layerScale1 + flow1); + float fog2 = noise(uv * layerScale2 + flow2); + float fog3 = turbulence(uv * layerScale3, iTime); + + float fogPattern = fog1 * 0.5 + fog2 * 0.3 + fog3 * 0.2; + float fogDensity = smoothstep(densityMin, densityMax, fogPattern); + + // Gentle pulsing + float pulse = sin(iTime * 0.4) * pulseAmount + (1.0 - pulseAmount); + fogDensity *= pulse; + + vec3 hazeColor = vec3(0.88, 0.90, 0.93); + float hazeOpacity = fogDensity * baseOpacity; + vec3 fogContribution = hazeColor * hazeOpacity; + float fogAlpha = hazeOpacity; + + vec3 resultRGB = fogContribution + col.rgb * (1.0 - fogAlpha); + float resultAlpha = fogAlpha + col.a * (1.0 - fogAlpha); + + // Calculate corner mask + vec2 pixelPos = qt_TexCoord0 * vec2(ubuf.itemWidth, ubuf.itemHeight); + vec2 center = pixelPos - vec2(ubuf.itemWidth, ubuf.itemHeight) * 0.5; + vec2 halfSize = vec2(ubuf.itemWidth, ubuf.itemHeight) * 0.5; + float dist = roundedBoxSDF(center, halfSize, ubuf.cornerRadius); + float cornerMask = 1.0 - smoothstep(-1.0, 0.0, dist); + + // Apply global opacity and corner mask + float finalAlpha = resultAlpha * ubuf.qt_Opacity * cornerMask; + fragColor = vec4(resultRGB * (finalAlpha / max(resultAlpha, 0.001)), finalAlpha); +} \ No newline at end of file diff --git a/config/quickshell/.config/quickshell/Shaders/frag/weather_rain.frag b/config/quickshell/.config/quickshell/Shaders/frag/weather_rain.frag new file mode 100644 index 0000000..bd3bc39 --- /dev/null +++ b/config/quickshell/.config/quickshell/Shaders/frag/weather_rain.frag @@ -0,0 +1,84 @@ +#version 450 + +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; + +layout(binding = 1) uniform sampler2D source; + +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + float qt_Opacity; + float time; + float itemWidth; + float itemHeight; + vec4 bgColor; + float cornerRadius; +} ubuf; + +// Signed distance function for rounded rectangle +float roundedBoxSDF(vec2 center, vec2 size, float radius) { + vec2 q = abs(center) - size + radius; + return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - radius; +} + +vec3 hash3(vec2 p) { + vec3 q = vec3(dot(p, vec2(127.1, 311.7)), + dot(p, vec2(269.5, 183.3)), + dot(p, vec2(419.2, 371.9))); + return fract(sin(q) * 43758.5453); +} + +float noise(vec2 x, float iTime) { + vec2 p = floor(x); + vec2 f = fract(x); + + float va = 0.0; + for (int j = -2; j <= 2; j++) { + for (int i = -2; i <= 2; i++) { + vec2 g = vec2(float(i), float(j)); + vec3 o = hash3(p + g); + vec2 r = g - f + o.xy; + float d = sqrt(dot(r, r)); + float ripple = max(mix(smoothstep(0.99, 0.999, max(cos(d - iTime * 2.0 + (o.x + o.y) * 5.0), 0.0)), 0.0, d), 0.0); + va += ripple; + } + } + + return va; +} + +void main() { + vec2 uv = qt_TexCoord0; + float iTime = ubuf.time * 0.07; + + // Aspect ratio correction for circular ripples + float aspect = ubuf.itemWidth / ubuf.itemHeight; + vec2 uvAspect = vec2(uv.x * aspect, uv.y); + + float f = noise(6.0 * uvAspect, iTime) * smoothstep(0.0, 0.2, sin(uv.x * 3.141592) * sin(uv.y * 3.141592)); + + // Calculate normal from noise for distortion + float normalScale = 0.5; + vec2 e = normalScale / vec2(ubuf.itemWidth, ubuf.itemHeight); + vec2 eAspect = vec2(e.x * aspect, e.y); + float cx = noise(6.0 * (uvAspect + eAspect), iTime) * smoothstep(0.0, 0.2, sin((uv.x + e.x) * 3.141592) * sin(uv.y * 3.141592)); + float cy = noise(6.0 * (uvAspect + eAspect.yx), iTime) * smoothstep(0.0, 0.2, sin(uv.x * 3.141592) * sin((uv.y + e.y) * 3.141592)); + vec2 n = vec2(cx - f, cy - f); + + // Scale distortion back to texture space (undo aspect correction for X) + vec2 distortion = vec2(n.x / aspect, n.y); + + // Sample source with distortion + vec4 col = texture(source, uv + distortion); + + // Apply rounded corner mask + vec2 pixelPos = qt_TexCoord0 * vec2(ubuf.itemWidth, ubuf.itemHeight); + vec2 center = pixelPos - vec2(ubuf.itemWidth, ubuf.itemHeight) * 0.5; + vec2 halfSize = vec2(ubuf.itemWidth, ubuf.itemHeight) * 0.5; + float dist = roundedBoxSDF(center, halfSize, ubuf.cornerRadius); + float cornerMask = 1.0 - smoothstep(-1.0, 0.0, dist); + + // Output with premultiplied alpha + float finalAlpha = col.a * ubuf.qt_Opacity * cornerMask; + fragColor = vec4(col.rgb * finalAlpha, finalAlpha); +} diff --git a/config/quickshell/.config/quickshell/Shaders/frag/weather_snow.frag b/config/quickshell/.config/quickshell/Shaders/frag/weather_snow.frag new file mode 100644 index 0000000..e00a952 --- /dev/null +++ b/config/quickshell/.config/quickshell/Shaders/frag/weather_snow.frag @@ -0,0 +1,75 @@ +#version 450 + +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; + +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + float qt_Opacity; + float time; + float itemWidth; + float itemHeight; + vec4 bgColor; + float cornerRadius; +} ubuf; + +// Signed distance function for rounded rectangle +float roundedBoxSDF(vec2 center, vec2 size, float radius) { + vec2 q = abs(center) - size + radius; + return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - radius; +} + +void main() { + // Aspect ratio correction + float aspect = ubuf.itemWidth / ubuf.itemHeight; + vec2 uv = qt_TexCoord0; + uv.x *= aspect; + uv.y = 1.0 - uv.y; + + float iTime = ubuf.time * 0.15; + + float snow = 0.0; + + for (int k = 0; k < 6; k++) { + for (int i = 0; i < 12; i++) { + float cellSize = 2.0 + (float(i) * 3.0); + float downSpeed = 0.3 + (sin(iTime * 0.4 + float(k + i * 20)) + 1.0) * 0.00008; + + vec2 uvAnim = uv + vec2( + 0.01 * sin((iTime + float(k * 6185)) * 0.6 + float(i)) * (5.0 / float(i + 1)), + downSpeed * (iTime + float(k * 1352)) * (1.0 / float(i + 1)) + ); + + vec2 uvStep = (ceil((uvAnim) * cellSize - vec2(0.5, 0.5)) / cellSize); + float x = fract(sin(dot(uvStep.xy, vec2(12.9898 + float(k) * 12.0, 78.233 + float(k) * 315.156))) * 43758.5453 + float(k) * 12.0) - 0.5; + float y = fract(sin(dot(uvStep.xy, vec2(62.2364 + float(k) * 23.0, 94.674 + float(k) * 95.0))) * 62159.8432 + float(k) * 12.0) - 0.5; + + float randomMagnitude1 = sin(iTime * 2.5) * 0.7 / cellSize; + float randomMagnitude2 = cos(iTime * 1.65) * 0.7 / cellSize; + + float d = 5.0 * distance((uvStep.xy + vec2(x * sin(y), y) * randomMagnitude1 + vec2(y, x) * randomMagnitude2), uvAnim.xy); + + float omiVal = fract(sin(dot(uvStep.xy, vec2(32.4691, 94.615))) * 31572.1684); + if (omiVal < 0.03) { + float newd = (x + 1.0) * 0.4 * clamp(1.9 - d * (15.0 + (x * 6.3)) * (cellSize / 1.4), 0.0, 1.0); + snow += newd; + } + } + } + + // Blend white snow over background color + float snowAlpha = clamp(snow * 2.0, 0.0, 1.0); + vec3 snowColor = vec3(1.0); + vec3 blended = mix(ubuf.bgColor.rgb, snowColor, snowAlpha); + + // Apply rounded corner mask + vec2 pixelPos = qt_TexCoord0 * vec2(ubuf.itemWidth, ubuf.itemHeight); + vec2 center = pixelPos - vec2(ubuf.itemWidth, ubuf.itemHeight) * 0.5; + vec2 halfSize = vec2(ubuf.itemWidth, ubuf.itemHeight) * 0.5; + float dist = roundedBoxSDF(center, halfSize, ubuf.cornerRadius); + float cornerMask = 1.0 - smoothstep(-1.0, 0.0, dist); + + // Output with premultiplied alpha + float finalAlpha = ubuf.qt_Opacity * cornerMask; + fragColor = vec4(blended * finalAlpha, finalAlpha); +} diff --git a/config/quickshell/.config/quickshell/Shaders/frag/weather_stars.frag b/config/quickshell/.config/quickshell/Shaders/frag/weather_stars.frag new file mode 100644 index 0000000..1048cae --- /dev/null +++ b/config/quickshell/.config/quickshell/Shaders/frag/weather_stars.frag @@ -0,0 +1,130 @@ +#version 450 +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + float qt_Opacity; + float time; + float itemWidth; + float itemHeight; + vec4 bgColor; + float cornerRadius; +} ubuf; + +// Signed distance function for rounded rectangle +float roundedBoxSDF(vec2 center, vec2 size, float radius) { + vec2 q = abs(center) - size + radius; + return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - radius; +} + +float hash(vec2 p) { + p = fract(p * vec2(234.34, 435.345)); + p += dot(p, p + 34.23); + return fract(p.x * p.y); +} + +vec2 hash2(vec2 p) { + p = fract(p * vec2(234.34, 435.345)); + p += dot(p, p + 34.23); + return fract(vec2(p.x * p.y, p.y * p.x)); +} + +float stars(vec2 uv, float density, float iTime) { + vec2 gridUV = uv * density; + vec2 gridID = floor(gridUV); + vec2 gridPos = fract(gridUV); + + float starField = 0.0; + + // Check neighboring cells for stars + for (int y = -1; y <= 1; y++) { + for (int x = -1; x <= 1; x++) { + vec2 offset = vec2(float(x), float(y)); + vec2 cellID = gridID + offset; + + // Random position within cell + vec2 starPos = hash2(cellID); + + // Only create a star for some cells (sparse distribution) + float starChance = hash(cellID + vec2(12.345, 67.890)); + if (starChance > 0.85) { + // Star position in grid space + vec2 toStar = (offset + starPos - gridPos); + float dist = length(toStar) * density; // Scale distance to pixel space + + float starSize = 1.5; + + // Star brightness variation + float brightness = hash(cellID + vec2(23.456, 78.901)) * 0.6 + 0.4; + + // Twinkling effect + float twinkleSpeed = hash(cellID + vec2(34.567, 89.012)) * 3.0 + 2.0; + float twinklePhase = iTime * twinkleSpeed + hash(cellID) * 6.28; + float twinkle = pow(sin(twinklePhase) * 0.5 + 0.5, 3.0); // Sharp on/off + + // Sharp star core + float star = 0.0; + if (dist < starSize) { + star = 1.0 * brightness * (0.3 + twinkle * 0.7); + + // Add tiny cross-shaped glow for brighter stars + if (brightness > 0.7) { + float crossGlow = max( + exp(-abs(toStar.x) * density * 5.0), + exp(-abs(toStar.y) * density * 5.0) + ) * 0.3 * twinkle; + star += crossGlow; + } + } + + starField += star; + } + } + } + + return starField; +} + +void main() { + vec2 uv = qt_TexCoord0; + float iTime = ubuf.time * 0.01; + + // Base background color + vec4 col = vec4(ubuf.bgColor.rgb, 1.0); + + // Aspect ratio for consistent stars + float aspect = ubuf.itemWidth / ubuf.itemHeight; + vec2 uvAspect = vec2(uv.x * aspect, uv.y); + + // Generate multiple layers of stars at different densities + float stars1 = stars(uvAspect, 40.0, iTime); // Tiny distant stars + float stars2 = stars(uvAspect + vec2(0.5, 0.3), 25.0, iTime * 1.3); // Small stars + float stars3 = stars(uvAspect + vec2(0.25, 0.7), 15.0, iTime * 0.9); // Bigger stars + + // Star colors with slight variation + vec3 starColor1 = vec3(0.85, 0.9, 1.0); // Faint blue-white + vec3 starColor2 = vec3(0.95, 0.97, 1.0); // White + vec3 starColor3 = vec3(1.0, 0.98, 0.95); // Warm white + + // Combine star layers + vec3 starsRGB = starColor1 * stars1 * 0.6 + + starColor2 * stars2 * 0.8 + + starColor3 * stars3 * 1.0; + + float starsAlpha = clamp(stars1 * 0.6 + stars2 * 0.8 + stars3, 0.0, 1.0); + + // Apply rounded corner mask + vec2 pixelPos = qt_TexCoord0 * vec2(ubuf.itemWidth, ubuf.itemHeight); + vec2 center = pixelPos - vec2(ubuf.itemWidth, ubuf.itemHeight) * 0.5; + vec2 halfSize = vec2(ubuf.itemWidth, ubuf.itemHeight) * 0.5; + float dist = roundedBoxSDF(center, halfSize, ubuf.cornerRadius); + float cornerMask = 1.0 - smoothstep(-1.0, 0.0, dist); + + // Add stars on top + vec3 resultRGB = starsRGB * starsAlpha + col.rgb * (1.0 - starsAlpha); + float resultAlpha = starsAlpha + col.a * (1.0 - starsAlpha); + + // Apply global opacity and corner mask + float finalAlpha = resultAlpha * ubuf.qt_Opacity * cornerMask; + fragColor = vec4(resultRGB * (finalAlpha / max(resultAlpha, 0.001)), finalAlpha); +} \ No newline at end of file diff --git a/config/quickshell/.config/quickshell/Shaders/frag/weather_sun.frag b/config/quickshell/.config/quickshell/Shaders/frag/weather_sun.frag new file mode 100644 index 0000000..bfc0cd9 --- /dev/null +++ b/config/quickshell/.config/quickshell/Shaders/frag/weather_sun.frag @@ -0,0 +1,148 @@ +#version 450 +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; + +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + float qt_Opacity; + float time; + float itemWidth; + float itemHeight; + vec4 bgColor; + float cornerRadius; +} ubuf; + +// Signed distance function for rounded rectangle +float roundedBoxSDF(vec2 center, vec2 size, float radius) { + vec2 q = abs(center) - size + radius; + return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - radius; +} + +float hash(vec2 p) { + p = fract(p * vec2(234.34, 435.345)); + p += dot(p, p + 34.23); + return fract(p.x * p.y); +} + +float noise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + f = f * f * (3.0 - 2.0 * f); + + float a = hash(i); + float b = hash(i + vec2(1.0, 0.0)); + float c = hash(i + vec2(0.0, 1.0)); + float d = hash(i + vec2(1.0, 1.0)); + + return mix(mix(a, b, f.x), mix(c, d, f.x), f.y); +} + +// God rays originating from sun position +float sunRays(vec2 uv, vec2 sunPos, float iTime) { + vec2 toSun = uv - sunPos; + float angle = atan(toSun.y, toSun.x); + float dist = length(toSun); + + float rayCount = 7; + + // Radial pattern + float rays = sin(angle * rayCount + sin(iTime * 0.25)) * 0.5 + 0.5; + rays = pow(rays, 3.0); + + // Fade with distance + float falloff = 1.0 - smoothstep(0.0, 1.2, dist); + + return rays * falloff * 0.15; +} + +// Atmospheric shimmer / heat haze +float atmosphericShimmer(vec2 uv, float iTime) { + // Multiple layers of noise for complexity + float n1 = noise(uv * 5.0 + vec2(iTime * 0.1, iTime * 0.05)); + float n2 = noise(uv * 8.0 - vec2(iTime * 0.08, iTime * 0.12)); + float n3 = noise(uv * 12.0 + vec2(iTime * 0.15, -iTime * 0.1)); + + return (n1 * 0.5 + n2 * 0.3 + n3 * 0.2) * 0.15; +} + +float sunCore(vec2 uv, vec2 sunPos, float iTime) { + vec2 toSun = uv - sunPos; + float dist = length(toSun); + + // Main bright spot + float mainFlare = exp(-dist * 15.0) * 2.0; + + // Secondary reflection spots along the line + float flares = 0.0; + for (int i = 1; i <= 3; i++) { + vec2 flarePos = sunPos + toSun * float(i) * 0.3; + float flareDist = length(uv - flarePos); + float flareSize = 0.02 + float(i) * 0.01; + flares += smoothstep(flareSize * 2.0, flareSize * 0.5, flareDist) * (0.3 / float(i)); + } + + // Pulsing effect + float pulse = sin(iTime) * 0.1 + 0.9; + + return (mainFlare + flares) * pulse; +} + +void main() { + vec2 uv = qt_TexCoord0; + float iTime = ubuf.time * 0.08; + + // Sample the source + vec4 col = vec4(ubuf.bgColor.rgb, 1.0); + + vec2 sunPos = vec2(0.85, 0.2); + + // Aspect ratio correction + float aspect = ubuf.itemWidth / ubuf.itemHeight; + vec2 uvAspect = vec2(uv.x * aspect, uv.y); + vec2 sunPosAspect = vec2(sunPos.x * aspect, sunPos.y); + + // Generate sunny effects + float rays = sunRays(uvAspect, sunPosAspect, iTime); + float shimmerEffect = atmosphericShimmer(uv, iTime); + float flare = sunCore(uvAspect, sunPosAspect, iTime); + + // Warm sunny colors + vec3 sunColor = vec3(1.0, 0.95, 0.7); // Warm golden yellow + vec3 skyColor = vec3(0.9, 0.95, 1.0); // Light blue tint + vec3 shimmerColor = vec3(1.0, 0.98, 0.85); // Subtle warm shimmer + + // Apply rounded corner mask + vec2 pixelPos = qt_TexCoord0 * vec2(ubuf.itemWidth, ubuf.itemHeight); + vec2 center = pixelPos - vec2(ubuf.itemWidth, ubuf.itemHeight) * 0.5; + vec2 halfSize = vec2(ubuf.itemWidth, ubuf.itemHeight) * 0.5; + float dist = roundedBoxSDF(center, halfSize, ubuf.cornerRadius); + float cornerMask = 1.0 - smoothstep(-1.0, 0.0, dist); + + vec3 resultRGB = col.rgb; + float resultAlpha = col.a; + + // Add sun rays + vec3 raysContribution = sunColor * rays; + float raysAlpha = rays * 0.4; + resultRGB = raysContribution + resultRGB * (1.0 - raysAlpha); + resultAlpha = raysAlpha + resultAlpha * (1.0 - raysAlpha); + + // Add atmospheric shimmer + vec3 shimmerContribution = shimmerColor * shimmerEffect; + float shimmerAlpha = shimmerEffect * 0.1; + resultRGB = shimmerContribution + resultRGB * (1.0 - shimmerAlpha); + resultAlpha = shimmerAlpha + resultAlpha * (1.0 - shimmerAlpha); + + // Add bright sun core + vec3 flareContribution = sunColor * flare; + float flareAlpha = clamp(flare, 0.0, 1.0) * 0.6; + resultRGB = flareContribution + resultRGB * (1.0 - flareAlpha); + resultAlpha = flareAlpha + resultAlpha * (1.0 - flareAlpha); + + // Overall warm sunny tint + resultRGB = mix(resultRGB, resultRGB * vec3(1.08, 1.04, 0.98), 0.15); + + // Apply global opacity and corner mask + float finalAlpha = resultAlpha * ubuf.qt_Opacity * cornerMask; + fragColor = vec4(resultRGB * (finalAlpha / max(resultAlpha, 0.001)), finalAlpha); +} \ No newline at end of file diff --git a/config/quickshell/.config/quickshell/Shaders/qsb/circled_image.frag.qsb b/config/quickshell/.config/quickshell/Shaders/qsb/circled_image.frag.qsb deleted file mode 100644 index 37a99ef08dbbc9febf0363349dbb46c1cdf5b81a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1717 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 P{i%d`5uE-F>oD`KHOXkE literal 2767 zcmV;=3NZBm04+>-ob6iudmF_O9)BApE#HuoLN_s_rywhe>+oTtBqSyuZNSDhB;bbQ ztXoM3(w%a*=g5ITfTlnxv_JL#>R;3TLg_Pm@5-yQ>;x#K^zl8CL;O<7Y(@BzynmLm^^A|<(5{@%->OU zoYpC&IuT7$+IwELP~&=l%)^(-BSkR<)Fq;QK%BPF_nC)>pt49E@~B1uDcZ!Kr;T^{ z>j4du)TYZ6kWY%5P+8bX<)NO+1(Fm{gPNqEbYUl@160#N3Q1B-%jA(y0i~2c@zTzU z0kw6c<-nOO?WAbOd_N@=QA#n@ND`5Q1!CFvzIeI#^0!#~pBx#Unqd3;Xp;U4?)@~Z zN8Cr_`v3EnRxXtj<(FqqaN^O=X{6`zy)>bx#dq5aLc)sN)+;`u`4en|MyQOey+EV- znXfTTkM-yYWQxt3!vBUh1m0;R3@G?C^p{RBYHL7U3O{v3xtLl~dw8Ef9Z!SlE!e+KJn=y~{K=u=ov);|l|6BwWA1#3LEdoG*) zDMJUEymgB8Bk1y+57Iq!4Zh@eqoE(85nWyx$NF3|_=+(;(<&G5&D?x$!5-`X0kRVG znZ6Akug@Rh57W=kn2wbxG0giX4eMC2F6YJ5RxCJ2=5q8&i)Q?p9DOlIzn0U#lB3_w z;k_JP%i(2?Zdx?2MJFfkf_{<4bgs-Izc`1gSkDvS8_cu7JY!*=1AmFew7ut{$NZBt zqT}|wW#a|dn73?H!Dsv&XoEiu{3#3nBKV9y37Ta*{!7SH^*hi{SUy|=-(X$_=8%PX1^oRqq3K2Brvv?0EI*eZ=le78UWG49@be;g z#?NKsm7!OVcekOx3f{6c&s7WmI`qE+{xW#yfPW18>j206f6=g}dF^>Q@NPeZ5`OwOHoqF^wy*9N^nD#;90DFEh01;;ftzpy!FKCsTRew(JP9lHOhfvHp^J5 zG!BK+l3_zNownC?{A@g@Qmzz>tkn*}Oi=EYgkRn)mV}mxX*e-oxLGJ{M1e0jYn(ox zINUtEsAg6dsdZLbyF zkbW-;eL*=UcJad+uj9pDo8z*PXS&BlwdamKbS9k<)oo7LDAx~o%hz}&{W+}lOJ)%B zFw?idNjr*EQ~EEqI!#Yh#qlyPM#fE)>~Id2MEyFaiqjjaSlSwPYm&2~7PX>SRE3gV zmBw;r!O1pYsc*IW9HB<6cSJD~Wq0ajztP&yx5zt0aE2 zRT5A9pOnNP9Q1-1r7CN0_1J5i<-qN|H154L?!7erFfWb&?N`R$rv4tKaTrJ7<`=a$ zYU^peE@NGG_}jY?Bud84dd0kk#L;!P?Y$Gl*_>v(v)VhiS1-k!=yL^+tGbrLwWr50oJV=ox}6T9~bRa^ZD>X7Mzo) z&~_*lM`R@{;Va5hQn(q&MP0YS34;rX@>EdEe96~%Th?Fb(bra%E-t_Bo{ggzD$ZT@NaN zx#Lkqrd95CcRQ!3lq>aex4X+JMSb8g1y9xOJF9?O)@en?arzUul+V#SomaSbe_qj- zn-+BNiAAM+f~Vex+wsI=mqUV{jUA3FV0yO$i~4`~!~#R+&n#^A|JtcVeOm{r-&G63 z)-z2gt0CpAusFGr@DwV%^vK_@T!d{_m@lK zB(`wI7%+5ZA<{$!vN`3xd2ZEQtK9S(xM?@tQT#rEYy9h}PV6<>o~X6Fup#`wYlKn4 zRXu2gQ7nCNc=~cXNRl9IOn2f)MQXDnlf#9=R1nr$sV~I~ZK=GLn<&2(te>>_NznF> z@jvC}$wDDz6C(85GU<3VDYVw0f V)?6pu6>I*2BCn&be*?wwtCWWGn>qjh diff --git a/config/quickshell/.config/quickshell/Shaders/qsb/weather_cloud.frag.qsb b/config/quickshell/.config/quickshell/Shaders/qsb/weather_cloud.frag.qsb new file mode 100644 index 0000000000000000000000000000000000000000..2fe97346064877322147be42918abe65d1ae0157 GIT binary patch literal 6082 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 I1J0z)mkDV1z5oCK literal 0 HcmV?d00001 diff --git a/config/quickshell/.config/quickshell/Shaders/qsb/weather_rain.frag.qsb b/config/quickshell/.config/quickshell/Shaders/qsb/weather_rain.frag.qsb new file mode 100644 index 0000000000000000000000000000000000000000..5c06dd66e8e0680dc094317884ac5ca9814eb25c GIT binary patch literal 5275 zcmV;M6lCiF0AetBob6o=lv_u2o>}j%ZN>S;`6tOFPMoJ*S=P^Lwd;)>JL|+Yacalw z#7Q&e%vF(G6nA!LvcLJ1+~kpsjh zd9sICq)DcUOI+fSB1sTJ1_&YKd3;F7hZ{JH*u*0yDGBAWP?zS9sya;ONQ2mfkQJoS z`d-wemWv6bhc^(D_{1X)X%a$Cfy5?F`V94DHB@$z8Zk+MIK(H5m~@l!F8EnNeBzJ_ zu^_mmm*82#o<%BTFL6kb_+%a`+j^<2ZmDb|7I8?4%o86F z@dm-$q49>m+okb_!Mg*oypIraX)6{*G8>Di<#y1H#`+$ui+1|9%cx zCgx;>kaNkP$ipzk7#SohkelV8SK|Ayuro{|_`U)^D`Dph@Rtd>L2@SYyF$=Izqvnh2IRmj)Gnjpw}W+PILVcjC0>I+B&N72|`FZ{oFN*o+N}k^we9YV`NCkZh*|B zrjz3aAA8lbqBjykp4gFX>0Aw&jhfCi-0oXG*V38h^Zwu?EuAfpnbvf+6GC3|-I1+4 z_L~qpMJzWXmYX2Yf8Rm~dG7h=pI3Aq>$I8-h-=u4WgdEpjhXKx;0v)IAy5|1g zKs)2xE5`3>t=TP-{)E~G`>2eU+qdxj-N+xOZScm)kjS075;*S}G9b>LDeUiG*VdQs z#dihh4{9`*|7c+RlY#M12gV-{&|eP7KNXN`1FdTI`>5HcalWcpeh^Z#{{g;|`OlEN{Sf?n zk*EKH-Ve3hJp;a8n?KTO^BHZ9{|>43{{dgw`!RUBy=OIhKh|WQ!(7j5wf=latv{#f z@WJmxj_2^(;Hfnlpq*>BjLJ3JNtO$GkV+o^QN4p&&mk)Hxr|!RVJi7*J^8&hOxy9V zr0wtjm3-Zgvo$|fYqIB1=_4Ok>v=BiUe9who%5*76PM#T zT7EB}vKLYGg;e%F_d)-qRLWea>0Cr*KceW113H&bDZ}~dEMQdHx&(gk{)B1yyNt@7 zMPIWuRPq&lIhE&f*WpSk`N|iAO5H2r3-65#%)|TH_2^|)jVG}F zicV75PpP_2Q7Oaud&!XKS<|qS!W_KcN^3SVRQ5OO4j8B1_e7RTzLJ@s-S@;Km3$T3 z1}gO?Q3ImKqH?|s@P+q!Q<^V1D*Hb*&&Cj6uBMW&WHyEPvYAT0^5q&T^)_q!eVR)C zlc*PgBdPRZ8vgO#bBi|Dbs_$2rIN2?wuSh!ol3s)=VestZP)y{o=X0cIAeI9d%c#M zomBS1e2n+FJGHSJsqBmS81Hv))cl!&KR0OgyN60Xm*@TP9&wf}7h^Z^y^TJa+u98| z-e=#W*|?R;-dpYe+qC`9@ze-p)LxvW5_jDOd%W+S)%MHFfd%$ybL6Syt61(pEDvkB zHL2vQb7qc8JyWw&pt8?bGDRxo3h;&Z{6)y~JyU`&JIM<1yknk9UWvA`=z!*fLnUtD zYsuyJQ>o)fyb5?!<5j^M5dF+@;h#&TK9}d?cOnjsOZnKdgpvtD z1wIw`3GP4sJSofXFFgEJ-&f)L%2qEhKu*^%)CHsy6I}>>eot`vrO3%3#+2Meu&wsj z#gM&7%iSfIvvod$cZqgiUkY7@y>S6}s&+1ikNTN?1^7zlO33Kvu>t-n)P59k7|=Jg zu~Cd&0l5(^&KPW~7}jBY1p1s8*KEfiqwd-D7`p~?>Yh$u-u0T#N#slIs}$stqJJ6? zv|9T!#B)jYJh;8yXJL(3V~sQ7Sw`m?XCe1S%rgbv22JLwfXpWFuGVBW2V|}VZ;Qy+ zu<-FZ*x!V4zQ*I==kUC9D|}o9oo!mLxs|l{-peqyU6Z{YvJs5)JYBElX&C)D5zk87 zc{)ei7v~~p=j+cJ0?$rv3_L5@qtX1Clg&%(xC zn12C0HQ&#nt`&VZXmx(v1DU(E^YdQJaS6t`-NWFkbMuwhABuhz_O$8=?g#JH+S&RG z(7Ru|M}85p>UYgAfv;p<0~!6Uc`f+rY=0f}UaR@=Aovexa=#2e9@OG}J>oqLlU$(Y?-irNo4;d2l+rWER+xu?|?0pOL?VxYh?EM<{qq6sQ z@E+FAu(t=!uvsmhM-UIcQy#&c!doG{N2NF_(CMujUbA3%b;qr;WFir%c~-#@)Vx!g zuQ9vqn*LN5+o}3fkx1S58U>%tHSEY;5ylv+nEn_$;OB2Jea~r537)XPnLBG{!SNS$ zuJ2SVopyYyvezm4^Fih|%PGzKlDl9P#@JkG+AX`DE?029s^#rAi%z3H6*)W=i8QK? z?RpjNAZyGuY>g$usGF5q+453bSlw-S1#2o2Sunjtr&{8(q`236tmd@qdc_1EvkGZ8 zZ`SA2h8V6z5w{pPz;fb?QFMKy784eXWGWj^OioTtPK;&8lBr~3GM&xDSUQ=EXA_A; zGL=eX6X|3s6OG20u9zC1jAvpjIgyU16NyA3oya7!ne0S}=vZcmeGYh^^cdEvLC>vp1WFnp!8=uH#qEnH>k%&xdzUn%4 zO9tGGsWsW-R4gs}6sxfuA9FpU8J%jgZO#@GDYi7QV6q%b#1r7T9y6S(&-Sw%TbG(* z``J}F#($5FMumi&sYBL*tOHp`$#%-OTq()*Ng2We!-*moW4}sFhj`8O%nHk~8XIM$ zuJJT;Sq`-zB*RR2EXRxzTgU7u8)a_1x!5TrQ&D6&RzKkRqHeqx^MdY3G0&;h$`;G9 zirF+OPSdDYT-Tqk`&P|J#3v^c;xm~^k7viy$?>toIIjafB8HNwiR{>TBAv=6WjVxz zp@LgCj3Qgd46&qagr(w%D0kN|+;~&Cy~xWW!`a-P`W0hE(1FNJHkyOC?UBQg_MfP~ z1yids&uvtTR`D9QIkV+DS!@NX>RXV%0z7F?&u__=Cyc+*&5 zxxm?>9cLoXIdLGxeVOw_Jdu^_mq<-cBvR>ADm{@MpUm*htSg)KnpN)VXL zQOUj8th7WVjfHrVjlfilH5THF`i@m~-%=Z4d z6l{VgJJmDj7~=;MTusN5W68{9Dv`=$GvgEK3_nDLMJcn`Q>Nq0sk&apEYB27v5gY( zj25+}vZDz_*?MHGBiXTFuJ!C$ow>F;Qf-A~I-laD+e-GRvJmZ+_jFj^3(Y{{^C^}3 z^s%L0=6;k}@n-aREP)pBSa`(o#b(c-Goe8*27?|`L1#`}y~$`t#bWjqbX65Yp~d3s) zw932Odf;GUBQLnZ>ahc*{Sor+<7};QdMhN^<|>tw2>xnFza`+981O%{7PyU>r;zHUa{fBcENwA;b2hP0CUs00Xf7UN zBbwou_FXo60ff+O|2?NuzSu?xsl@;5u79cDU+VXl`u(MTf2rSJ>i3uW{iS|?so!7f z_m}#8iWerT-~Vd6-_M+M{k~HTYxUiRFK4tpQ_Z>5{x7xvOYQ$s`@huwFSY+m?f+8y z|9_+XUuyoBnt!RTI@M$D`v>rsb@4gv{e`)PZCjq`q&G0nDmiuE@{GBJy6!ypV7_AR zcRlI+SaVExns0#twt>~m!kw;d*Dc>DBsxM>q6jJ=JJu~LuFHT_Yinq7?Taxed?;L4 zR#=zxL0uZ50fGc%Gu?LestQPtM=bp0gQ=TV{OIw^&{Z}>b}BigiV1Dm-yzsK}SmJbs`M8Paf+5%b_JRre_>9@d^6by%3b{O8WH53J9 z0lDmO%oQTu4ZG}6++~LWuW;ODhX606ARKzB{B(gY3@G^31AW;q4*Y^MCADgOKrj_p zZxDu3I5G@_nG*-Z%9dB_2Z`B7f@1a&u$bKgjM;!;t=}y?W}g5+hCC`r2C_FOQ-?)w zSSID;&1OHC%IgkT>9rP&?PbEYN|Z}L7_rLH6Mv0&mm(`W?~ z-wUhhX+H{D3t5EY0N46khkgLtF|0@5pe-c!Xhf*WH!NXO~?aKdbv@$;vzYyShL-N-U>K#6#}W_Wdgv zAyZ2Wt{|k4&WU$K*+wZ^uh41WunuGg@n7{SAp7u|nrD_OCM%T9YKavcvs88K90xh2 zs_R)rcG@Tb@yN3sqfRpi-w=cB&T3=S|=DoVkW?<@1JNmJgbX^}HO6MmrIr zKkr#)krmu(-N$@ue?q)?l-F}QT0H6tx}I3^_`ITf;dxV6Z)o0{G1kprdu)*Nr~Ah5 zB!!%|S@$<=IBstM+va}y?*WgYlsoV2(27=wHFQwo*RYCzp=cI07W$eINp`A6JKa5B zIcg%U!z1+7BR`BQR#iK!zT3#}EM2AiFgPk6;i9&7!L zqN02r*?Jr~JF8wcNICW7Q4Y^kYGr;WfGcF8t-l(gyyHJPcGuwm{N)g#8rDK|LGly* zvh;W(@{{Wd4*zjk!2fnS0CbNiOSJz5)~W!X{4e~#N&8j5&hh{N literal 0 HcmV?d00001 diff --git a/config/quickshell/.config/quickshell/Shaders/qsb/weather_snow.frag.qsb b/config/quickshell/.config/quickshell/Shaders/qsb/weather_snow.frag.qsb new file mode 100644 index 0000000000000000000000000000000000000000..e466857a6c4023b249fc03f90fb8407da323cf26 GIT binary patch literal 5327 zcmV;=6fo-m0AgTxob6o;oLp6T|ITK2lT5<FF!pvrNa__t+o6VafXhK2~0=R_X z?##Wrmz~FE=1wvjOp8TYv~ASdQn8}cTCk{Xl|JwRwzS$>u~t#B)mE|ATH8`9ieP>H zzH`1abLP(7M~LVz{muM-%g*;d-}zqWanJwUBmlrt0O$t*1^^erA@Cp%`@x1LY=J6N zp$0`T0H6;5;K%fVfDalt8yu*C1rwrkNpzRy=j}QOarWA!GZ#~;K2+z-Ke7Tf0lp;E|kG0#jUd`o-ORzP=@Ql zg(7$`NnLi#*5%w*mmOe(3llI29(CF|Tc^`uJfNz3^hEW~g-{S`6|kWOd%=PtxX^$) z`MG5_KPU4Uq~F@0T~b`sw{kK1T!uAe5$t z5w=ys77?~X!}y& z-_M6d!UqR{3!q=*JxFaF`e6yxVll}}>HDCtGYBF2zJz|T{XFQ03&|#aR+D^@PrjIH zi1MJOk8woFZoinN{V)t5uzr!>Qc;^>($RlZKa6z^<$<~*B&$6NV>HR9iUH*~jTiP$ zknW(?o~9VEeT4chCi^2a=k%Pmh>ULEp!#1;HdQ;0|6A_4Xg%87O}0$U-?duJP`-iU zIT!liMhzbafaRK9TXx&1CFMO)jviFo|=dGH54FD2}M5`v>6!H@-d5{6z^cB{!x0nGu zwf2$Css>A#)InV7EMou%-u3J@C7;0nUjNv4*D3h|hCZHY`M5~zt<$ZXUdnKsKHiFv z3;l;S;2JcUoNH=5#2L*`hU;ul{7y1C$JI}3oiMqdVZRKMb3Uq#oh9P9{Ug|ZE0aE= zw2n}oA}kF9qOW=iA@&^f3ELyIAFbBbAnrkHHF+uRySjXhChw;GR+qzOQf#%gkn$AYT?<9Yl2wSi9+pP6NKbuKUwQr^Ni#5Af5q<~R z-9~HcDy`pktsmOmPI{_7FQ@ukt=WAA;VRx;gzY37I|#$?*U+;Ozwe>v(DTp-dujdQ z?`NSO_5;9mv^KH*0JR?=zql7%PiyRbFd&{;uOtkgW#5N>A@2hawy^I$@;j#ay@}dx zBEL5SKwk5E3$@>(`L$>*E58N8#>p?r2LXgF?0Zn_>rme+)oy|?RXdlkN%8EH*sb*3 zbt(QLVXFRR+KZHpDq$7P?=;m)$%iP8*T8^~>xAL+`F6rQ>Q~p|Z4j;+!xj zup{&gK8@OyZj;)!Xt5q8d`8o|jm{BADc>o=)cQI`b5!O3i8sms`RexDgx#(Aely`J-+Rc;n>F8W zAz$}swtvT$*INnG^SYOCmDk&7uH38HemmKJn}}7OU+$xP-%fEM_PaDs?xQ*MD#Fwp z`aOESs#?60p2x?j|L>E|J88c}>|KOEKx2#8AJEt?rFLcSL9(Y}d^ceaQhtcNhx)&p zVtfZ-D#kygXTGZ8AJh1#dcBV@Rhx$hd%re5e?sTkhcr8X=CkwXgef~8Anaky&Ijq- z`vEPd50U*3ia8+lK1^|cNSptUX!HNWnxBtQ+oM`+f8mSmF9}n99wSW8@vq3%W17#u zCZ8c{SI@kUQrrDh^Y;>_YW_FAnt$9E&)*TI;(45~PiXOclFs~(Yj!^6v-1yxDLbDb z?9-Z^Cx{z-MyvT}$^H{s%|AzRe^$%?^IFY6r}_B;wLPiD_K&{U{)sTv=Zl2tHUARX z`l9CZpS7BQNvrw4P@Agx#|TqvHLUzi1PJg()*F-3zPdkwW%60Cim;*uvpmXW3mQYNFUo(9TqY9 zJVYI=_qPaBwg#B2w^~0-nZ%F>s~U$`yT(g3+sm13Tc+ur#@h3KIr&5`bsB5ueL9o; z>R~!Fp2;MJy!v~v2+pRvNjyJd+gS_*hUdq#G&|=qS!1;h&tnqP&s66#iC46`U&tii zQ}Rp5j_PwUlNj=LW&08)dHuy&{+BXo>k=)dm$AIYm#PF=I>U}oJ zc{;!6wY@UJI?pjfOk&41ZZX6`>u5`ArZl1rDNnGt;S25XdE6roXwlV4NDvAZ~IJRlA>|pZVL;1NnAeNUii7TB~ z1jMqFNnFLUi^+aFS$prihDrQ>?Tosc$r!Go`0&nUx902GfOz&Yi7TCb0rBi-5?AqD z$7H|#T094s#MN^i?{^MpwYibWJ0NVsyPX@gwlOB}gRl+ndB(JO4pKZfXz|?4B#!!c z4}@}#o(*{>pVR0AZQVqAc)yg_=HfV$cTQ@Z6ll(wbdShMN9|Wd+Gh)75AU0bTAU8W zd0dNgf=OJ-lT6-IwfH`hI+J7*@2p(bUOy$eS8H*7ChxVd=Ga~#owBA=C7o-@$292y zw6Q~kF%7E`)+e6fi^Y1cGua>YvAs%h;yo(1{SX*rfr6a+8y7SbSJ6i#aYx}@vXCIkLjm2rFRb5R_owg(mh8Ti}T2*n#1Q4cAj?j zxPbbePjmQ8!qolPODIPDzV4-jE1gS7N56Z!lyG%^c^Tyqrv8^|ZI@Bo6{N?tJXery zmBUJE=hPpu%Qf38NJpJjuAsKfq^F*XtH|#aTFh5cz0~{`SU_<1v{i7u86EZ9vaQRmXP2*ci{7Lk++n*Dlb%Em+l3?_ zpV(3@RcpFlp<1ihwY^r+ZPZ6X$3{Y-M#XiiwK4|C8{-XUBosPq)n?qv1R9BBQ2XuX zmTI+DG)6)pX{J_fREl$b4aE7%p!u0>_1x_i`)lFOQK7NP8=O+L!Q)_6TE zJ4N`g=n>(oMSGaK$iQp1*Qix^xa?NKWvdxJ6l+HLq1a3$%8i(TeM)v^!kd(hGJr6) zL?URorg9C14p-eG#}N*zXk>&|ecCQ~d{pEgYt@&pX#>96#(Co~ANAD++Zu;sO+GYA zCR^AHALVAu;KM@b7`Xjz*;cM`sQC~#VrI$^pHwbwB-6=cCX-6!jC4Gucdb{dNBAff zfzWD=huwyGpfFU7J33skF~MiUG%C z24Bs?0uQ?pKEx9-gNCfLQ?YuavTxe9iz3*B%*-@%aU&Pc=29smlQrWhG#0MAm2g;= z1j~>#W+?-Xy7WAS{ail8<2c}aHI^BJ$;5KvlgVXrM#3=6gpo}qO*0)gja(v=vWJb7 zFIpPcO%=C{19Di+Mx0DWj?87!$!t88#n|K7l$lD$Q+$YvxGC^b-f$R#aP zi6^p2Gn>lfOha^TXW~YLW;hiy_!@;_LXk+6hucLQ;sK7#L@G}G&F=oeiSG04X+Fve zwp$8^Des}y5aZgRrGzQ0kwU9AXrP;Xlsh%6;K_k3R=u$FLW|Oj$8yQlsub)n4F(oS3`dhz z9zF_Y(3`&&VndL+Gg00QP>DyPwB!S4bhYdruu8325jZIl@mMmQGYun`iknH1xg35o znagFe=|m=-OC`*BN)C0xOl9IRGo8ta8QSU9b$FP3uEUKzPTNi*p2OWQnT}`D*%VFg zj-H*nbvuKKeFQ5R4maC-9lH%|_(&JSu`5>B>et>{gG8d^%f?g*v|-^F1Orm8K^x+~-7E z!Vazqqs!f4yO0n)#Hopo3LxxjA1~Q>^5LVr>^8&VsZGbMSaarRwB0`{ySE~UM6}bx zwA-{xd#ZKc8G;YVDb;xniTaNrx+Qsn;-hVo!!+aPGa@EJO1Cv>l^lAypMV8DEvsgg z#UllS@;BshZ&E(=gN$sAt5ZZ&W!@U*eJOL+>uDKGiyg)yvhY zH(B@W>F}`GnhWTmqo?Cmtdg2QBDnS$9al=rucGA6@n$_#1d zqT2U}IGp;}N3;E>KbNQ5>?3aU7)6g!^cY2tQS=x^k5Tj(MUPSR7)6g!^cY2tQOwII z?$#K^#x|o!otliotpt&YYQvLeotmW#_XtLhVDtz^k6`o&Mvq|h2u6=!^aw_eVDt!v zA{ecM@(bn`ee}mL>2vynh4F^t*fnulU(0Lugj@IQT6o-0N7!2RNWN^{TCK^Dlg*?E zG~Xf|d@Y~03R6|bsoP$-V00v?R8>x$y2)-`aYXfL#b&zo+NWs0^3h;rIj6d`kLcQz z8Z9`VZmQd^K9hXf>4;=Qp}J>zZbA0)Ga$XKlLisV+qdWHy$AAJYSnr@e|2TL;px1h z(;8pP_g{6xmfgGeZZ)Lq02{j-p3jKpmapae4&?V+wF%oJ6G3c@d@DXi;_Qq}#X37D zllrk{b0LJx>1Jez#+uDwK8ES#=Obl#KPk)mNf~AFf@qmD4=>Bl&&!-(Vy57qH8XR% zshM*U+{`&4a^}p<&YXqPv%rY`3{8Ubax~|pNSYJK(wvi}X#dBe9Mw+r)>r&;OkUj_EA6pZ?;NVzHO5oUU0DWeXQ>Wr7!7 zYob!EBeilTD%F}@v@ORjV96T7%)2+-wL&wG%b-NZWRSOlpyCAx5rQ}v@p3hsJS98Z3o}JH!!&d2t zHB-;a#z-WPbb6CD+bZ%xwNm%U?Wz_94Q!Nzw7Y553DZ;B&;nl?I#%6VyB3)$eVgkA zqNdV19t6Hf#E~f56>X&Cx3H=|cPo5sOfAxb2ct4Wt0Jc0DzVG(u`zkKBZe{3dReS2 z;?F3*?U+7gPVh~oPKw=1z(&z~UD@5~RC!%_GIaQqdu4e7RJgTpRU z@#=CeI^27C`Ez)A`SYT{-7hd_=fJ1-D@^$$|9|=-Q%`P=*O>tXSVG}0ofCMeNhZ2p zYj)FM-+`l(3D5s_bAe2F!EZR{B*F`Q%lXsg!Kd(>&Z?gxE|>`O_UXJTi6b({0del7 h7ofhEpz0rvkpIJmZhG0(4%)v5qU(vzPXL!Wd`mNv=kEXj literal 0 HcmV?d00001 diff --git a/config/quickshell/.config/quickshell/Shaders/qsb/weather_stars.frag.qsb b/config/quickshell/.config/quickshell/Shaders/qsb/weather_stars.frag.qsb new file mode 100644 index 0000000000000000000000000000000000000000..544c2b3f5ec4ef57bab162fdea165bad259987ee GIT binary patch literal 6639 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 literal 0 HcmV?d00001 diff --git a/config/quickshell/.config/quickshell/Shaders/qsb/weather_sun.frag.qsb b/config/quickshell/.config/quickshell/Shaders/qsb/weather_sun.frag.qsb new file mode 100644 index 0000000000000000000000000000000000000000..2b231fa5f8bbad6c3aed9ca45cbb46f696aad2e0 GIT binary patch literal 7373 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 literal 0 HcmV?d00001 diff --git a/config/quickshell/.config/quickshell/Utils/Cava.qml b/config/quickshell/.config/quickshell/Utils/Cava.qml index b60398e..e8c198b 100644 --- a/config/quickshell/.config/quickshell/Utils/Cava.qml +++ b/config/quickshell/.config/quickshell/Utils/Cava.qml @@ -38,7 +38,7 @@ Scope { id: process stdinEnabled: true - running: !root.forceDisable && (MusicManager.isPlaying || root.forceEnable) + running: !root.forceDisable && (MediaService.isPlaying || root.forceEnable) command: ["cava", "-p", "/dev/stdin"] onExited: { stdinEnabled = true; diff --git a/config/quickshell/.config/quickshell/Utils/CavaColorList.qml b/config/quickshell/.config/quickshell/Utils/CavaColorList.qml deleted file mode 100644 index 3f84207..0000000 --- a/config/quickshell/.config/quickshell/Utils/CavaColorList.qml +++ /dev/null @@ -1,10 +0,0 @@ -import QtQuick -import Quickshell -import qs.Constants -pragma Singleton - -Singleton { - id: root - - readonly property var colorList: [Colors.lavender, Colors.blue, Colors.sapphire, Colors.sky, Colors.teal, Colors.green, Colors.yellow, Colors.peach] -} diff --git a/config/quickshell/.config/quickshell/Utils/FuzzySort.qml b/config/quickshell/.config/quickshell/Utils/FuzzySort.qml new file mode 100644 index 0000000..d729e2a --- /dev/null +++ b/config/quickshell/.config/quickshell/Utils/FuzzySort.qml @@ -0,0 +1,883 @@ +pragma Singleton + +import QtQuick +import Quickshell + +Singleton { + id: root + + // Public API + function go(search, targets, options) { + return _go(search, targets, options); + } + + function single(search, target) { + return _single(search, target); + } + + function highlight(result, open, close) { + if (open === undefined) + open = ''; + if (close === undefined) + close = ''; + return _highlight(result, open, close); + } + + function prepare(target) { + return _prepare(target); + } + + function cleanup() { + return _cleanup(); + } + + // Internal implementation + readonly property var _INFINITY: Infinity + readonly property var _NEGATIVE_INFINITY: -Infinity + readonly property var _NULL: null + property var _noResults: { + let r = []; + r.total = 0; + return r; + } + property var _noTarget: _prepare('') + + property var _preparedCache: new Map() + property var _preparedSearchCache: new Map() + + property var _matchesSimple: [] + property var _matchesStrict: [] + property var _nextBeginningIndexesChanges: [] + property var _keysSpacesBestScores: [] + property var _allowPartialMatchScores: [] + property var _tmpTargets: [] + property var _tmpResults: [] + property var _q: _fastpriorityqueue() + + function _fastpriorityqueue() { + var e = [], o = 0, a = {}; + var v = function (r) { + for (var a = 0, vc = e[a], c = 1; c < o; ) { + var s = c + 1; + a = c; + if (s < o && e[s]._score < e[c]._score) + a = s; + e[a - 1 >> 1] = e[a]; + c = 1 + (a << 1); + } + for (var f = a - 1 >> 1; a > 0 && vc._score < e[f]._score; f = (a = f) - 1 >> 1) + e[a] = e[f]; + e[a] = vc; + }; + a.add = function (r) { + var ac = o; + e[o++] = r; + for (var vc = ac - 1 >> 1; ac > 0 && r._score < e[vc]._score; vc = (ac = vc) - 1 >> 1) + e[ac] = e[vc]; + e[ac] = r; + }; + a.poll = function () { + if (o !== 0) { + var ac = e[0]; + e[0] = e[--o]; + v(); + return ac; + } + }; + a.peek = function () { + if (o !== 0) + return e[0]; + }; + a.replaceTop = function (r) { + e[0] = r; + v(); + }; + return a; + } + + function _createResult() { + return { + target: '', + obj: _NULL, + _score: _NEGATIVE_INFINITY, + _indexes: [], + _targetLower: '', + _targetLowerCodes: _NULL, + _nextBeginningIndexes: _NULL, + _bitflags: 0, + get indexes() { + return this._indexes.slice(0, this._indexes.len).sort((a, b) => a - b); + }, + set indexes(idx) { + this._indexes = idx; + }, + highlight: function (open, close) { + return root._highlight(this, open, close); + }, + get score() { + return root._normalizeScore(this._score); + }, + set score(s) { + this._score = root._denormalizeScore(s); + } + }; + } + + function _createKeysResult(len) { + var arr = new Array(len); + arr._score = _NEGATIVE_INFINITY; + arr.obj = _NULL; + Object.defineProperty(arr, 'score', { + get: function () { + return root._normalizeScore(this._score); + }, + set: function (s) { + this._score = root._denormalizeScore(s); + } + }); + return arr; + } + + function _new_result(target, options) { + var result = _createResult(); + 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; + } + + function _normalizeScore(score) { + if (score === _NEGATIVE_INFINITY) + return 0; + if (score > 1) + return score; + return Math.E ** (((-score + 1) ** 0.04307 - 1) * -2); + } + + function _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); + } + + function _remove_accents(str) { + return str.replace(/\p{Script=Latin}+/gu, match => match.normalize('NFD')).replace(/[\u0300-\u036f]/g, ''); + } + + function _prepareLowerInfo(str) { + str = _remove_accents(str); + var strLen = str.length; + var lower = str.toLowerCase(); + var lowerCodes = []; + var bitflags = 0; + var containsSpace = false; + + for (var i = 0; i < strLen; ++i) { + var lowerCode = lowerCodes[i] = lower.charCodeAt(i); + if (lowerCode === 32) { + containsSpace = true; + continue; + } + var bit = lowerCode >= 97 && lowerCode <= 122 ? lowerCode - 97 : lowerCode >= 48 && lowerCode <= 57 ? 26 : lowerCode <= 127 ? 30 : 31; + bitflags |= 1 << bit; + } + return { + lowerCodes: lowerCodes, + bitflags: bitflags, + containsSpace: containsSpace, + _lower: lower + }; + } + + function _prepareBeginningIndexes(target) { + 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; + } + + function _prepareNextBeginningIndexes(target) { + target = _remove_accents(target); + var targetLen = target.length; + var beginningIndexes = _prepareBeginningIndexes(target); + var nextBeginningIndexes = []; + 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; + } + + function _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)]; + for (var i = 0; i < searches.length; i++) { + if (searches[i] === '') + continue; + var _info = _prepareLowerInfo(searches[i]); + spaceSearches.push({ + lowerCodes: _info.lowerCodes, + _lower: searches[i].toLowerCase(), + containsSpace: false + }); + } + } + return { + lowerCodes: info.lowerCodes, + _lower: info._lower, + containsSpace: info.containsSpace, + bitflags: info.bitflags, + spaceSearches: spaceSearches + }; + } + + function _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 + }); + } + + function _cleanup() { + _preparedCache.clear(); + _preparedSearchCache.clear(); + } + + function _isPrepared(x) { + return typeof x === 'object' && typeof x._bitflags === 'number'; + } + + function _getPrepared(target) { + if (target.length > 999) + return _prepare(target); + var targetPrepared = _preparedCache.get(target); + if (targetPrepared !== undefined) + return targetPrepared; + targetPrepared = _prepare(target); + _preparedCache.set(target, targetPrepared); + return targetPrepared; + } + + function _getPreparedSearch(search) { + if (search.length > 999) + return _prepareSearch(search); + var searchPrepared = _preparedSearchCache.get(search); + if (searchPrepared !== undefined) + return searchPrepared; + searchPrepared = _prepareSearch(search); + _preparedSearchCache.set(search, searchPrepared); + return searchPrepared; + } + + function _getValue(obj, prop) { + var tmp = obj[prop]; + if (tmp !== undefined) + return tmp; + if (typeof prop === 'function') + return prop(obj); + 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; + } + + function _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); + } + + function _highlight(result, open, close) { + if (open === undefined) + open = ''; + if (close === undefined) + 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 ch = 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 += ch; + parts.push(callback(highlighted, matchI++)); + highlighted = ''; + parts.push(target.substr(i + 1)); + } else { + highlighted += ch + close + target.substr(i + 1); + } + break; + } + } else { + if (opened) { + opened = false; + if (callback) { + parts.push(callback(highlighted, matchI++)); + highlighted = ''; + } else { + highlighted += close; + } + } + } + highlighted += ch; + } + return callback ? parts : highlighted; + } + + function _all(targets, options) { + var results = []; + results.total = targets.length; + var limit = options?.limit || _INFINITY; + + if (options?.key) { + for (var i = 0; i < targets.length; i++) { + var obj = targets[i]; + var target = _getValue(obj, options.key); + if (target == _NULL) + continue; + if (!_isPrepared(target)) + target = _getPrepared(target); + var result = _new_result(target.target, { + _score: target._score, + obj: obj + }); + results.push(result); + if (results.length >= limit) + return results; + } + } else if (options?.keys) { + for (var i = 0; i < targets.length; i++) { + var obj = targets[i]; + var objResults = _createKeysResult(options.keys.length); + for (var keyI = options.keys.length - 1; keyI >= 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 < targets.length; i++) { + var target = targets[i]; + if (target == _NULL) + continue; + if (!_isPrepared(target)) + target = _getPrepared(target); + target._score = _NEGATIVE_INFINITY; + target._indexes.len = 0; + results.push(target); + if (results.length >= limit) + return results; + } + } + return results; + } + + function _algorithm(preparedSearch, prepared, allowSpaces, allowPartialMatch) { + if (allowSpaces === undefined) + allowSpaces = false; + if (allowPartialMatch === undefined) + 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; + var targetI = 0; + var matchesSimpleLen = 0; + + 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; + } + + 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]; + + var backtrackCount = 0; + if (targetI !== targetLen) + for (; ; ) { + if (targetI >= targetLen) { + if (searchI <= 0) + break; + ++backtrackCount; + if (backtrackCount > 200) + break; + --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]; + } + } + } + + var substringIndex = searchLen <= 1 ? -1 : prepared._targetLower.indexOf(searchLower, _matchesSimple[0]); + var isSubstring = !!~substringIndex; + var isSubstringBeginning = !isSubstring ? false : substringIndex === 0 || prepared._nextBeginningIndexes[substringIndex - 1] === substringIndex; + + if (isSubstring && !isSubstringBeginning) { + for (var i = 0; i < nextBeginningIndexes.length; i = nextBeginningIndexes[i]) { + if (i <= substringIndex) + continue; + for (var s = 0; s < searchLen; s++) + if (searchLowerCodes[s] !== prepared._targetLowerCodes[i + s]) + break; + if (s === searchLen) { + substringIndex = i; + isSubstringBeginning = true; + break; + } + } + } + + var calculateScore = function (matches) { + 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; + if (matches[0] !== 0) + score -= matches[0] * matches[0] * 0.2; + if (!successStrict) { + score *= 1000; + } else { + var uniqueBeginningIndexes = 1; + for (var i = nextBeginningIndexes[0]; i < targetLen; i = nextBeginningIndexes[i]) + ++uniqueBeginningIndexes; + if (uniqueBeginningIndexes > 24) + score *= (uniqueBeginningIndexes - 24) * 10; + } + score -= (targetLen - searchLen) / 2; + if (isSubstring) + score /= 1 + searchLen * searchLen * 1; + if (isSubstringBeginning) + score /= 1 + searchLen * searchLen * 1; + score -= (targetLen - searchLen) / 2; + return score; + }; + + var matchesBest, score; + if (!successStrict) { + if (isSubstring) + for (var i = 0; i < searchLen; ++i) + _matchesSimple[i] = substringIndex + i; + matchesBest = _matchesSimple; + score = calculateScore(matchesBest); + } else { + if (isSubstringBeginning) { + for (var i = 0; i < searchLen; ++i) + _matchesSimple[i] = substringIndex + i; + matchesBest = _matchesSimple; + score = calculateScore(_matchesSimple); + } else { + matchesBest = _matchesStrict; + score = calculateScore(_matchesStrict); + } + } + + prepared._score = score; + for (var i = 0; i < searchLen; ++i) + prepared._indexes[i] = matchesBest[i]; + prepared._indexes.len = searchLen; + + var result = _createResult(); + result.target = prepared.target; + result._score = prepared._score; + result._indexes = prepared._indexes; + return result; + } + + function _algorithmSpaces(preparedSearch, target, allowPartialMatch) { + 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; + + var resetNextBeginningIndexes = function () { + 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 < searchesLen; ++i) { + _allowPartialMatchScores[i] = _NEGATIVE_INFINITY; + var search = searches[i]; + result = _algorithm(search, target); + + if (allowPartialMatch) { + if (result === _NULL) + continue; + hasAtLeast1Match = true; + } else { + if (result === _NULL) { + resetNextBeginningIndexes(); + return _NULL; + } + } + + var isTheLastSearch = i === searchesLen - 1; + if (!isTheLastSearch) { + var indexes = result._indexes; + var indexesIsConsecutiveSubstring = true; + for (let j = 0; j < indexes.len - 1; j++) { + if (indexes[j + 1] - indexes[j] !== 1) { + indexesIsConsecutiveSubstring = false; + break; + } + } + + if (indexesIsConsecutiveSubstring) { + var newBeginningIndex = indexes[indexes.len - 1] + 1; + var toReplace = target._nextBeginningIndexes[newBeginningIndex - 1]; + for (let j = newBeginningIndex - 1; j >= 0; j--) { + if (toReplace !== target._nextBeginningIndexes[j]) + break; + target._nextBeginningIndexes[j] = newBeginningIndex; + _nextBeginningIndexesChanges[changeslen * 2 + 0] = j; + _nextBeginningIndexesChanges[changeslen * 2 + 1] = toReplace; + changeslen++; + } + } + } + + score += result._score / searchesLen; + _allowPartialMatchScores[i] = result._score / searchesLen; + + 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 < result._indexes.len; ++j) + seen_indexes.add(result._indexes[j]); + } + + if (allowPartialMatch && !hasAtLeast1Match) + return _NULL; + + resetNextBeginningIndexes(); + + var allowSpacesResult = _algorithm(preparedSearch, target, true); + if (allowSpacesResult !== _NULL && allowSpacesResult._score > score) { + if (allowPartialMatch) { + for (var i = 0; i < searchesLen; ++i) { + _allowPartialMatchScores[i] = allowSpacesResult._score / searchesLen; + } + } + return allowSpacesResult; + } + + if (allowPartialMatch) + result = target; + result._score = score; + + var idx = 0; + for (let index of seen_indexes) + result._indexes[idx++] = index; + result._indexes.len = idx; + + return result; + } + + function _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.35); + 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); + } + } + + 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); + } + } else if (options?.keys) { + var keys = options.keys; + var keysLen = keys.length; + + outer: for (var i = 0; i < targetsLen; ++i) { + var obj = targets[i]; + 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 j = 0; j < preparedSearch.spaceSearches.length; j++) + _keysSpacesBestScores[j] = _NEGATIVE_INFINITY; + + for (var keyI = 0; keyI < keysLen; ++keyI) { + target = _tmpTargets[keyI]; + if (target === _noTarget) { + _tmpResults[keyI] = _noTarget; + continue; + } + + _tmpResults[keyI] = _algorithm(preparedSearch, target, false, containsSpace); + if (_tmpResults[keyI] === _NULL) { + _tmpResults[keyI] = _noTarget; + continue; + } + + if (containsSpace) + for (let j = 0; j < preparedSearch.spaceSearches.length; j++) { + if (_allowPartialMatchScores[j] > -1000) { + if (_keysSpacesBestScores[j] > _NEGATIVE_INFINITY) { + var tmp = (_keysSpacesBestScores[j] + _allowPartialMatchScores[j]) / 4; + if (tmp > _keysSpacesBestScores[j]) + _keysSpacesBestScores[j] = tmp; + } + } + if (_allowPartialMatchScores[j] > _keysSpacesBestScores[j]) + _keysSpacesBestScores[j] = _allowPartialMatchScores[j]; + } + } + + if (containsSpace) { + for (let j = 0; j < preparedSearch.spaceSearches.length; j++) + if (_keysSpacesBestScores[j] === _NEGATIVE_INFINITY) + continue outer; + } else { + var hasAtLeast1Match = false; + for (let j = 0; j < keysLen; j++) + if (_tmpResults[j]._score !== _NEGATIVE_INFINITY) { + hasAtLeast1Match = true; + break; + } + if (!hasAtLeast1Match) + continue; + } + + var objResults = _createKeysResult(keysLen); + for (let j = 0; j < keysLen; j++) + objResults[j] = _tmpResults[j]; + + var score; + if (containsSpace) { + score = 0; + for (let j = 0; j < preparedSearch.spaceSearches.length; j++) + score += _keysSpacesBestScores[j]; + } else { + score = _NEGATIVE_INFINITY; + for (let j = 0; j < keysLen; j++) { + var res = objResults[j]; + if (res._score > -1000) { + if (score > _NEGATIVE_INFINITY) { + var tmp = (score + res._score) / 4; + if (tmp > score) + score = tmp; + } + } + if (res._score > score) + score = res._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); + } + } 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; + } +} diff --git a/config/quickshell/.config/quickshell/Utils/Logger.qml b/config/quickshell/.config/quickshell/Utils/Logger.qml index 063e488..7f14f62 100644 --- a/config/quickshell/.config/quickshell/Utils/Logger.qml +++ b/config/quickshell/.config/quickshell/Utils/Logger.qml @@ -1,58 +1,65 @@ pragma Singleton import Quickshell -import qs.Utils Singleton { id: root function _formatMessage(...args) { - var t = Time.getFormattedTimestamp() if (args.length > 1) { - const maxLength = 14 - var module = args.shift().substring(0, maxLength).padStart(maxLength, " ") - return `\x1b[36m[${t}]\x1b[0m \x1b[35m${module}\x1b[0m ` + args.join(" ") + const maxLength = 14; + var module = args.shift().substring(0, maxLength).padStart(maxLength, " "); + return `\x1b[35m${module}\x1b[0m ` + args.join(" "); } else { - return `[\x1b[36m[${t}]\x1b[0m ` + args.join(" ") + return args.join(" "); } } function _getStackTrace() { try { - throw new Error("Stack trace") + throw new Error("Stack trace"); } catch (e) { - return e.stack + return e.stack; } } - function log(...args) { - var msg = _formatMessage(...args) - console.log(msg) + // Debug log (only when Settings.isDebug is true) + function d(...args) { + var msg = _formatMessage(...args); + console.debug(msg); } - function warn(...args) { - var msg = _formatMessage(...args) - console.warn(msg) + // Info log (always visible) + function i(...args) { + var msg = _formatMessage(...args); + console.info(msg); } - function error(...args) { - var msg = _formatMessage(...args) - console.error(msg) + // Warning log (always visible) + function w(...args) { + var msg = _formatMessage(...args); + console.warn(msg); + } + + // Error log (always visible) + function e(...args) { + var msg = _formatMessage(...args); + console.error(msg); } function callStack() { - var stack = _getStackTrace() - Logger.log("Debug", "--------------------------") - Logger.log("Debug", "Current call stack") + var stack = _getStackTrace(); + Logger.i("Debug", "--------------------------"); + Logger.i("Debug", "Current call stack"); // Split the stack into lines and log each one - var stackLines = stack.split('\n') + var stackLines = stack.split('\n'); for (var i = 0; i < stackLines.length; i++) { - var line = stackLines[i].trim() // Remove leading/trailing whitespace + var line = stackLines[i].trim(); // Remove leading/trailing whitespace if (line.length > 0) { // Only log non-empty lines - Logger.log("Debug", `- ${line}`) + Logger.i("Debug", `- ${line}`); } } - Logger.log("Debug", "--------------------------") + Logger.i("Debug", "--------------------------"); } } diff --git a/config/quickshell/.config/quickshell/Utils/Time.qml b/config/quickshell/.config/quickshell/Utils/Time.qml index 1cd3d08..6c13123 100644 --- a/config/quickshell/.config/quickshell/Utils/Time.qml +++ b/config/quickshell/.config/quickshell/Utils/Time.qml @@ -6,11 +6,25 @@ Singleton { id: root // Current date - property var date: new Date() + property var now: new Date() + // Unix timestamp of the last update + property real _lastUpdateTs: Date.now() // Returns a Unix Timestamp (in seconds) readonly property int timestamp: { - return Math.floor(date / 1000); + return Math.floor(root.now / 1000); } + // Timer state (for countdown/stopwatch) + property bool timerRunning: false + property bool timerStopwatchMode: false + property int timerRemainingSeconds: 0 + property int timerTotalSeconds: 0 + property int timerElapsedSeconds: 0 + property bool timerSoundPlaying: false + property int timerStartTimestamp: 0 // Unix timestamp when timer was started + property int timerPausedAt: 0 // Value when paused (for resuming) + + // Signal emitted when a significant time jump is detected (e.g. system resume) + signal resumed() // Formats a Date object into a YYYYMMDD-HHMMSS string. function getFormattedTimestamp(date) { @@ -27,7 +41,7 @@ Singleton { return `${year}${month}${day}-${hours}${minutes}${seconds}`; } - // Format an easy to read approximate duration ex: 4h32m + // Format an easy to read approximate duration ex: 4h 32m // Used to display the time remaining on the Battery widget, computer uptime, etc.. function formatVagueHumanReadableDuration(totalSeconds) { if (typeof totalSeconds !== 'number' || totalSeconds < 0) @@ -53,7 +67,7 @@ Singleton { if (!hours && !minutes) parts.push(`${seconds}s`); - return parts.join(''); + return parts.join(' '); } // Format a date into @@ -63,22 +77,74 @@ Singleton { const diff = Date.now() - date.getTime(); if (diff < 60000) - return "now"; + return "Just now"; + + if (diff < 120000) + return "1 minute ago"; if (diff < 3.6e+06) - return `${Math.floor(diff / 60000)}m ago`; + return `${Math.floor(diff / 60000)} minutes ago`; + + if (diff < 7.2e+06) + return "1 hour ago"; if (diff < 8.64e+07) - return `${Math.floor(diff / 3600000)}h ago`; + return `${Math.floor(diff / 3.6e+06)} hours ago`; - return `${Math.floor(diff / 86400000)}d ago`; + if (diff < 1.728e+08) + return "1 day ago"; + + return `${Math.floor(diff / 8.64e+07)} days ago`; + } + + Component.onCompleted: { + // Start by syncing to the next second boundary + var now = new Date(); + var msUntilNextSecond = 1000 - now.getMilliseconds(); + updateTimer.interval = msUntilNextSecond + 10; // +10ms buffer + updateTimer.restart(); } Timer { + id: updateTimer + interval: 1000 repeat: true running: true - onTriggered: root.date = new Date() + triggeredOnStart: false + onTriggered: { + var newTime = new Date(); + var currentTs = newTime.getTime(); + // Detect time jump (e.g. system resume) - threshold: 5 seconds + if (currentTs - root._lastUpdateTs > 5000) { + Logger.i("Time", "Time jump detected (" + Math.round((currentTs - root._lastUpdateTs) / 1000) + "s) - likely system resume"); + root.resumed(); + } + root._lastUpdateTs = currentTs; + root.now = newTime; + // Update timer if running + if (root.timerRunning && root.timerStartTimestamp > 0) { + const elapsedSinceStart = root.timestamp - root.timerStartTimestamp; + if (root.timerStopwatchMode) { + root.timerElapsedSeconds = root.timerPausedAt + elapsedSinceStart; + } else { + root.timerRemainingSeconds = root.timerTotalSeconds - elapsedSinceStart; + if (root.timerRemainingSeconds <= 0) + root.timerOnFinished(); + + } + } + // Adjust next interval to sync with the start of the next second + var msIntoSecond = newTime.getMilliseconds(); + if (msIntoSecond > 100) { + // If we're more than 100ms into the second, adjust for next time + updateTimer.interval = 1000 - msIntoSecond + 10; + // +10ms buffer + updateTimer.restart(); + } else { + updateTimer.interval = 1000; + } + } } } diff --git a/config/quickshell/.config/quickshell/apply-color b/config/quickshell/.config/quickshell/apply-color index 1958de4..2381631 100755 --- a/config/quickshell/.config/quickshell/apply-color +++ b/config/quickshell/.config/quickshell/apply-color @@ -6,14 +6,7 @@ } . "$HOME/.local/snippets/apply-color-helper" -if pgrep -x "quickshell" -u "$USER" >/dev/null; then - qs ipc call colors setPrimary ${colorHex} || { - log_error "Failed to send IPC command to quickshell" - exit 1 - } +qs ipc call colors setColor mPrimary "$colorHex" - log_success "quickshell" -else - log_error "quickshell is not running. Cannot apply color." - exit 1 -fi + +log_success "quickshell" diff --git a/config/quickshell/.config/quickshell/shell.qml b/config/quickshell/.config/quickshell/shell.qml index 735ad3e..f1f6b0c 100644 --- a/config/quickshell/.config/quickshell/shell.qml +++ b/config/quickshell/.config/quickshell/shell.qml @@ -1,28 +1,27 @@ import QtQuick import Quickshell import Quickshell.Widgets -import qs.Constants +import qs.Modules.Background import qs.Modules.Bar import qs.Modules.Misc -import qs.Modules.Panel +import qs.Modules.Sidebar import qs.Services ShellRoot { id: root + Component.onCompleted: { + ImageCacheService.init(); + } + Loader { id: loader - active: CacheService.loaded && NukeKded6.done + active: Init.loaded && NukeKded6.done && ImageCacheService.initialized && ShellState.isLoaded sourceComponent: Item { Component.onCompleted: { SunsetService; - Niri.onScreenshotCaptured = Screenshot.onScreenshotCaptured; - } - - Notification { - id: notification } IPCService { @@ -37,34 +36,16 @@ ShellRoot { id: corners } - CalendarPanel { - id: calendarPanel - - objectName: "calendarPanel" + Sidebars { + id: sidebars } - ControlCenterPanel { - id: controlCenterPanel - - objectName: "controlCenterPanel" + Notification { + id: notification } - NotificationHistoryPanel { - id: notificationHistoryPanel - - objectName: "notificationHistoryPanel" - } - - WiFiPanel { - id: wifiPanel - - objectName: "wifiPanel" - } - - BluetoothPanel { - id: bluetoothPanel - - objectName: "bluetoothPanel" + Background { + id: background } } diff --git a/config/wallpaper/.config/wallreel/config.json b/config/wallpaper/.config/wallreel/config.json index bdc19c0..cfea8b4 100644 --- a/config/wallpaper/.config/wallreel/config.json +++ b/config/wallpaper/.config/wallreel/config.json @@ -1,39 +1,30 @@ { - "$schema": "https://raw.githubusercontent.com/Uyanide/WallReel/refs/heads/master/config.schema.json", - "wallpaper": { - "dirs": [ - { - "path": "~/Pictures/backgrounds" - }, - { - "path": "/media/Beta/壁纸/库" - } - ], - "excludes": [ - "nao-stars-crop-adjust-flop.jpg", - "miku-gate.jpg", - "\\.md$" - ] - }, - "action": { - "onSelected": "change-wallpaper '{{ path }}' 2560 1440 --skip-colortheme; change-colortheme -c '{{ colorHex }}'", - "onPreview": "change-colortheme -c '{{ colorHex }}' niri quickshell; swww img -n background \"{{ path }}\" --transition-type fade --transition-duration 0.5", - "quitOnSelected": true, - "saveState": [ - { - "key": "flavor", - "fallback": "#89b4fa", - "command": "cat ~/.config/posh_theme.omp.json | jq -r .blocks[0].segments[0].foreground" - }, - { - "key": "wallpaper", - "fallback": "$HOME/Pictures/backgrounds/miku-space.jpg", - "command": "find ~/.local/share/wallpaper/current -type f | head -n 1" - } - ], - "onRestore": "change-colortheme -c '{{ flavor }}' niri quickshell; swww img -n background \"{{ wallpaper }}\" --transition-type fade --transition-duration 0.5" - }, - "cache": { - "maxImageEntries": 300 - } + "$schema": "https://raw.githubusercontent.com/Uyanide/WallReel/refs/heads/master/config.schema.json", + "wallpaper": { + "dirs": [ + { + "path": "~/Pictures/backgrounds" + }, + { + "path": "/media/Beta/壁纸/库" + } + ], + "excludes": ["nao-stars-crop-adjust-flop.jpg", "miku-gate.jpg", "\\.md$"] + }, + "action": { + "onSelected": "qs ipc call background setWallpaper '{{ path }}'; qs ipc call colors setColor mPrimary '{{ colorHex }}'", + "onPreview": "qs ipc call background previewWallpaper '{{ path }}'; change-colortheme -c '{{ colorHex }}' quickshell niri", + "quitOnSelected": true, + "saveState": [ + { + "key": "flavor", + "fallback": "#89b4fa", + "command": "qs ipc call colors getColor mPrimary" + } + ], + "onRestore": "qs ipc call background previewWallpaper ''; change-colortheme -c '{{ flavor }}' quickshell niri" + }, + "cache": { + "maxImageEntries": 300 + } } diff --git a/config/yazi/.config/yazi/package.toml b/config/yazi/.config/yazi/package.toml index 513851b..27d8683 100644 --- a/config/yazi/.config/yazi/package.toml +++ b/config/yazi/.config/yazi/package.toml @@ -1,11 +1,11 @@ [[plugin.deps]] use = "yazi-rs/plugins:git" -rev = "b224ddf" -hash = "270915fa8282a19908449530ff66f7e2" +rev = "1962818" +hash = "26db011a778f261d730d4f5f8bf24b3f" [[plugin.deps]] use = "yazi-rs/plugins:smart-enter" -rev = "b224ddf" +rev = "1962818" hash = "187cc58ba7ac3befd49c342129e6f1b6" [[plugin.deps]] diff --git a/config/yazi/.config/yazi/plugins/git.yazi/main.lua b/config/yazi/.config/yazi/plugins/git.yazi/main.lua index 993be7e..dcb0ce1 100644 --- a/config/yazi/.config/yazi/plugins/git.yazi/main.lua +++ b/config/yazi/.config/yazi/plugins/git.yazi/main.lua @@ -1,4 +1,4 @@ ---- @since 25.12.29 +--- @since 26.1.22 local WINDOWS = ya.target_family() == "windows" @@ -224,7 +224,6 @@ local function fetch(_, job) :cwd(tostring(cwd)) :arg({ "--no-optional-locks", "-c", "core.quotePath=", "status", "--porcelain", "-unormal", "--no-renames", "--ignored=matching" }) :arg(paths) - :stdout(Command.PIPED) :output() if not output then return true, Err("Cannot spawn `git` command, error: %s", err)