From 22105c20d42e8b596ac0c37f07cb5a3e9c45baa5 Mon Sep 17 00:00:00 2001 From: Uyanide Date: Sun, 12 Oct 2025 23:23:36 +0200 Subject: [PATCH] quickshell: should be everything I want now --- .licenses/noctalia-dev/noctalia-shell | 21 + .scripts/change-colortheme | 44 +- .scripts/change-wallpaper | 4 +- .scripts/config-switch | 2 +- .scripts/sl-wrap | 4 + README.md | 36 +- backgrounds | 2 +- niri/config.kdl | 15 +- niri/config.kdl.template | 10 +- quickshell/Assets/Config/.gitignore | 4 + quickshell/Assets/Config/LyricsOffset.txt | 1 + quickshell/Assets/Config/Settings.json | 4 + quickshell/Assets/Images/Avatar.jpg | Bin 0 -> 21572 bytes quickshell/Assets/Ip/.gitignore | 2 - quickshell/Constants/Color.qml | 25 + quickshell/Constants/Colors.qml | 4 +- quickshell/Constants/Fonts.qml | 9 +- quickshell/Constants/Icons.qml | 8 +- quickshell/Constants/Style.qml | 68 +++ quickshell/Modules/Bar/Bar.qml | 75 ++- quickshell/Modules/Bar/Components/CavaBar.qml | 2 +- .../Modules/Bar/Components/FocusedWindow.qml | 2 +- quickshell/Modules/Bar/Components/Ip.qml | 4 +- .../Modules/Bar/Components/LyricsBar.qml | 88 +++ .../Modules/Bar/Components/NetworkSpeed.qml | 12 +- quickshell/Modules/Bar/Components/Time.qml | 10 +- .../Modules/Bar/Components/Workspace.qml | 12 +- quickshell/Modules/Bar/Misc/MonitorItem.qml | 4 +- quickshell/Modules/Bar/Misc/SymbolButton.qml | 9 +- quickshell/Modules/Bar/Misc/SystemTray.qml | 4 +- quickshell/Modules/Bar/Misc/TrayMenu.qml | 80 +-- quickshell/Modules/Panel/CalendarPanel.qml | 537 ++++++++++++++++++ quickshell/Modules/Panel/Cards/LyricsCard.qml | 46 ++ .../Modules/Panel/Cards/LyricsControl.qml | 96 ++++ quickshell/Modules/Panel/Cards/MediaCard.qml | 458 +++++++++++++++ .../Modules/Panel/Cards/SystemMonitorCard.qml | 61 ++ .../Modules/Panel/Cards/TopLeftCard.qml | 175 ++++++ .../Modules/Panel/ControlCenterPanel.qml | 86 +++ .../Modules/Panel/Misc/MonitorSlider.qml | 54 ++ quickshell/Noctalia/NBox.qml | 28 + quickshell/Noctalia/NBusyIndicator.qml | 53 ++ quickshell/Noctalia/NCircleStat.qml | 122 ++++ quickshell/Noctalia/NContextMenu.qml | 124 ++++ quickshell/Noctalia/NDivider.qml | 35 ++ quickshell/Noctalia/NIcon.qml | 28 + quickshell/Noctalia/NIconButton.qml | 92 +++ quickshell/Noctalia/NImageCircled.qml | 85 +++ quickshell/Noctalia/NImageRounded.qml | 103 ++++ quickshell/Noctalia/NListView.qml | 217 +++++++ quickshell/Noctalia/NPanel.qml | 459 +++++++++++++++ quickshell/Noctalia/NSlider.qml | 152 +++++ quickshell/Noctalia/NText.qml | 20 + quickshell/Services/AudioService.qml | 11 +- quickshell/Services/BrightnessService.qml | 15 +- quickshell/Services/Caffeine.qml | 5 +- quickshell/Services/IPCService.qml | 35 ++ quickshell/Services/IpService.qml | 154 ++--- quickshell/Services/LocationService.qml | 323 +++++++++++ quickshell/Services/LyricsService.qml | 144 +++++ quickshell/Services/MusicManager.qml | 16 + quickshell/Services/Niri.qml | 15 +- quickshell/Services/PowerProfileService.qml | 88 +++ quickshell/Services/SendNotification.qml | 2 +- quickshell/Services/SettingsService.qml | 35 ++ quickshell/Services/SystemStatService.qml | 8 +- quickshell/Services/WorkspaceManager.qml | 3 +- quickshell/Shaders/qsb/circled_image.frag.qsb | Bin 0 -> 1717 bytes quickshell/Shaders/qsb/rounded_image.frag.qsb | Bin 0 -> 2767 bytes quickshell/{Modules/Misc => Utils}/Cava.qml | 24 +- .../{Modules/Misc => Utils}/CavaColorList.qml | 0 quickshell/Utils/Logger.qml | 58 ++ quickshell/Utils/Time.qml | 84 +++ quickshell/shell.qml | 18 + rofi/config.rasi | 12 +- rofi/config.rasi.template | 12 +- waybar-niri/config.jsonc | 232 -------- waybar-niri/mocha.css | 26 - waybar-niri/modules/.gitignore | 3 - waybar-niri/modules/caffeine.sh | 31 - waybar-niri/modules/mediaplayer.py | 221 ------- waybar-niri/modules/publicip.sh | 62 -- waybar-niri/style.css | 222 -------- waybar-niri/style.css.template | 222 -------- waybar-niri/waybar.sh | 10 - 84 files changed, 4375 insertions(+), 1312 deletions(-) create mode 100644 .licenses/noctalia-dev/noctalia-shell create mode 100755 .scripts/sl-wrap create mode 100644 quickshell/Assets/Config/.gitignore create mode 100644 quickshell/Assets/Config/LyricsOffset.txt create mode 100644 quickshell/Assets/Config/Settings.json create mode 100644 quickshell/Assets/Images/Avatar.jpg delete mode 100644 quickshell/Assets/Ip/.gitignore create mode 100644 quickshell/Constants/Color.qml create mode 100644 quickshell/Constants/Style.qml create mode 100644 quickshell/Modules/Bar/Components/LyricsBar.qml create mode 100644 quickshell/Modules/Panel/CalendarPanel.qml create mode 100644 quickshell/Modules/Panel/Cards/LyricsCard.qml create mode 100644 quickshell/Modules/Panel/Cards/LyricsControl.qml create mode 100644 quickshell/Modules/Panel/Cards/MediaCard.qml create mode 100644 quickshell/Modules/Panel/Cards/SystemMonitorCard.qml create mode 100644 quickshell/Modules/Panel/Cards/TopLeftCard.qml create mode 100644 quickshell/Modules/Panel/ControlCenterPanel.qml create mode 100644 quickshell/Modules/Panel/Misc/MonitorSlider.qml create mode 100644 quickshell/Noctalia/NBox.qml create mode 100644 quickshell/Noctalia/NBusyIndicator.qml create mode 100644 quickshell/Noctalia/NCircleStat.qml create mode 100644 quickshell/Noctalia/NContextMenu.qml create mode 100644 quickshell/Noctalia/NDivider.qml create mode 100644 quickshell/Noctalia/NIcon.qml create mode 100644 quickshell/Noctalia/NIconButton.qml create mode 100644 quickshell/Noctalia/NImageCircled.qml create mode 100644 quickshell/Noctalia/NImageRounded.qml create mode 100644 quickshell/Noctalia/NListView.qml create mode 100644 quickshell/Noctalia/NPanel.qml create mode 100644 quickshell/Noctalia/NSlider.qml create mode 100644 quickshell/Noctalia/NText.qml create mode 100644 quickshell/Services/IPCService.qml create mode 100644 quickshell/Services/LocationService.qml create mode 100644 quickshell/Services/LyricsService.qml create mode 100644 quickshell/Services/PowerProfileService.qml create mode 100644 quickshell/Services/SettingsService.qml create mode 100644 quickshell/Shaders/qsb/circled_image.frag.qsb create mode 100644 quickshell/Shaders/qsb/rounded_image.frag.qsb rename quickshell/{Modules/Misc => Utils}/Cava.qml (70%) rename quickshell/{Modules/Misc => Utils}/CavaColorList.qml (100%) create mode 100644 quickshell/Utils/Logger.qml create mode 100644 quickshell/Utils/Time.qml delete mode 100644 waybar-niri/config.jsonc delete mode 100644 waybar-niri/mocha.css delete mode 100644 waybar-niri/modules/.gitignore delete mode 100755 waybar-niri/modules/caffeine.sh delete mode 100755 waybar-niri/modules/mediaplayer.py delete mode 100755 waybar-niri/modules/publicip.sh delete mode 100644 waybar-niri/style.css delete mode 100644 waybar-niri/style.css.template delete mode 100755 waybar-niri/waybar.sh diff --git a/.licenses/noctalia-dev/noctalia-shell b/.licenses/noctalia-dev/noctalia-shell new file mode 100644 index 0000000..2bdaed9 --- /dev/null +++ b/.licenses/noctalia-dev/noctalia-shell @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 noctalia-dev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/.scripts/change-colortheme b/.scripts/change-colortheme index bcf45e4..93c372d 100755 --- a/.scripts/change-colortheme +++ b/.scripts/change-colortheme @@ -34,6 +34,8 @@ - fuzzel: edit $HOME/.config/fuzzel/fuzzel.ini - niri: edit $HOME/.config/niri/config.kdl + +- quickshell qs ipc call colors setPrimary $hex ''' import os @@ -216,6 +218,11 @@ def _change_niri(palette: dict[str, str], flavor: str): replace_placeholders(niri_dist, palette, flavor) +def _change_quickshell(palette: dict[str, str], flavor: str): + hex_color = palette[flavor] + os.system(f'qs ipc call colors setPrimary {hex_color}') + + apply_theme_funcs: dict[str, Callable[[dict[str, str], str], None]] = { 'kvantum': _change_kvantum, # 'nwg-look': _change_nwglook, @@ -230,6 +237,7 @@ apply_theme_funcs: dict[str, Callable[[dict[str, str], str], None]] = { 'wlogout': _change_wlogout, 'fuzzel': _change_fuzzel, 'niri': _change_niri, + 'quickshell': _change_quickshell, } @@ -333,7 +341,8 @@ def main(): parser.add_argument('-i', '--image', type=str, help="Path to the image") parser.add_argument('-f', '--flavor', type=str, help="Flavor to apply") parser.add_argument('-c', '--color', type=str, help="Color to match from the palette") - parser.add_argument('arguments', nargs='*', help="List of applications to change the color theme of") + parser.add_argument('arguments', nargs='*', + help="List of applications to change the color theme of, or !app to exclude an application. Available apps: " + ', '.join(apply_theme_funcs.keys())) arguments = parser.parse_args() @@ -363,15 +372,34 @@ def main(): return flavor def parse_apps() -> list[str]: + apps = set() if not arguments.arguments: - return list(apply_theme_funcs.keys()) - apps = [] + apps = set(apply_theme_funcs.keys()) + else: + allExclude = True + for arg in arguments.arguments: + if arg[0] == '!': + continue + allExclude = False + if arg not in apply_theme_funcs: + print(f"Unknown app: {arg}. Available apps: {', '.join(apply_theme_funcs.keys())}") + sys.exit(1) + apps.add(arg) + + # If all arguments are exclusions, start with all apps + if allExclude: + apps = set(apply_theme_funcs.keys()) + for arg in arguments.arguments: - if arg not in apply_theme_funcs: - print(f"Unknown app: {arg}. Available apps: {', '.join(apply_theme_funcs.keys())}") - sys.exit(1) - apps.append(arg) - return apps + if arg[0] == '!': + print(f"Excluding app: {arg[1:]}") + app = arg[1:] + if app not in apply_theme_funcs: + print(f"Unknown app to exclude: {app}. Available apps: {', '.join(apply_theme_funcs.keys())}") + sys.exit(1) + apps.discard(app) + + return list(apps) palette_name = parse_palette_name() palette = PALETTES[palette_name] diff --git a/.scripts/change-wallpaper b/.scripts/change-wallpaper index 78cff48..789ef91 100755 --- a/.scripts/change-wallpaper +++ b/.scripts/change-wallpaper @@ -80,13 +80,13 @@ if [ "$XDG_CURRENT_DESKTOP" = "Hyprland" ]; then notify-send "Wallpaper Changed" "$image" - change-colortheme -i "$image_copied" || exit 1 + change-colortheme -i "$image_copied" !quickshell || exit 1 elif [ "$XDG_CURRENT_DESKTOP" = "niri" ]; then swww img -n background "$image_copied" --transition-type fade --transition-duration 2 > /dev/null 2> /dev/null notify-send "Wallpaper Changed" "$image" - change-colortheme -i "$image_copied" || exit 1 + change-colortheme -i "$image_copied" !waybar !eww || exit 1 else echo "Unsupported desktop environment: $XDG_CURRENT_DESKTOP" exit 1 diff --git a/.scripts/config-switch b/.scripts/config-switch index cbdc2ab..c88bdb9 100755 --- a/.scripts/config-switch +++ b/.scripts/config-switch @@ -6,7 +6,7 @@ else desktop="$1" fi -for item in "waybar" "kitty" "ghostty" "wlogout"; do +for item in "kitty" "ghostty" "wlogout"; do [ -L "$HOME/.config/$item" ] || exit 1 rm "$HOME/.config/$item" diff --git a/.scripts/sl-wrap b/.scripts/sl-wrap new file mode 100755 index 0000000..81e10fd --- /dev/null +++ b/.scripts/sl-wrap @@ -0,0 +1,4 @@ +#!/bin/sh + +pgrep -f spotify-lyrics && (killall spotify-lyrics || exit 1) +spotify-lyrics "$@" diff --git a/README.md b/README.md index d9302cc..18fdfdb 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,43 @@ ## Screenshots -- desktop with a few widgets: - +- Hyprland & Waybar & Eww: -- dynamic colortheme based on Catppuccin Mocha: - + -- the grub menu looks like: +- Niri & Quickshell + + + + +- Grub menu: ## Setup Overview - **OS**: Archlinux -- **WM**: Niri & Hyprland (looks similar through screenshots) -- **Bar**: Waybar +- **WM**: Hyprland | Niri +- **Bar**: Waybar | Quickshell - **Shell**: Fish - **Prompt**: Oh My Posh - **Terminal**: Kitty & Ghostty - **Colorscheme**: Catppuccin Mocha - **App Launcher**: Rofi - **Logout Screen**: Wlogout -- **Desktop Widgets**: Eww +- **Desktop Widgets**: Eww | Quickshell +- **Wallpaper Darmon**: swww - **Notification Daemon**: Mako ## Hyprland & friends -Based on [end-4/dots-hyprland](https://github.com/end-4/dots-hyprland) but without ags amd tons of other stuff. +Based on an old version of [end-4/dots-hyprland](https://github.com/end-4/dots-hyprland) but without ags amd tons of other stuff. ## Niri -Ported from Hyprland, and shares most of the desktop components such as hyprlock & eww widgets & rofi & waybar & mako. +Ported from Hyprland, and shares some of the desktop components such as hyprlock & hypridle & mako, but uses quickshell as bar and desktop-widgets instead of the combination of waybar and eww. + +## Quickshell + +Not based on, but heaviely depends on modules from [noctalia-shell](https://github.com/noctalia-dev/noctalia-shell). This setup is currently only adapted for Niri. ## Eww @@ -37,13 +45,17 @@ Ported from Hyprland, and shares most of the desktop components such as hyprlock - `lyrics`, scrolling lyrics player, depends on [a small program](https://github.com/Uyanide/spotify-lyrics) from myself (which also happens to be my frist Golang program :D). - `lyrics-single`, similar to `lyrics`, but only with a single line and can be easily embeded into the status bar. +# SWWW + +In Niri, the wallpaper will be automatically blurred when there are windows in focus. And the backdrop also has a blurred wallpaper applied to it. These are implemented in [wallpaper-daemon](https://github.com/Uyanide/dotfiles/blob/main/.scripts/wallpaper-daemon). + ## Rofi Based on [codeopshq/dotfiles](https://github.com/codeopshq/dotfiles), also serves as cliphist browser and emojis picker. ## Grub theme -Based on [vinceliuice/Elegant-grub2-themes](https://github.com/vinceliuice/Elegant-grub2-themes) with [illustration from 紺屋鴉江](https://www.pixiv.net/artworks/119683453). +Based on [vinceliuice/Elegant-grub2-themes](https://github.com/vinceliuice/Elegant-grub2-themes) with the [illustration from 紺屋鴉江](https://www.pixiv.net/artworks/119683453). ## MPV @@ -57,8 +69,8 @@ See [backgrounds repo for personal usage](https://github.com/Uyanide/backgrounds including: -- MesloLGM Nerd Font (& Mono) - Maple Mono NF CN +- MesloLGM Nerd Font (& Mono) - WenQuanYi Micro Hei - Sour Gummy - Noto Sans diff --git a/backgrounds b/backgrounds index 49aad19..ec1022f 160000 --- a/backgrounds +++ b/backgrounds @@ -1 +1 @@ -Subproject commit 49aad198141797bb8a67d51b94fe1bd2006d2e71 +Subproject commit ec1022ffec5c8f7909b42f6fee684527ed7d62bf diff --git a/niri/config.kdl b/niri/config.kdl index de7a316..904f147 100644 --- a/niri/config.kdl +++ b/niri/config.kdl @@ -27,7 +27,7 @@ input { warp-mouse-to-focus // Focus windows and outputs automatically when moving the mouse into them. - focus-follows-mouse max-scroll-amount="75%" + focus-follows-mouse max-scroll-amount="100%" } /************************Output************************/ @@ -114,11 +114,6 @@ layer-rule { } -layer-rule { - match namespace="^quickshell-bar$" - place-within-backdrop false -} - /************************Autostart************************/ @@ -126,7 +121,7 @@ layer-rule { spawn-sh-at-startup "config-switch niri" // Bar -spawn-at-startup "waybar" +spawn-at-startup "quickshell" // Wallpaper spawn-at-startup "wallpaper-daemon" @@ -141,7 +136,7 @@ spawn-sh-at-startup "gnome-keyring-daemon --start --components=secrets" spawn-at-startup "/usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1" spawn-at-startup "mako" -// idle +// Idle spawn-sh-at-startup "hypridle" // Clipboard history @@ -286,8 +281,8 @@ binds { Mod+Shift+W { spawn "wallpaper-chooser"; } // EWW - Mod+Space { spawn-sh "eww open main --toggle"; } - Mod+Shift+L { spawn-sh "lyrics-widgets"; } + Mod+Space { spawn-sh "qs ipc call panels toggleControlCenter"; } + Mod+Shift+L { spawn-sh "qs ipc call lyrics toggleBarLyrics"; } // Waybar Mod+Shift+K { spawn-sh "waybar-toggle"; } diff --git a/niri/config.kdl.template b/niri/config.kdl.template index d836191..7376d22 100644 --- a/niri/config.kdl.template +++ b/niri/config.kdl.template @@ -27,7 +27,7 @@ input { warp-mouse-to-focus // Focus windows and outputs automatically when moving the mouse into them. - focus-follows-mouse max-scroll-amount="75%" + focus-follows-mouse max-scroll-amount="100%" } /************************Output************************/ @@ -121,7 +121,7 @@ layer-rule { spawn-sh-at-startup "config-switch niri" // Bar -spawn-at-startup "waybar" +spawn-at-startup "quickshell" // Wallpaper spawn-at-startup "wallpaper-daemon" @@ -136,7 +136,7 @@ spawn-sh-at-startup "gnome-keyring-daemon --start --components=secrets" spawn-at-startup "/usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1" spawn-at-startup "mako" -// idle +// Idle spawn-sh-at-startup "hypridle" // Clipboard history @@ -281,8 +281,8 @@ binds { Mod+Shift+W { spawn "wallpaper-chooser"; } // EWW - Mod+Space { spawn-sh "eww open main --toggle"; } - Mod+Shift+L { spawn-sh "lyrics-widgets"; } + Mod+Space { spawn-sh "qs ipc call panels toggleControlCenter"; } + Mod+Shift+L { spawn-sh "qs ipc call lyrics toggleBarLyrics"; } // Waybar Mod+Shift+K { spawn-sh "waybar-toggle"; } diff --git a/quickshell/Assets/Config/.gitignore b/quickshell/Assets/Config/.gitignore new file mode 100644 index 0000000..f1b0917 --- /dev/null +++ b/quickshell/Assets/Config/.gitignore @@ -0,0 +1,4 @@ + +Location.json + +GeoInfoToken.txt \ No newline at end of file diff --git a/quickshell/Assets/Config/LyricsOffset.txt b/quickshell/Assets/Config/LyricsOffset.txt new file mode 100644 index 0000000..c227083 --- /dev/null +++ b/quickshell/Assets/Config/LyricsOffset.txt @@ -0,0 +1 @@ +0 \ No newline at end of file diff --git a/quickshell/Assets/Config/Settings.json b/quickshell/Assets/Config/Settings.json new file mode 100644 index 0000000..8f52f69 --- /dev/null +++ b/quickshell/Assets/Config/Settings.json @@ -0,0 +1,4 @@ +{ + "primaryColor": "#89b4fa", + "showLyricsBar": true +} diff --git a/quickshell/Assets/Images/Avatar.jpg b/quickshell/Assets/Images/Avatar.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a39dbbcf28d6707cd4421ce5d49068b74a7de106 GIT binary patch literal 21572 zcmbTdbx>U2wl3N@A-E*Cy9H|;8VT<15FDD|4grEg;~HEW_uv)?4#C}nYaqDG?cY9o zpL^aPuim?NRgLad)vH$5n8UvD%`smWUsnJj090gT6l5e+6cm&oHBPJ!Kq-AHIq-LcdC1vDeV&≦o+fR5D?+#5@zS-;evbf<_-E=bV3Xa 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 yy0RSAs)a 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/quickshell/Modules/Panel/Cards/SystemMonitorCard.qml b/quickshell/Modules/Panel/Cards/SystemMonitorCard.qml new file mode 100644 index 0000000..ad24efa --- /dev/null +++ b/quickshell/Modules/Panel/Cards/SystemMonitorCard.qml @@ -0,0 +1,61 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import qs.Constants +import qs.Modules.Panel.Misc +import qs.Noctalia +import qs.Services +import qs.Utils + +// Unified system card: monitors CPU, temp, memory, disk +NBox { + id: root + + compact: true + + ColumnLayout { + id: content + + anchors.fill: parent + anchors.margins: Style.marginXS + 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/quickshell/Modules/Panel/Cards/TopLeftCard.qml b/quickshell/Modules/Panel/Cards/TopLeftCard.qml new file mode 100644 index 0000000..cbfd7bb --- /dev/null +++ b/quickshell/Modules/Panel/Cards/TopLeftCard.qml @@ -0,0 +1,175 @@ +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/quickshell/Modules/Panel/ControlCenterPanel.qml b/quickshell/Modules/Panel/ControlCenterPanel.qml new file mode 100644 index 0000000..8f2cfb4 --- /dev/null +++ b/quickshell/Modules/Panel/ControlCenterPanel.qml @@ -0,0 +1,86 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +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/quickshell/Modules/Panel/Misc/MonitorSlider.qml b/quickshell/Modules/Panel/Misc/MonitorSlider.qml new file mode 100644 index 0000000..778d113 --- /dev/null +++ b/quickshell/Modules/Panel/Misc/MonitorSlider.qml @@ -0,0 +1,54 @@ +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/quickshell/Noctalia/NBox.qml b/quickshell/Noctalia/NBox.qml new file mode 100644 index 0000000..c753d07 --- /dev/null +++ b/quickshell/Noctalia/NBox.qml @@ -0,0 +1,28 @@ +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/quickshell/Noctalia/NBusyIndicator.qml b/quickshell/Noctalia/NBusyIndicator.qml new file mode 100644 index 0000000..137f37b --- /dev/null +++ b/quickshell/Noctalia/NBusyIndicator.qml @@ -0,0 +1,53 @@ +import QtQuick +import qs.Constants +import qs.Noctalia + +Item { + id: root + + property bool running: true + property color color: Color.mPrimary + property int size: Style.baseWidgetSize + property int strokeWidth: Style.borderL + property int duration: Style.animationSlow * 2 + + implicitWidth: size + implicitHeight: size + + Canvas { + id: canvas + + property real rotationAngle: 0 + + anchors.fill: parent + onPaint: { + var ctx = getContext("2d"); + ctx.reset(); + var centerX = width / 2; + var centerY = height / 2; + var radius = Math.min(width, height) / 2 - strokeWidth / 2; + ctx.strokeStyle = root.color; + ctx.lineWidth = Math.max(1, root.strokeWidth); + ctx.lineCap = "round"; + // Draw arc with gap (270 degrees with 90 degree gap) + ctx.beginPath(); + ctx.arc(centerX, centerY, radius, -Math.PI / 2 + rotationAngle, -Math.PI / 2 + rotationAngle + Math.PI * 1.5); + ctx.stroke(); + } + onRotationAngleChanged: { + requestPaint(); + } + + NumberAnimation { + target: canvas + property: "rotationAngle" + running: root.running + from: 0 + to: 2 * Math.PI + duration: root.duration + loops: Animation.Infinite + } + + } + +} diff --git a/quickshell/Noctalia/NCircleStat.qml b/quickshell/Noctalia/NCircleStat.qml new file mode 100644 index 0000000..59dc94f --- /dev/null +++ b/quickshell/Noctalia/NCircleStat.qml @@ -0,0 +1,122 @@ +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/quickshell/Noctalia/NContextMenu.qml b/quickshell/Noctalia/NContextMenu.qml new file mode 100644 index 0000000..c2d735d --- /dev/null +++ b/quickshell/Noctalia/NContextMenu.qml @@ -0,0 +1,124 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import qs.Constants +import qs.Noctalia +import qs.Services +import qs.Utils + +Popup { + id: root + + property alias model: listView.model + property real itemHeight: 36 + property real itemPadding: Style.marginM + + signal triggered(string action) + + // Helper function to open at mouse position + function openAt(x, y) { + root.x = x; + root.y = y; + root.open(); + } + + // Helper function to open at item + function openAtItem(item, mouseX, mouseY) { + var pos = item.mapToItem(root.parent, mouseX || 0, mouseY || 0); + openAt(pos.x, pos.y); + } + + 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) + radius: Style.radiusM + } + + contentItem: NListView { + id: listView + + implicitHeight: contentHeight + spacing: Style.marginXXS + interactive: contentHeight > root.height + + delegate: ItemDelegate { + id: menuItem + + // Store reference to the popup + property var popup: root + + width: listView.width + height: modelData.visible !== false ? root.itemHeight : 0 + visible: modelData.visible !== false + opacity: modelData.enabled !== false ? 1 : 0.5 + enabled: modelData.enabled !== false + onClicked: { + if (enabled) { + popup.triggered(modelData.action || modelData.key || index.toString()); + popup.close(); + } + } + + background: Rectangle { + color: menuItem.hovered && menuItem.enabled ? Color.mTertiary : Color.transparent + radius: Style.radiusS + + Behavior on color { + ColorAnimation { + duration: Style.animationFast + } + + } + + } + + contentItem: RowLayout { + spacing: Style.marginS + + // Optional icon + NIcon { + visible: modelData.icon !== undefined + icon: modelData.icon || "" + pointSize: Style.fontSizeM + color: menuItem.hovered && menuItem.enabled ? Color.mOnTertiary : Color.mOnSurface + Layout.leftMargin: root.itemPadding + + Behavior on color { + ColorAnimation { + duration: Style.animationFast + } + + } + + } + + NText { + text: modelData.label || modelData.text || "" + pointSize: Style.fontSizeM + color: menuItem.hovered && menuItem.enabled ? Color.mOnTertiary : Color.mOnSurface + verticalAlignment: Text.AlignVCenter + Layout.fillWidth: true + Layout.leftMargin: modelData.icon === undefined ? root.itemPadding : 0 + + Behavior on color { + ColorAnimation { + duration: Style.animationFast + } + + } + + } + + } + + } + + } + +} diff --git a/quickshell/Noctalia/NDivider.qml b/quickshell/Noctalia/NDivider.qml new file mode 100644 index 0000000..d476143 --- /dev/null +++ b/quickshell/Noctalia/NDivider.qml @@ -0,0 +1,35 @@ +import QtQuick +import Quickshell +import Quickshell.Widgets +import qs.Constants + +Rectangle { + width: parent.width + height: Math.max(1, Style.borderS) + + gradient: Gradient { + orientation: Gradient.Horizontal + + GradientStop { + position: 0 + color: Color.transparent + } + + GradientStop { + position: 0.1 + color: Color.mOutline + } + + GradientStop { + position: 0.9 + color: Color.mOutline + } + + GradientStop { + position: 1 + color: Color.transparent + } + + } + +} diff --git a/quickshell/Noctalia/NIcon.qml b/quickshell/Noctalia/NIcon.qml new file mode 100644 index 0000000..831aa60 --- /dev/null +++ b/quickshell/Noctalia/NIcon.qml @@ -0,0 +1,28 @@ +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/quickshell/Noctalia/NIconButton.qml b/quickshell/Noctalia/NIconButton.qml new file mode 100644 index 0000000..bebffe4 --- /dev/null +++ b/quickshell/Noctalia/NIconButton.qml @@ -0,0 +1,92 @@ +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/quickshell/Noctalia/NImageCircled.qml b/quickshell/Noctalia/NImageCircled.qml new file mode 100644 index 0000000..d091fe1 --- /dev/null +++ b/quickshell/Noctalia/NImageCircled.qml @@ -0,0 +1,85 @@ +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/quickshell/Noctalia/NImageRounded.qml b/quickshell/Noctalia/NImageRounded.qml new file mode 100644 index 0000000..d7dfea3 --- /dev/null +++ b/quickshell/Noctalia/NImageRounded.qml @@ -0,0 +1,103 @@ +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/quickshell/Noctalia/NListView.qml b/quickshell/Noctalia/NListView.qml new file mode 100644 index 0000000..2d79633 --- /dev/null +++ b/quickshell/Noctalia/NListView.qml @@ -0,0 +1,217 @@ +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 handleHoverColor: handleColor + property color handlePressedColor: handleColor + property color trackColor: Color.transparent + property real handleWidth: 6 + property real handleRadius: Style.radiusM + property int verticalPolicy: ScrollBar.AsNeeded + property int horizontalPolicy: ScrollBar.AsNeeded + // Forward ListView properties + property alias model: listView.model + property alias delegate: listView.delegate + property alias spacing: listView.spacing + property alias orientation: listView.orientation + property alias currentIndex: listView.currentIndex + property alias count: listView.count + property alias contentHeight: listView.contentHeight + property alias contentWidth: listView.contentWidth + property alias contentY: listView.contentY + property alias contentX: listView.contentX + property alias currentItem: listView.currentItem + property alias highlightItem: listView.highlightItem + property alias headerItem: listView.headerItem + property alias footerItem: listView.footerItem + property alias section: listView.section + property alias highlightFollowsCurrentItem: listView.highlightFollowsCurrentItem + property alias highlightMoveDuration: listView.highlightMoveDuration + property alias highlightMoveVelocity: listView.highlightMoveVelocity + property alias preferredHighlightBegin: listView.preferredHighlightBegin + property alias preferredHighlightEnd: listView.preferredHighlightEnd + property alias highlightRangeMode: listView.highlightRangeMode + property alias snapMode: listView.snapMode + property alias keyNavigationWraps: listView.keyNavigationWraps + property alias cacheBuffer: listView.cacheBuffer + property alias displayMarginBeginning: listView.displayMarginBeginning + property alias displayMarginEnd: listView.displayMarginEnd + property alias layoutDirection: listView.layoutDirection + property alias effectiveLayoutDirection: listView.effectiveLayoutDirection + property alias verticalLayoutDirection: listView.verticalLayoutDirection + property alias boundsBehavior: listView.boundsBehavior + property alias flickableDirection: listView.flickableDirection + property alias interactive: listView.interactive + property alias moving: listView.moving + property alias flicking: listView.flicking + property alias dragging: listView.dragging + property alias horizontalVelocity: listView.horizontalVelocity + property alias verticalVelocity: listView.verticalVelocity + + // Forward ListView methods + function positionViewAtIndex(index, mode) { + listView.positionViewAtIndex(index, mode); + } + + function positionViewAtBeginning() { + listView.positionViewAtBeginning(); + } + + function positionViewAtEnd() { + listView.positionViewAtEnd(); + } + + function forceLayout() { + listView.forceLayout(); + } + + function cancelFlick() { + listView.cancelFlick(); + } + + function flick(xVelocity, yVelocity) { + listView.flick(xVelocity, yVelocity); + } + + function incrementCurrentIndex() { + listView.incrementCurrentIndex(); + } + + function decrementCurrentIndex() { + listView.decrementCurrentIndex(); + } + + function indexAt(x, y) { + return listView.indexAt(x, y); + } + + function itemAt(x, y) { + return listView.itemAt(x, y); + } + + function itemAtIndex(index) { + return listView.itemAtIndex(index); + } + + // Set reasonable implicit sizes for Layout usage + implicitWidth: 200 + implicitHeight: 200 + + ListView { + id: listView + + anchors.fill: parent + // Enable clipping to keep content within bounds + 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 + } + + } + + } + + 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 + + parent: listView + x: 0 + y: listView.height - height + width: listView.width + active: listView.ScrollBar.vertical.active + policy: root.horizontalPolicy + + 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 || parent.active ? 1 : 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 || parent.active ? 0.3 : 0 + radius: root.handleRadius / 2 + + Behavior on opacity { + NumberAnimation { + duration: Style.animationFast + } + + } + + } + + } + + } + +} diff --git a/quickshell/Noctalia/NPanel.qml b/quickshell/Noctalia/NPanel.qml new file mode 100644 index 0000000..107bf44 --- /dev/null +++ b/quickshell/Noctalia/NPanel.qml @@ -0,0 +1,459 @@ +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/quickshell/Noctalia/NSlider.qml b/quickshell/Noctalia/NSlider.qml new file mode 100644 index 0000000..45f8491 --- /dev/null +++ b/quickshell/Noctalia/NSlider.qml @@ -0,0 +1,152 @@ +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/quickshell/Noctalia/NText.qml b/quickshell/Noctalia/NText.qml new file mode 100644 index 0000000..83ec766 --- /dev/null +++ b/quickshell/Noctalia/NText.qml @@ -0,0 +1,20 @@ +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 + elide: Text.ElideRight + wrapMode: Text.NoWrap + verticalAlignment: Text.AlignVCenter +} diff --git a/quickshell/Services/AudioService.qml b/quickshell/Services/AudioService.qml index 5c7c55f..0b63944 100644 --- a/quickshell/Services/AudioService.qml +++ b/quickshell/Services/AudioService.qml @@ -4,6 +4,7 @@ import QtQuick import Quickshell import Quickshell.Io import Quickshell.Services.Pipewire +import qs.Utils Singleton { id: root @@ -60,6 +61,7 @@ Singleton { function onMutedChanged() { root._muted = (sink?.audio.muted ?? true) + Logger.log("AudioService", "OnMuteChanged:", root._muted) } } @@ -76,6 +78,7 @@ Singleton { function onMutedChanged() { root._inputMuted = (source?.audio.muted ?? true) + Logger.log("AudioService", "OnInputMuteChanged:", root._inputMuted) } } @@ -93,7 +96,7 @@ Singleton { sink.audio.muted = false sink.audio.volume = Math.max(0, Math.min(1.0, newVolume)) } else { - console.warn("No sink available") + Logger.warn("AudioService", "No sink available") } } @@ -101,7 +104,7 @@ Singleton { if (sink?.ready && sink?.audio) { sink.audio.muted = muted } else { - console.warn("No sink available") + Logger.warn("AudioService", "No sink available") } } @@ -111,7 +114,7 @@ Singleton { source.audio.muted = false source.audio.volume = Math.max(0, Math.min(1.0, newVolume)) } else { - console.warn("No source available") + Logger.warn("AudioService", "No source available") } } @@ -119,7 +122,7 @@ Singleton { if (source?.ready && source?.audio) { source.audio.muted = muted } else { - console.warn("No source available") + Logger.warn("AudioService", "No source available") } } diff --git a/quickshell/Services/BrightnessService.qml b/quickshell/Services/BrightnessService.qml index 72fc110..1bb59da 100644 --- a/quickshell/Services/BrightnessService.qml +++ b/quickshell/Services/BrightnessService.qml @@ -3,6 +3,7 @@ pragma Singleton import QtQuick import Quickshell import Quickshell.Io +import qs.Utils Singleton { id: root @@ -44,6 +45,10 @@ Singleton { reloadableId: "brightness" + Component.onCompleted: { + Logger.log("Brightness", "Service started") + } + onMonitorsChanged: { ddcMonitors = [] ddcProc.running = true @@ -80,7 +85,7 @@ Singleton { var ddcModel = ddcModelMatch ? ddcModelMatch.length > 0 : false var model = modelMatch ? modelMatch[1] : "Unknown" var bus = busMatch ? busMatch[1] : "Unknown" - console.log("Detected DDC Monitor:", model, "on bus", bus, "is DDC:", !ddcModel) + Logger.log("Brigthness", "Detected DDC Monitor:", model, "on bus", bus, "is DDC:", !ddcModel) return { "model": model, "busNum": bus, @@ -188,7 +193,7 @@ Singleton { var val = parseInt(dataText) if (!isNaN(val)) { monitor.brightness = val / 101 - console.log("Apple display brightness:", monitor.brightness) + Logger.log("Brightness", "Apple display brightness:", monitor.brightness) } } else if (monitor.isDdc) { var parts = dataText.split(" ") @@ -197,7 +202,7 @@ Singleton { var max = parseInt(parts[4]) if (!isNaN(current) && !isNaN(max) && max > 0) { monitor.brightness = current / max - console.log("DDC brightness:", monitor.brightness) + Logger.log("Brightness", "DDC brightness:", current + "/" + max + " =", monitor.brightness) } } } else { @@ -213,8 +218,8 @@ Singleton { if (!isNaN(current) && !isNaN(max) && max > 0) { monitor.maxBrightness = max monitor.brightness = current / max - console.log("Internal brightness:", monitor.brightness) - console.log("Using backlight device:", monitor.backlightDevice) + Logger.log("Brightness", "Internal brightness:", current + "/" + max + " =", monitor.brightness) + Logger.log("Brightness", "Using backlight device:", monitor.backlightDevice) } } } diff --git a/quickshell/Services/Caffeine.qml b/quickshell/Services/Caffeine.qml index 4dc9946..51e0eab 100644 --- a/quickshell/Services/Caffeine.qml +++ b/quickshell/Services/Caffeine.qml @@ -2,6 +2,7 @@ import QtQuick import Quickshell import Quickshell.Io import qs.Services +import qs.Utils pragma Singleton Singleton { @@ -136,10 +137,10 @@ Singleton { if (isInhibited) isInhibited = false; - console.log("Inhibitor process exited with code:", exitCode, "status:", exitStatus); + Logger.log("Caffeine", "Inhibitor process exited with code:", exitCode, "status:", exitStatus); } onStarted: function() { - console.log("Inhibitor process started with strategy:", root.strategy); + Logger.log("Caffeine", "Inhibitor process started with PID:", inhibitorProcess.processId); } } diff --git a/quickshell/Services/IPCService.qml b/quickshell/Services/IPCService.qml new file mode 100644 index 0000000..66a5425 --- /dev/null +++ b/quickshell/Services/IPCService.qml @@ -0,0 +1,35 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Constants + +Item { + IpcHandler { + function setPrimary(color: color) { + SettingsService.primaryColor = color; + } + + target: "colors" + } + + IpcHandler { + function toggleCalendar() { + calendarPanel.toggle(); + } + + function toggleControlCenter() { + controlCenterPanel.toggle(); + } + + target: "panels" + } + + IpcHandler { + function toggleBarLyrics() { + SettingsService.showLyricsBar = !SettingsService.showLyricsBar; + } + + target: "lyrics" + } + +} diff --git a/quickshell/Services/IpService.qml b/quickshell/Services/IpService.qml index 22b273f..cb5a775 100644 --- a/quickshell/Services/IpService.qml +++ b/quickshell/Services/IpService.qml @@ -1,6 +1,7 @@ import QtQuick import Quickshell import Quickshell.Io +import qs.Utils pragma Singleton Singleton { @@ -9,28 +10,93 @@ Singleton { property real fetchInterval: 30 // in s property real fetchTimeout: 10 // in s property string ipURL: "https://api.uyanide.com/ip" - property string geoURL: "curl https://api.ipinfo.io/lite/" + property string geoURL: "https://api.ipinfo.io/lite/" property string geoURLToken: "" function fetchIP() { - if (fetchIPProcess.running) { - console.warn("Fetch IP process is still running, skipping fetchIP"); - return ; - } - fetchIPProcess.running = true; + const xhr = new XMLHttpRequest(); + xhr.timeout = fetchTimeout * 1000; + xhr.onreadystatechange = function() { + if (xhr.readyState === XMLHttpRequest.DONE) { + if (xhr.status === 200) { + try { + const response = JSON.parse(xhr.responseText); + if (response && response.ip) { + let newIP = response.ip; + Logger.log("IpService", "Fetched IP: " + newIP); + if (newIP !== ip) { + ip = newIP; + fetchGeoInfo(); // Fetch geo info only if IP has changed + } + } else { + ip = "N/A"; + countryCode = "N/A"; + Logger.error("IpService", "IP response does not contain 'ip' field"); + } + } catch (e) { + ip = "N/A"; + countryCode = "N/A"; + Logger.error("IpService", "Failed to parse IP response: " + e); + } + } else { + ip = "N/A"; + countryCode = "N/A"; + Logger.error("IpService", "Failed to fetch IP, status: " + xhr.status); + } + } + }; + xhr.ontimeout = function() { + ip = "N/A"; + countryCode = "N/A"; + Logger.error("IpService", "Fetch IP request timed out"); + }; + xhr.open("GET", ipURL); + xhr.send(); } function fetchGeoInfo() { - if (fetchGeoProcess.running) { - console.warn("Fetch geo process is still running, skipping fetchGeoInfo"); - return ; - } if (!ip || ip === "N/A") { countryCode = "N/A"; return ; } - fetchGeoProcess.command = ["sh", "-c", `curl -L -m ${fetchTimeout.toString()} ${geoURL}${ip}${geoURLToken ? "?token=" + geoURLToken : ""}`]; - fetchGeoProcess.running = true; + const xhr = new XMLHttpRequest(); + xhr.timeout = fetchTimeout * 1000; + xhr.onreadystatechange = function() { + if (xhr.readyState === XMLHttpRequest.DONE) { + if (xhr.status === 200) { + try { + const response = JSON.parse(xhr.responseText); + if (response && response.country) { + let newCountryCode = response.country_code; + Logger.log("IpService", "Fetched country code: " + newCountryCode); + if (newCountryCode !== countryCode) { + countryCode = newCountryCode; + SendNotification.show("New IP", `IP: ${ip}\nCountry: ${newCountryCode}`); + } + } else { + countryCode = "N/A"; + Logger.error("IpService", "Geo response does not contain 'country' field"); + } + } catch (e) { + countryCode = "N/A"; + Logger.error("IpService", "Failed to parse geo response: " + e); + } + } else { + countryCode = "N/A"; + Logger.error("IpService", "Failed to fetch geo info, status: " + xhr.status); + } + } + }; + xhr.ontimeout = function() { + countryCode = "N/A"; + Logger.error("IpService", "Fetch geo info request timed out"); + }; + let url = geoURL + ip; + if (geoURLToken) + url += "?token=" + geoURLToken; + + xhr.open("GET", url); + xhr.send(); } function refresh() { @@ -46,11 +112,11 @@ Singleton { FileView { id: tokenFile - path: Qt.resolvedUrl("../Assets/Ip/token.txt") + path: Qt.resolvedUrl("../Assets/Config/GeoInfoToken.txt") onLoaded: { geoURLToken = tokenFile.text(); if (!geoURLToken) - console.warn("No token found for geoIP service, assuming none is required"); + Logger.warn("IpService", "No token found for geoIP service, assuming none is required"); fetchIP(); fetchTimer.start(); @@ -68,64 +134,4 @@ Singleton { } } - Process { - id: fetchIPProcess - - command: ["sh", "-c", `curl -L -m ${fetchTimeout.toString()} ${ipURL}`] - running: false - - stdout: SplitParser { - splitMarker: "" - onRead: (data) => { - let newIP = ""; - try { - const response = JSON.parse(data); - if (response && response.ip) { - newIP = response.ip; - console.log("Fetched IP: " + newIP); - } - } catch (e) { - console.error("Failed to parse IP response: " + e); - } - if (newIP && newIP !== ip) { - ip = newIP; - fetchGeoInfo(); - } else if (!newIP) { - ip = "N/A"; - countryCode = "N/A"; - } - } - } - - } - - Process { - id: fetchGeoProcess - - command: [] - running: false - - stdout: SplitParser { - splitMarker: "" - onRead: (data) => { - let newCountryCode = ""; - try { - const response = JSON.parse(data); - if (response && response.country) { - newCountryCode = response.country_code; - console.log("Fetched country code: " + newCountryCode); - SendNotification.show("New IP", `IP: ${ip}\nCountry: ${newCountryCode}`); - } - } catch (e) { - console.error("Failed to parse geo response: " + e); - } - if (newCountryCode && newCountryCode !== countryCode) - countryCode = newCountryCode; - else if (!newCountryCode) - countryCode = "N/A"; - } - } - - } - } diff --git a/quickshell/Services/LocationService.qml b/quickshell/Services/LocationService.qml new file mode 100644 index 0000000..8f5f70b --- /dev/null +++ b/quickshell/Services/LocationService.qml @@ -0,0 +1,323 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Constants +import qs.Utils +pragma Singleton + +// Weather logic and caching with stable UI properties +Singleton { + //console.log(JSON.stringify(weatherData)) + + id: root + + property string locationName: "Munich" + property string locationFile: Qt.resolvedUrl("../Assets/Config/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 + property bool coordinatesReady: false + property string stableLatitude: "" + property string stableLongitude: "" + property string stableName: "" + // Helper property for UI components (outside JsonAdapter to avoid binding loops) + readonly property string displayCoordinates: { + if (!root.coordinatesReady || root.stableLatitude === "" || root.stableLongitude === "") + return ""; + + const lat = parseFloat(root.stableLatitude).toFixed(4); + const lon = parseFloat(root.stableLongitude).toFixed(4); + return `${lat}, ${lon}`; + } + + // -------------------------------- + function init() { + // does nothing but ensure the singleton is created + // do not remove + Logger.log("Location", "Service started"); + } + + // -------------------------------- + function resetWeather() { + Logger.log("Location", "Resetting weather data"); + // Mark as changing to prevent UI updates + 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(); + } + + // -------------------------------- + function updateWeather() { + if (isFetchingWeather) { + Logger.warn("Location", "Weather is still fetching"); + 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); + } + 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 + adapter.latitude = latitude.toString(); + adapter.longitude = longitude.toString(); + root.stableName = `${name}, ${country}`; + _fetchWeather(latitude, longitude, errorCallback); + }, 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"; + 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); + } + } + }; + xhr.open("GET", geoUrl); + xhr.send(); + } + + // -------------------------------- + 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"; + var xhr = new XMLHttpRequest(); + xhr.onreadystatechange = function() { + if (xhr.readyState === XMLHttpRequest.DONE) { + 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.log("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); + isFetchingWeather = false; + } + + // -------------------------------- + function weatherSymbolFromCode(code) { + if (code === 0) + return "weather-sun"; + + if (code === 1 || code === 2) + return "weather-cloud-sun"; + + if (code === 3) + return "weather-cloud"; + + if (code >= 45 && code <= 48) + return "weather-cloud-haze"; + + if (code >= 51 && code <= 67) + return "weather-cloud-rain"; + + if (code >= 71 && code <= 77) + return "weather-cloud-snow"; + + if (code >= 71 && code <= 77) + return "weather-cloud-snow"; + + if (code >= 85 && code <= 86) + return "weather-cloud-snow"; + + if (code >= 95 && code <= 99) + return "weather-cloud-lightning"; + + 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) + return "Clear sky"; + + if (code === 1) + return "Mainly clear"; + + if (code === 2) + return "Partly cloudy"; + + if (code === 3) + return "Overcast"; + + if (code === 45 || code === 48) + return "Fog"; + + if (code >= 51 && code <= 67) + return "Drizzle"; + + if (code >= 71 && code <= 77) + return "Snow"; + + if (code >= 80 && code <= 82) + return "Rain showers"; + + if (code >= 95 && code <= 99) + return "Thunderstorm"; + + return "Unknown"; + } + + // -------------------------------- + function celsiusToFahrenheit(celsius) { + return 32 + celsius * 1.8; + } + + FileView { + id: locationFileView + + path: locationFile + printErrors: false + onAdapterUpdated: saveTimer.start() + onLoaded: { + Logger.log("Location", "Loaded cached data"); + // Initialize stable properties on load + 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"); + } + updateWeather(); + } + onLoadFailed: function(error) { + updateWeather(); + } + + JsonAdapter { + id: adapter + + // Core data properties + property string latitude: "" + property string longitude: "" + property string name: "" + property int weatherLastFetch: 0 + property var weather: null + } + + } + + // Every 20s check if we need to fetch new weather + Timer { + id: updateTimer + + interval: 20 * 1000 + running: true + repeat: true + onTriggered: { + updateWeather(); + } + } + + Timer { + id: saveTimer + + running: false + interval: 1000 + onTriggered: locationFileView.writeAdapter() + } + +} diff --git a/quickshell/Services/LyricsService.qml b/quickshell/Services/LyricsService.qml new file mode 100644 index 0000000..e8d19ed --- /dev/null +++ b/quickshell/Services/LyricsService.qml @@ -0,0 +1,144 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Services +import qs.Utils +pragma Singleton + +Singleton { + property int linesCount: 3 + property int linesAhead: linesCount / 2 + property int currentIndex: linesCount - linesAhead - 1 + property string offsetFile: Qt.resolvedUrl("../Assets/Config/LyricsOffset.txt") + property int offset: 0 // in ms + property int offsetStep: 500 // in ms + property int referenceCount: 0 + // with linesCount=3 and linesAhead=1, lyrics will be like: + // line 1 + // line 2 <- current line + // line 3 + property var lyrics: Array(linesCount).fill(" ") + + function startSyncing() { + referenceCount++; + Logger.log("LyricsService", "Reference count:", referenceCount); + if (referenceCount === 1) { + Logger.log("LyricsService", "Starting lyrics syncing"); + // fill lyrics with empty lines + lyrics = Array(linesCount).fill(" "); + listenProcess.exec(["sh", "-c", `sl-wrap listen -l ${linesCount} -a ${linesAhead} -f ${offsetFile.slice(7)}`]); + } + } + + function stopSyncing() { + referenceCount--; + Logger.log("LyricsService", "Reference count:", referenceCount); + if (referenceCount <= 0) { + Logger.log("LyricsService", "Stopping lyrics syncing"); + // Execute again to stop + // kinda ugly but works, but meanwhile: + // listenProcess.signal(9) + // listenProcess.signal(15) + // listenProcess.running = false + // counts on exec() to terminate previous exec() + // all don't work + listenProcess.exec(["sh", "-c", `sl-wrap trackid`]); + } + } + + function writeOffset() { + offsetFileView.setText(String(offset)); + } + + function increaseOffset() { + offset += offsetStep; + } + + function decreaseOffset() { + offset -= offsetStep; + } + + function resetOffset() { + offset = 0; + } + + function clearCache() { + action.command = ["sh", "-c", "spotify-lyrics clear $(spotify-lyrics trackid)"]; + action.startDetached(); + } + + function showLyricsText() { + action.command = ["sh", "-c", "ghostty -e sh -c 'spotify-lyrics fetch | less'"]; + action.startDetached(); + } + + onOffsetChanged: { + if (SettingsService.showLyricsBar) + SendNotification.show("Lyrics Offset Changed", `Current offset: ${offset} ms`, 1000); + + writeOffset(); + } + + Process { + id: listenProcess + + running: false + + stdout: SplitParser { + splitMarker: "" + onRead: (data) => { + lyrics = data.split("\n").slice(0, linesCount); + if (lyrics.length < linesCount) { + // fill with empty lines if not enough + for (let i = lyrics.length; i < linesCount; i++) { + lyrics[i] = " "; + } + } + } + } + + } + + Process { + id: action + + running: false + } + + FileView { + id: offsetFileView + + path: offsetFile + watchChanges: false + onLoaded: { + try { + const fileContents = text(); + if (fileContents.length > 0) { + const val = parseInt(fileContents); + if (!isNaN(val)) { + offset = val; + Logger.log("LyricsService", "Loaded offset:", offset); + } else { + offset = 0; + writeOffset(); + } + } else { + offset = 0; + writeOffset(); + } + } catch (e) { + Logger.log("LyricsService", "Error reading offset file:", e); + } + } + onLoadFailed: { + Logger.log("LyricsService", "Error loading offset file:", errorString); + } + onSaveFailed: { + Logger.log("LyricsService", "Error saving offset file:", errorString); + } + onSaved: { + Logger.log("LyricsService", "Offset file saved."); + } + } + +} diff --git a/quickshell/Services/MusicManager.qml b/quickshell/Services/MusicManager.qml index 4d87380..fcbe174 100644 --- a/quickshell/Services/MusicManager.qml +++ b/quickshell/Services/MusicManager.qml @@ -2,6 +2,7 @@ import QtQuick import Quickshell import Quickshell.Services.Mpris import qs.Modules.Misc +import qs.Utils pragma Singleton Singleton { @@ -11,6 +12,7 @@ Singleton { 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") : "" @@ -59,11 +61,25 @@ Singleton { // 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 diff --git a/quickshell/Services/Niri.qml b/quickshell/Services/Niri.qml index 363d27d..9a12197 100644 --- a/quickshell/Services/Niri.qml +++ b/quickshell/Services/Niri.qml @@ -1,6 +1,7 @@ import QtQuick import Quickshell import Quickshell.Io +import qs.Utils pragma Singleton pragma ComponentBehavior: Bound @@ -66,7 +67,7 @@ Singleton { }); root.workspaces = workspacesList; } catch (e) { - console.error("Failed to parse workspaces:", e, line); + Logger.error("Niri", "Failed to parse workspaces:", e, line); } } } @@ -102,7 +103,7 @@ Singleton { } root.windows = windowsMap; } catch (e) { - console.error("Error parsing windows event:", e); + Logger.error("Niri", "Error parsing windows event:", e); } } else if (event.WorkspaceActivated) { workspaceProcess.running = true; @@ -120,13 +121,13 @@ Singleton { root.focusedWindowId = -1; } } catch (e) { - console.error("Error parsing window focus event:", e); + Logger.error("Niri", "Error parsing window focus event:", e); } } else if (event.OverviewOpenedOrClosed) { try { root.inOverview = event.OverviewOpenedOrClosed.is_open === true; } catch (e) { - console.error("Error parsing overview state:", e); + Logger.error("Niri", "Error parsing overview state:", e); } } else if (event.WindowOpenedOrChanged) { try { @@ -161,7 +162,7 @@ Singleton { } } catch (e) { - console.error("Error parsing window opened/changed event:", e); + Logger.error("Niri", "Error parsing window opened/changed event:", e); } } else if (event.windowClosed) { try { @@ -170,11 +171,11 @@ Singleton { delete root.windows[closedId]; } } catch (e) { - console.error("Error parsing window closed event:", e); + Logger.error("Niri", "Error parsing window closed event:", e); } } } catch (e) { - console.error("Error parsing event stream:", e, data); + Logger.error("Niri", "Error parsing event stream:", e, data); } } } diff --git a/quickshell/Services/PowerProfileService.qml b/quickshell/Services/PowerProfileService.qml new file mode 100644 index 0000000..422a1d6 --- /dev/null +++ b/quickshell/Services/PowerProfileService.qml @@ -0,0 +1,88 @@ +import QtQuick +import Quickshell +import Quickshell.Services.UPower +import qs.Services +import qs.Utils +pragma Singleton + +Singleton { + id: root + + readonly property var powerProfiles: PowerProfiles + readonly property bool available: powerProfiles && powerProfiles.hasPerformanceProfile + property int profile: powerProfiles ? powerProfiles.profile : PowerProfile.Balanced + + function getName(p) { + if (!available) + return "Unknown"; + + const prof = (p !== undefined) ? p : profile; + switch (prof) { + case PowerProfile.Performance: + return "Performance"; + case PowerProfile.Balanced: + return "Balanced"; + case PowerProfile.PowerSaver: + return "Power saver"; + default: + return "Unknown"; + } + } + + function getIcon(p) { + if (!available) + return "balanced"; + + const prof = (p !== undefined) ? p : profile; + switch (prof) { + case PowerProfile.Performance: + return "performance"; + case PowerProfile.Balanced: + return "balanced"; + case PowerProfile.PowerSaver: + return "powersaver"; + default: + return "balanced"; + } + } + + function setProfile(p) { + if (!available) + return ; + + try { + powerProfiles.profile = p; + } catch (e) { + Logger.error("PowerProfileService", "Failed to set profile:", e); + } + } + + function cycleProfile() { + if (!available) + return ; + + const current = powerProfiles.profile; + if (current === PowerProfile.Performance) + setProfile(PowerProfile.PowerSaver); + else if (current === PowerProfile.Balanced) + setProfile(PowerProfile.Performance); + else if (current === PowerProfile.PowerSaver) + setProfile(PowerProfile.Balanced); + } + + 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/quickshell/Services/SendNotification.qml b/quickshell/Services/SendNotification.qml index 661a951..197fc0f 100644 --- a/quickshell/Services/SendNotification.qml +++ b/quickshell/Services/SendNotification.qml @@ -6,7 +6,7 @@ pragma Singleton Singleton { id: root - function show(title, message, icon = "", urgency = "normal", timeout = 5000) { + function show(title, message, timeout = 5000, icon = "", urgency = "normal") { if (icon) action.command = ["notify-send", "-u", urgency, "-i", icon, "-t", timeout.toString(), title, message]; else diff --git a/quickshell/Services/SettingsService.qml b/quickshell/Services/SettingsService.qml new file mode 100644 index 0000000..ed8ac6c --- /dev/null +++ b/quickshell/Services/SettingsService.qml @@ -0,0 +1,35 @@ +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 string settingsFilePath: Qt.resolvedUrl("../Assets/Config/Settings.json") + + FileView { + id: settingsFile + + path: settingsFilePath + watchChanges: true + onFileChanged: reload() + + JsonAdapter { + id: adapter + + property string primaryColor: "#89b4fa" + property bool showLyricsBar: false + } + + } + + Connections { + target: adapter + onPrimaryColorChanged: settingsFile.writeAdapter() + onShowLyricsBarChanged: settingsFile.writeAdapter() + } + +} diff --git a/quickshell/Services/SystemStatService.qml b/quickshell/Services/SystemStatService.qml index 4605baf..bc571df 100644 --- a/quickshell/Services/SystemStatService.qml +++ b/quickshell/Services/SystemStatService.qml @@ -2,6 +2,7 @@ import Qt.labs.folderlistmodel import QtQuick import Quickshell import Quickshell.Io +import qs.Utils pragma Singleton Singleton { @@ -222,7 +223,7 @@ Singleton { } root.cpuTemp = Math.round(sum / root.intelTempValues.length); } else { - console.warn("No temperature sensors found for coretemp"); + Logger.warn("SystemStatService", "No temperature sensors found for coretemp"); root.cpuTemp = 0; } return ; @@ -328,7 +329,7 @@ Singleton { function checkNext() { if (currentIndex >= 16) { // Check up to hwmon10 - console.warn("No supported temperature sensor found"); + Logger.warn("SystemStatService", "No supported temperature sensor found"); return ; } cpuTempNameReader.path = `/sys/class/hwmon/hwmon${currentIndex}/name`; @@ -341,7 +342,7 @@ Singleton { if (root.supportedTempCpuSensorNames.includes(name)) { root.cpuTempSensorName = name; root.cpuTempHwmonPath = `/sys/class/hwmon/hwmon${currentIndex}`; - console.log(`Found ${root.cpuTempSensorName} CPU thermal sensor at ${root.cpuTempHwmonPath}`); + Logger.log("SystemStatService", `Found ${root.cpuTempSensorName} CPU thermal sensor at ${root.cpuTempHwmonPath}`); } else { currentIndex++; Qt.callLater(() => { @@ -370,7 +371,6 @@ Singleton { if (root.cpuTempSensorName === "coretemp") { // For Intel, collect all temperature values const temp = parseInt(data) / 1000; - //console.log(temp, cpuTempReader.path) root.intelTempValues.push(temp); Qt.callLater(() => { // Qt.callLater is mandatory diff --git a/quickshell/Services/WorkspaceManager.qml b/quickshell/Services/WorkspaceManager.qml index 945c13e..7d34859 100644 --- a/quickshell/Services/WorkspaceManager.qml +++ b/quickshell/Services/WorkspaceManager.qml @@ -5,6 +5,7 @@ import QtQuick import Quickshell import Quickshell.Io import qs.Services +import qs.Utils Singleton { id: root @@ -40,7 +41,7 @@ Singleton { try { Quickshell.execDetached(["niri", "msg", "action", "focus-workspace", workspaceId.toString()]); } catch (e) { - console.error("Error switching Niri workspace:", e); + Logger.error("WorkspaceManager", "Error switching Niri workspace:", e); } } diff --git a/quickshell/Shaders/qsb/circled_image.frag.qsb b/quickshell/Shaders/qsb/circled_image.frag.qsb new file mode 100644 index 0000000000000000000000000000000000000000..37a99ef08dbbc9febf0363349dbb46c1cdf5b81a GIT binary patch literal 1717 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 V)?6pu6>I*2BCn&be*?wwtCWWGn>qjh literal 0 HcmV?d00001 diff --git a/quickshell/Modules/Misc/Cava.qml b/quickshell/Utils/Cava.qml similarity index 70% rename from quickshell/Modules/Misc/Cava.qml rename to quickshell/Utils/Cava.qml index f0c87d7..82e5491 100644 --- a/quickshell/Modules/Misc/Cava.qml +++ b/quickshell/Utils/Cava.qml @@ -23,7 +23,9 @@ Scope { }, "output": { "method": "raw", - "bit_format": 8, + "data_format": "ascii", + "ascii_max_range": 100, + "bit_format": "8bit", "channels": channels, "mono_option": monoOption } @@ -33,14 +35,11 @@ Scope { Process { id: process - property int index: 0 - stdinEnabled: true running: !MusicManager.isAllPaused() command: ["cava", "-p", "/dev/stdin"] onExited: { stdinEnabled = true; - index = 0; values = Array(count).fill(0); } onStarted: { @@ -56,23 +55,14 @@ Scope { } } stdinEnabled = false; + values = Array(count).fill(0); } stdout: SplitParser { - splitMarker: "" onRead: (data) => { - const newValues = Array(count).fill(0); - for (let i = 0; i < values.length; i++) { - newValues[i] = values[i]; - } - if (process.index + data.length > count) - process.index = 0; - - for (let i = 0; i < data.length; i += 1) { - newValues[i + process.index] = Math.min(data.charCodeAt(i), 128) / 128; - } - process.index += data.length; - values = newValues; + root.values = data.slice(0, -1).split(";").map((v) => { + return parseInt(v, 10) / 100; + }); } } diff --git a/quickshell/Modules/Misc/CavaColorList.qml b/quickshell/Utils/CavaColorList.qml similarity index 100% rename from quickshell/Modules/Misc/CavaColorList.qml rename to quickshell/Utils/CavaColorList.qml diff --git a/quickshell/Utils/Logger.qml b/quickshell/Utils/Logger.qml new file mode 100644 index 0000000..063e488 --- /dev/null +++ b/quickshell/Utils/Logger.qml @@ -0,0 +1,58 @@ +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(" ") + } else { + return `[\x1b[36m[${t}]\x1b[0m ` + args.join(" ") + } + } + + function _getStackTrace() { + try { + throw new Error("Stack trace") + } catch (e) { + return e.stack + } + } + + function log(...args) { + var msg = _formatMessage(...args) + console.log(msg) + } + + function warn(...args) { + var msg = _formatMessage(...args) + console.warn(msg) + } + + function error(...args) { + var msg = _formatMessage(...args) + console.error(msg) + } + + function callStack() { + var stack = _getStackTrace() + Logger.log("Debug", "--------------------------") + Logger.log("Debug", "Current call stack") + // Split the stack into lines and log each one + var stackLines = stack.split('\n') + for (var i = 0; i < stackLines.length; i++) { + var line = stackLines[i].trim() // Remove leading/trailing whitespace + if (line.length > 0) { + // Only log non-empty lines + Logger.log("Debug", `- ${line}`) + } + } + Logger.log("Debug", "--------------------------") + } +} diff --git a/quickshell/Utils/Time.qml b/quickshell/Utils/Time.qml new file mode 100644 index 0000000..1cd3d08 --- /dev/null +++ b/quickshell/Utils/Time.qml @@ -0,0 +1,84 @@ +import QtQuick +import Quickshell +pragma Singleton + +Singleton { + id: root + + // Current date + property var date: new Date() + // Returns a Unix Timestamp (in seconds) + readonly property int timestamp: { + return Math.floor(date / 1000); + } + + // Formats a Date object into a YYYYMMDD-HHMMSS string. + function getFormattedTimestamp(date) { + if (!date) + date = new Date(); + + const year = date.getFullYear(); + // getMonth() is zero-based, so we add 1 + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + const seconds = String(date.getSeconds()).padStart(2, '0'); + return `${year}${month}${day}-${hours}${minutes}${seconds}`; + } + + // Format an easy to read approximate duration ex: 4h32m + // Used to display the time remaining on the Battery widget, computer uptime, etc.. + function formatVagueHumanReadableDuration(totalSeconds) { + if (typeof totalSeconds !== 'number' || totalSeconds < 0) + return '0s'; + + // Floor the input to handle decimal seconds + totalSeconds = Math.floor(totalSeconds); + const days = Math.floor(totalSeconds / 86400); + const hours = Math.floor((totalSeconds % 86400) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + const parts = []; + if (days) + parts.push(`${days}d`); + + if (hours) + parts.push(`${hours}h`); + + if (minutes) + parts.push(`${minutes}m`); + + // Only show seconds if no hours and no minutes + if (!hours && !minutes) + parts.push(`${seconds}s`); + + return parts.join(''); + } + + // Format a date into + function formatRelativeTime(date) { + if (!date) + return ""; + + const diff = Date.now() - date.getTime(); + if (diff < 60000) + return "now"; + + if (diff < 3.6e+06) + return `${Math.floor(diff / 60000)}m ago`; + + if (diff < 8.64e+07) + return `${Math.floor(diff / 3600000)}h ago`; + + return `${Math.floor(diff / 86400000)}d ago`; + } + + Timer { + interval: 1000 + repeat: true + running: true + onTriggered: root.date = new Date() + } + +} diff --git a/quickshell/shell.qml b/quickshell/shell.qml index 4e24b51..f32dddf 100644 --- a/quickshell/shell.qml +++ b/quickshell/shell.qml @@ -4,10 +4,16 @@ import Quickshell.Widgets import qs.Constants import qs.Modules.Bar import qs.Modules.Misc +import qs.Modules.Panel +import qs.Services Scope { id: root + IPCService { + id: ipcService + } + Bar { id: bar @@ -20,4 +26,16 @@ Scope { shell: root } + CalendarPanel { + id: calendarPanel + + objectName: "calendarPanel" + } + + ControlCenterPanel { + id: controlCenterPanel + + objectName: "controlCenterPanel" + } + } diff --git a/rofi/config.rasi b/rofi/config.rasi index 555c795..9530c95 100644 --- a/rofi/config.rasi +++ b/rofi/config.rasi @@ -20,7 +20,7 @@ peach: #fab387; search: rgba(49, 50, 68, 0.5); // alpha(@surface, 0.5) - accent: #89b4fa; + primary: #89b4fa; } @@ -53,7 +53,7 @@ window { padding: 0px; border: 2px solid; border-radius: 24px; - border-color: @accent; + border-color: @primary; cursor: "default"; background-color: @base; } @@ -126,7 +126,7 @@ listview { } scrollbar { handle-width: 5px ; - handle-color: @accent; + handle-color: @primary; border-radius: 10px; background-color: @mantle; } @@ -150,7 +150,7 @@ element alternate.normal { element normal.urgent, element alternate.urgent, element selected.active { - background-color: @accent; + background-color: @primary; text-color: @base; border-radius: 10px; } @@ -198,9 +198,9 @@ button { } button selected { background-color: @surface; - text-color: @accent; + text-color: @primary; border-radius: 0 0 14px 14px; - border-color: @accent; + border-color: @primary; } /*****----- Message -----*****/ diff --git a/rofi/config.rasi.template b/rofi/config.rasi.template index fdf8229..60d6ae8 100644 --- a/rofi/config.rasi.template +++ b/rofi/config.rasi.template @@ -20,7 +20,7 @@ peach: #fab387; search: rgba(49, 50, 68, 0.5); // alpha(@surface, 0.5) - accent: #; + primary: #; } @@ -53,7 +53,7 @@ window { padding: 0px; border: 2px solid; border-radius: 24px; - border-color: @accent; + border-color: @primary; cursor: "default"; background-color: @base; } @@ -126,7 +126,7 @@ listview { } scrollbar { handle-width: 5px ; - handle-color: @accent; + handle-color: @primary; border-radius: 10px; background-color: @mantle; } @@ -150,7 +150,7 @@ element alternate.normal { element normal.urgent, element alternate.urgent, element selected.active { - background-color: @accent; + background-color: @primary; text-color: @base; border-radius: 10px; } @@ -198,9 +198,9 @@ button { } button selected { background-color: @surface; - text-color: @accent; + text-color: @primary; border-radius: 0 0 14px 14px; - border-color: @accent; + border-color: @primary; } /*****----- Message -----*****/ diff --git a/waybar-niri/config.jsonc b/waybar-niri/config.jsonc deleted file mode 100644 index 49ac15e..0000000 --- a/waybar-niri/config.jsonc +++ /dev/null @@ -1,232 +0,0 @@ -{ - // ------------------------------------------------------------------------- - // Global configuration - // ------------------------------------------------------------------------- - "layer": "bottom", - "position": "top", - "margin-left": 0, - "margin-bottom": 0, - "margin-right": 0, - "spacing": 2, // Gaps between modules (px) - "modules-left": [ - "custom/rofi", - "custom/separator", - "group/workspaceactions", - "custom/separator", - "niri/window", - "custom/mediaplayer" - ], - "modules-center": ["clock"], - "modules-right": ["group/monitors", "custom/separator", "group/tray-expander", "idle_inhibitor", "custom/power"], - // ------------------------------------------------------------------------- - // Modules - // ------------------------------------------------------------------------- - // Separators - "custom/separator": { - "format": "|" - }, - // Buttons - "custom/power": { - "format": "󰐥", - "tooltip": false, - "on-click": "wlogout", - "min-length": 2, - "max-length": 2 - }, - "custom/rofi": { - "format": "󰣇", - "tooltip": false, - // "on-click-right": "fuzzel -l 0 -p '>> ' | xargs -r sh -c", - // "on-click": "fuzzel", - // "on-click-middle": "pkill -9 fuzzel", - "on-click": "eww open main --toggle", - "on-click-right": "pkill rofi || rofi -show drun", - "min-length": 2, - "max-length": 2 - }, - "idle_inhibitor": { - "format": "{icon}", - "format-icons": { - "activated": "", - "deactivated": "" - }, - "min-length": 2, - "max-length": 2 - }, - "custom/caffeine": { - "format": "{icon}", - "format-icons": { - "active": "", - "inactive": "" - }, - "return-type": "json", - "interval": "once", - "exec": "$HOME/.config/waybar/modules/caffeine.sh", - "on-click": "$HOME/.config/waybar/modules/caffeine.sh toggle && sleep 0.2", - "exec-on-event": true, - "tooltip": false, - "min-length": 2, - "max-length": 2 - }, - // Time and Date - "clock": { - "format": "{:%H:%M | %e %b}", - "tooltip-format": "{:%Y %B}\n{calendar}", - "today-format": "{}", - "on-click": "niri msg action center-column", - "on-scroll-up": "niri msg action set-column-width +10%", - "on-scroll-down": "niri msg action set-column-width -10%", - "on-click-middle": "niri msg action close-window" - }, - - // System monitors - "group/monitors": { - "modules": ["network#speed", "custom/publicip", "temperature", "memory", "cpu", "battery", "backlight", "wireplumber"], - "orientation": "inherit" - }, - "network#speed": { - "interval": 1, - "format": "{ifname}", - "format-wifi": " {bandwidthDownBytes}  {bandwidthUpBytes} ", - "format-ethernet": " {bandwidthDownBytes}  {bandwidthUpBytes} ", - "format-disconnected": "󰌙", - "tooltip-format": "{ipaddr}", - "format-linked": "󰈁 {ifname} (No IP)", - "tooltip-format-wifi": "{essid} {signalStrength}%", - "tooltip-format-ethernet": "{ifname} 󰌘", - "tooltip-format-disconnected": "󰌙 Disconnected", - "min-length": 20 - }, - "custom/publicip": { - "interval": 30, - "return-type": "json", - "format": " {text}", - "tooltip-format": "{alt}", - "max-length": 6, - "min-length": 6, - "exec": "$HOME/.config/waybar/modules/publicip.sh", - "on-click": "rm -f $HOME/.config/waybar/modules/publicip.cache && sleep 0.1" - }, - "temperature": { - "interval": 5, - "thermal-zone": 6, - "hwmon-path": "/sys/class/hwmon/hwmon6/temp1_input", - "critical-threshold": 80, - // "format-critical": " {temperatureC}°C", - "format-critical": " {temperatureC}°C", - "format": "{icon} {temperatureC}°C", - "format-icons": ["", "", ""], - "max-length": 6, - "min-length": 6 - }, - "memory": { - "interval": 11, - // "format": " {used:0.2f} / {total:0.0f} GB", - "format": "󰍛 {percentage}%", - "on-click": "killall btop || ghostty -e btop", - "max-length": 6, - "min-length": 6 - }, - "cpu": { - "interval": 3, - //"format": " {}%", // Icon: microchip - "format": "󰘚 {usage}%", - "max-length": 6, - "min-length": 6, - "on-click": "killall btop || ghostty -e btop" - }, - "battery": { - "interval": 30, - "states": { - "good": 95, - "warning": 30, - "critical": 15 - }, - "format": "{icon} {capacity}%", - "format-charging": " {capacity}%", - "format-plugged": " {capacity}%", - "format-icons": ["", "", "", "", ""], - "max-length": 6, - "min-length": 6 - }, - "backlight": { - "device": "$DISPLAY_DEVICE", - "format": "{icon} {percent}%", - "format-alt": "{percent}% {icon}", - "format-alt-click": "click-right", - //"format-icons": ["", ""], - "format-icons": [""], - "on-scroll-down": "brightnessctl -d $HYPR_DISPLAY_DEVICE set 5%-", - "on-scroll-up": "brightnessctl -d $HYPR_DISPLAY_DEVICE set +5%", - "max-length": 6, - "min-length": 6 - }, - "wireplumber": { - "on-click": "pavucontrol", - //on-click: "${wpctl} set-mute @DEFAULT_AUDIO_SINK@ toggle"; - "on-scroll-down": "wpctl set-volume -l 1.0 @DEFAULT_AUDIO_SINK@ 0.04-", - "on-scroll-up": "wpctl set-volume -l 1.0 @DEFAULT_AUDIO_SINK@ 0.04+", - "format": "{icon} {volume}%", - "format-muted": "", - "format-source": "", - "format-source-muted": "", - //"format-muted": "", - //"format-icons": [ "" ] - "format-icons": { - "headphone": "", - "phone": "", - "portable": "", - "car": "", - "default": ["", "", "", "", "", ""] - }, - "max-length": 6, - "min-length": 6 - }, - // Niri - "group/workspaceactions": { - "modules": ["niri/workspaces", "custom/workspacenew"], - "orientation": "inherit" - }, - "niri/workspaces": { - "all-outputs": true, - "format": "{index}", - "on-scroll-up": "niri msg action focus-workspace-up", - "on-scroll-down": "niri msg action focus-workspace-down", - "sort-by-number": true - }, - "niri/window": { - "format": "", - "separate-outputs": true, - "icon": true, - "icon-size": 14 - }, - "custom/mediaplayer": { - "format": "{text}", - "return-type": "json", - "max-length": 100, - "escape": true, - "exec": "$HOME/.config/waybar/modules/mediaplayer.py 2> /dev/null", - "on-click": "playerctl play-pause", - "on-click-right": "lyrics-widgets", - "on-scroll-up": "playerctl next", - "on-scroll-down": "playerctl previous" - }, - "group/tray-expander": { - "orientation": "inherit", - "drawer": { - "transition-duration": 600, - "children-class": "tray-group-item" - }, - "modules": ["custom/expand-icon", "tray", "custom/separator"] - }, - "custom/expand-icon": { - "format": "", - "tooltip": false, - "min-length": 2, - "max-length": 2 - }, - "tray": { - "icon-size": 15, - "spacing": 5 - } -} diff --git a/waybar-niri/mocha.css b/waybar-niri/mocha.css deleted file mode 100644 index 0eb6a82..0000000 --- a/waybar-niri/mocha.css +++ /dev/null @@ -1,26 +0,0 @@ -@define-color rosewater #f5e0dc; -@define-color flamingo #f2cdcd; -@define-color pink #f5c2e7; -@define-color mauve #cba6f7; -@define-color red #f38ba8; -@define-color maroon #eba0ac; -@define-color peach #fab387; -@define-color yellow #f9e2af; -@define-color green #a6e3a1; -@define-color teal #94e2d5; -@define-color sky #89dceb; -@define-color sapphire #74c7ec; -@define-color blue #89b4fa; -@define-color lavender #b4befe; -@define-color text #cdd6f4; -@define-color subtext1 #bac2de; -@define-color subtext0 #a6adc8; -@define-color overlay2 #9399b2; -@define-color overlay1 #7f849c; -@define-color overlay0 #6c7086; -@define-color surface2 #585b70; -@define-color surface1 #45475a; -@define-color surface0 #313244; -@define-color base #1e1e2e; -@define-color mantle #181825; -@define-color crust #11111b; diff --git a/waybar-niri/modules/.gitignore b/waybar-niri/modules/.gitignore deleted file mode 100644 index 3a64743..0000000 --- a/waybar-niri/modules/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -publicip.conf -publicip.cache -publicip.log \ No newline at end of file diff --git a/waybar-niri/modules/caffeine.sh b/waybar-niri/modules/caffeine.sh deleted file mode 100755 index f36e09b..0000000 --- a/waybar-niri/modules/caffeine.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/env bash - -function output() { - jq -n --unbuffered --compact-output \ - --arg alt "$1" \ - --arg class "$2" \ - '{alt: $alt, class: [$class]}' -} - -if [ "$1" = "toggle" ]; then - pid=$(pgrep -x "swayidle") - - if [ -n "$pid" ]; then - killall swayidle > /dev/null 2>&1 - notify-send "Caffeine enabled" "POWERRR!!!" - else - nohup niri-swayidle > /dev/null 2>&1 & - notify-send "Caffeine disabled" "zzz..." - fi - - exit 0 -fi - -# sleep 0.2 # Allow hypridle to start - -pid=$(pgrep -x "swayidle") -if [ -n "$pid" ]; then - output "inactive" "inactive" -else - output "active" "active" -fi \ No newline at end of file diff --git a/waybar-niri/modules/mediaplayer.py b/waybar-niri/modules/mediaplayer.py deleted file mode 100755 index 8edbe7b..0000000 --- a/waybar-niri/modules/mediaplayer.py +++ /dev/null @@ -1,221 +0,0 @@ -#!/usr/bin/env python3 -from gi.repository.Playerctl import Player -from gi.repository import Playerctl, GLib -from typing import List -import os -import json -import signal -import sys -import logging -import argparse - -import gi - -gi.require_version("Playerctl", "2.0") - - -logger = logging.getLogger(__name__) - - -def signal_handler(sig, frame): - logger.info("Received signal to stop, exiting") - sys.stdout.write("\n") - sys.stdout.flush() - # loop.quit() - sys.exit(0) - - -class PlayerManager: - def __init__(self, selected_player=None, excluded_player=[]): - self.manager = Playerctl.PlayerManager() - self.loop = GLib.MainLoop() - self.manager.connect( - "name-appeared", lambda *args: self.on_player_appeared(*args) - ) - self.manager.connect( - "player-vanished", lambda *args: self.on_player_vanished(*args) - ) - - signal.signal(signal.SIGINT, signal_handler) - signal.signal(signal.SIGTERM, signal_handler) - signal.signal(signal.SIGPIPE, signal.SIG_DFL) - self.selected_player = selected_player - self.excluded_player = excluded_player.split(",") if excluded_player else [] - - self.init_players() - - def init_players(self): - for player in self.manager.props.player_names: - if player.name in self.excluded_player: - continue - if self.selected_player is not None and self.selected_player != player.name: - logger.debug(f"{player.name} is not the filtered player, skipping it") - continue - self.init_player(player) - - def run(self): - logger.info("Starting main loop") - self.loop.run() - - def init_player(self, player): - logger.info(f"Initialize new player: {player.name}") - player = Playerctl.Player.new_from_name(player) - player.connect("playback-status", self.on_playback_status_changed, None) - player.connect("metadata", self.on_metadata_changed, None) - self.manager.manage_player(player) - self.on_metadata_changed(player, player.props.metadata) - - def get_players(self) -> List[Player]: - return self.manager.props.players - - def write_output(self, text, player): - logger.debug(f"Writing output: {text}") - - output = { - "text": text, - "class": "custom-" + player.props.player_name, - "alt": player.props.player_name, - } - - sys.stdout.write(json.dumps(output) + "\n") - sys.stdout.flush() - - def clear_output(self): - sys.stdout.write("\n") - sys.stdout.flush() - - def on_playback_status_changed(self, player, status, _=None): - logger.debug( - f"Playback status changed for player {player.props.player_name}: {status}" - ) - self.on_metadata_changed(player, player.props.metadata) - - def get_first_playing_player(self): - players = self.get_players() - logger.debug(f"Getting first playing player from {len(players)} players") - if len(players) > 0: - # if any are playing, show the first one that is playing - # reverse order, so that the most recently added ones are preferred - for player in players[::-1]: - if player.props.status == "Playing": - return player - # if none are playing, show the first one - return players[0] - else: - logger.debug("No players found") - return None - - def show_most_important_player(self): - logger.debug("Showing most important player") - # show the currently playing player - # or else show the first paused player - # or else show nothing - current_player = self.get_first_playing_player() - if current_player is not None: - self.on_metadata_changed(current_player, current_player.props.metadata) - else: - self.clear_output() - - def on_metadata_changed(self, player, metadata, _=None): - logger.debug(f"Metadata changed for player {player.props.player_name}") - player_name = player.props.player_name - artist = player.get_artist() - title = player.get_title() - title = title.replace("&", "&") - - track_info = "" - if ( - player_name == "spotify" - and "mpris:trackid" in metadata.keys() - and ":ad:" in player.props.metadata["mpris:trackid"] - ): - track_info = "Advertisement" - elif artist is not None and title is not None: - track_info = f"{artist} - {title}" - else: - track_info = title - - if track_info: - if player.props.status == "Playing": - track_info = " " + track_info - else: - track_info = " " + track_info - # only print output if no other player is playing - current_playing = self.get_first_playing_player() - if ( - current_playing is None - or current_playing.props.player_name == player.props.player_name - ): - self.write_output(track_info, player) - else: - logger.debug( - f"Other player {current_playing.props.player_name} is playing, skipping" - ) - - def on_player_appeared(self, _, player): - logger.info(f"Player has appeared: {player.name}") - if player.name in self.excluded_player: - logger.debug( - "New player appeared, but it's in exclude player list, skipping" - ) - return - if player is not None and ( - self.selected_player is None or player.name == self.selected_player - ): - self.init_player(player) - else: - logger.debug( - "New player appeared, but it's not the selected player, skipping" - ) - - def on_player_vanished(self, _, player): - logger.info(f"Player {player.props.player_name} has vanished") - self.show_most_important_player() - - -def parse_arguments(): - parser = argparse.ArgumentParser() - - # Increase verbosity with every occurrence of -v - parser.add_argument("-v", "--verbose", action="count", default=0) - - parser.add_argument("-x", "--exclude", "- Comma-separated list of excluded player") - - # Define for which player we"re listening - parser.add_argument("--player") - - parser.add_argument("--enable-logging", action="store_true") - - return parser.parse_args() - - -def main(): - arguments = parse_arguments() - - # Initialize logging - if arguments.enable_logging: - logfile = os.path.join( - os.path.dirname(os.path.realpath(__file__)), "media-player.log" - ) - logging.basicConfig( - filename=logfile, - level=logging.DEBUG, - format="%(asctime)s %(name)s %(levelname)s:%(lineno)d %(message)s", - ) - - # Logging is set by default to WARN and higher. - # With every occurrence of -v it's lowered by one - logger.setLevel(max((3 - arguments.verbose) * 10, 0)) - - logger.info("Creating player manager") - if arguments.player: - logger.info(f"Filtering for player: {arguments.player}") - if arguments.exclude: - logger.info(f"Exclude player {arguments.exclude}") - - player = PlayerManager(arguments.player, arguments.exclude) - player.run() - - -if __name__ == "__main__": - main() diff --git a/waybar-niri/modules/publicip.sh b/waybar-niri/modules/publicip.sh deleted file mode 100755 index 57f5485..0000000 --- a/waybar-niri/modules/publicip.sh +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env bash -# shellcheck disable=SC1091,SC1090 - -# Entries in publicip.conf: -# IP_QUERY_URL: URL to query public IP of the system. -# return: JSON object with "ip" field. -# note: This URL will be queried with short intervals (60s for example), -# therefore it may not be a good idea to use a public API with -# a limited number of calls. -# IP_INFO_URL: URL to query IP location. -# placeholder: -# return: JSON object with "country_code" field. -# note: This URL will only be quetried when public IP changes or when "force" is given as parameter. - -path="$(dirname "$(readlink -f "$0")")" -cache_file="$path/publicip.cache" -config_file="$path/publicip.conf" -time_log="$path/publicip.log" - -[ -f "$config_file" ] || exit 1 -. "$config_file" -[ -z "$IP_QUERY_URL" ] && exit 1 -[ -z "$IP_INFO_URL" ] && exit 1 -[ "$1" == "force" ] && rm -f "$cache_file" -[ -f "$cache_file" ] && . "$cache_file" - -# Try to check network connectivity before querying -if ! ping -c 1 -W 2 1.1.1.1 >/dev/null 2>&1; then - # No network, return cached values if available - [ -z "$CACHED_IP" ] && CACHED_IP="N/A" - [ -z "$CACHED_CODE" ] && CACHED_CODE="N/A" - - echo "$(date +%Y-%m-%dT%H:%M:%S) - Waiting for network" >>"$time_log" - - jq -n --unbuffered --compact-output \ - --arg ip "$CACHED_IP" \ - --arg country "$CACHED_CODE" \ - '{alt: $ip, text: $country}' - exit 0 -fi - -ip_current=$(curl -s -L -4 "$IP_QUERY_URL" | jq -r '.ip') -[ -z "$ip_current" ] && exit 1 - -if [ "$ip_current" != "$CACHED_IP" ]; then - echo "$(date +%Y-%m-%dT%H:%M:%S) - IP changed: $CACHED_IP -> $ip_current" >>"$time_log" - CACHED_IP="$ip_current" - - ip_info_url=${IP_INFO_URL///$ip_current} - CACHED_CODE=$(curl -s -L "$ip_info_url" | jq -r '.country_code') - [ -z "$CACHED_CODE" ] && CACHED_CODE="N/A" - - echo "CACHED_IP=$CACHED_IP" >"$cache_file" - echo "CACHED_CODE=$CACHED_CODE" >>"$cache_file" - notify-send "New Public IP detected" "New IP: $ip_current\nCountry: $CACHED_CODE" -fi - -jq -n --unbuffered --compact-output \ - --arg ip "$CACHED_IP" \ - --arg country "$CACHED_CODE" \ - '{alt: $ip, text: $country}' - diff --git a/waybar-niri/style.css b/waybar-niri/style.css deleted file mode 100644 index c849ae8..0000000 --- a/waybar-niri/style.css +++ /dev/null @@ -1,222 +0,0 @@ -@import 'mocha.css'; - -@define-color flavor #89b4fa; -/* @define-color archlinux #1793d1; */ -@define-color archlinux @sapphire; - -/* Font(s) */ -* { - /* main font icons CJK fallback */ - font-family: 'Sour Gummy Light', 'Meslo LGM Nerd Font Mono', 'WenQuanYi Micro Hei', 'Noto Sans', sans-serif; - font-size: 16px; -} - -/* Reset all styles */ -* { - border: none; - border-radius: 14px; - min-height: 0; - margin: 2px 1px 2px 1px; - padding: 0; - transition-property: background-color; - transition-duration: 0.5s; -} - -/* The whole bar */ -#waybar { - background: linear-gradient(to bottom, alpha(@base, 0.8), alpha(@base, 0)); - /* background: transparent; */ - color: @text; - border-radius: 0px; - margin: 0px; -} - -tooltip { - background: @base; - border: 2px solid @overlay0; -} - -#workspaceactions, -#window, -#clock, -#monitors, -#custom-mediaplayer, -#custom-power-menu, -#tray, -#custom-rofi, -#idle_inhibitor, -#custom-caffeine, -#custom-power { - padding: 0px 10px; -} - -#custom-separator { - padding: 0px 5px; - font-size: 20px; -} - -#custom-rofi, -#custom-caffeine -#idle_inhibitor, -#custom-power, -#custom-expand-icon { - padding: 0px 6px; - font-size: 18px; -} - -#custom-workspacenew, -#workspaces button { - padding: 0px; - margin: 3px 3px; - border-radius: 8px; - color: @flavor; - background-color: transparent; - transition: all 0.3s ease-in-out; -} - -#workspaces button:hover { - background-color: alpha(@flavor, 0.3); -} - -#workspaces button.active { - color: @base; - background: @flavor; -} - -#workspaces button.urgent { - color: @base; - background-color: @red; -} - -#workspaceactions { - padding-left: 1px; - padding-right: 1px; -} - -#workspaces { - padding: 0px; - margin: 0px; -} - -#window { - transition: none; /* Disable background transition */ -} - -window#waybar.empty #window { - background-color: transparent; - padding: 0; - margin: 0; -} - -#custom-mediaplayer { - color: @flavor; -} - -#network.speed { - color: @flavor; -} - -#custom-publicip { - color: @peach; -} - -#temperature { - color: @yellow; -} - -#memory { - color: @green; -} - -#cpu { - color: @teal; -} - -#battery { - color: @sapphire; -} - -#battery.charging, -#battery.full, -#battery.plugged { - color: @green; -} - -#backlight { - color: @blue; -} - -#wireplumber { - color: @lavender; -} - -#custom-power { - color: @maroon; -} - -#custom-power:hover { - background-color: alpha(@maroon, 0.3); -} - -#custom-rofi { - color: @archlinux; -} - -#custom-rofi:hover { - background-color: alpha(@archlinux, 0.3); -} - -#custom-expand-icon { - color: @green; -} - -#custom-caffeine, -#idle_inhibitor { - color: @yellow; -} - -#custom-caffeine:hover, -#idle_inhibitor:hover { - background-color: alpha(@yellow, 0.3); -} - -#custom-caffeine.active, -#idle_inhibitor.activated { - color: @peach; -} - -#clock { - color: @flavor; -} - -@keyframes blink { - to { - background-color: alpha(@red, 0.5); - } -} - -#battery.critical:not(.charging) { - color: @red; - animation-name: blink; - animation-duration: 1s; - animation-timing-function: linear; - animation-iteration-count: infinite; - animation-direction: alternate; -} - -#network.disconnected { - color: @red; -} - -#temperature.critical { - background-color: #eb4d4b; -} - -#tray > .passive { - -gtk-icon-effect: dim; -} - -#tray > .needs-attention { - -gtk-icon-effect: highlight; - background-color: #eb4d4b; -} diff --git a/waybar-niri/style.css.template b/waybar-niri/style.css.template deleted file mode 100644 index ea0218c..0000000 --- a/waybar-niri/style.css.template +++ /dev/null @@ -1,222 +0,0 @@ -@import 'mocha.css'; - -@define-color flavor #; -/* @define-color archlinux #1793d1; */ -@define-color archlinux @sapphire; - -/* Font(s) */ -* { - /* main font icons CJK fallback */ - font-family: 'Sour Gummy Light', 'Meslo LGM Nerd Font Mono', 'WenQuanYi Micro Hei', 'Noto Sans', sans-serif; - font-size: 16px; -} - -/* Reset all styles */ -* { - border: none; - border-radius: 14px; - min-height: 0; - margin: 2px 1px 2px 1px; - padding: 0; - transition-property: background-color; - transition-duration: 0.5s; -} - -/* The whole bar */ -#waybar { - background: linear-gradient(to bottom, alpha(@base, 0.8), alpha(@base, 0)); - /* background: transparent; */ - color: @text; - border-radius: 0px; - margin: 0px; -} - -tooltip { - background: @base; - border: 2px solid @overlay0; -} - -#workspaceactions, -#window, -#clock, -#monitors, -#custom-mediaplayer, -#custom-power-menu, -#tray, -#custom-rofi, -#idle_inhibitor, -#custom-caffeine, -#custom-power { - padding: 0px 10px; -} - -#custom-separator { - padding: 0px 5px; - font-size: 20px; -} - -#custom-rofi, -#custom-caffeine -#idle_inhibitor, -#custom-power, -#custom-expand-icon { - padding: 0px 6px; - font-size: 18px; -} - -#custom-workspacenew, -#workspaces button { - padding: 0px; - margin: 3px 3px; - border-radius: 8px; - color: @flavor; - background-color: transparent; - transition: all 0.3s ease-in-out; -} - -#workspaces button:hover { - background-color: alpha(@flavor, 0.3); -} - -#workspaces button.active { - color: @base; - background: @flavor; -} - -#workspaces button.urgent { - color: @base; - background-color: @red; -} - -#workspaceactions { - padding-left: 1px; - padding-right: 1px; -} - -#workspaces { - padding: 0px; - margin: 0px; -} - -#window { - transition: none; /* Disable background transition */ -} - -window#waybar.empty #window { - background-color: transparent; - padding: 0; - margin: 0; -} - -#custom-mediaplayer { - color: @flavor; -} - -#network.speed { - color: @flavor; -} - -#custom-publicip { - color: @peach; -} - -#temperature { - color: @yellow; -} - -#memory { - color: @green; -} - -#cpu { - color: @teal; -} - -#battery { - color: @sapphire; -} - -#battery.charging, -#battery.full, -#battery.plugged { - color: @green; -} - -#backlight { - color: @blue; -} - -#wireplumber { - color: @lavender; -} - -#custom-power { - color: @maroon; -} - -#custom-power:hover { - background-color: alpha(@maroon, 0.3); -} - -#custom-rofi { - color: @archlinux; -} - -#custom-rofi:hover { - background-color: alpha(@archlinux, 0.3); -} - -#custom-expand-icon { - color: @green; -} - -#custom-caffeine, -#idle_inhibitor { - color: @yellow; -} - -#custom-caffeine:hover, -#idle_inhibitor:hover { - background-color: alpha(@yellow, 0.3); -} - -#custom-caffeine.active, -#idle_inhibitor.activated { - color: @peach; -} - -#clock { - color: @flavor; -} - -@keyframes blink { - to { - background-color: alpha(@red, 0.5); - } -} - -#battery.critical:not(.charging) { - color: @red; - animation-name: blink; - animation-duration: 1s; - animation-timing-function: linear; - animation-iteration-count: infinite; - animation-direction: alternate; -} - -#network.disconnected { - color: @red; -} - -#temperature.critical { - background-color: #eb4d4b; -} - -#tray > .passive { - -gtk-icon-effect: dim; -} - -#tray > .needs-attention { - -gtk-icon-effect: highlight; - background-color: #eb4d4b; -} diff --git a/waybar-niri/waybar.sh b/waybar-niri/waybar.sh deleted file mode 100755 index d03b9eb..0000000 --- a/waybar-niri/waybar.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env sh - -# Terminate already running bar instances -killall -q waybar - -# Wait until the processes have been shut down -while pgrep -x waybar >/dev/null; do sleep 1; done - -# Launch main -waybar &