quickshell: should be everything I want now

This commit is contained in:
2025-10-12 23:23:36 +02:00
parent abadf04aa2
commit 22105c20d4
84 changed files with 4375 additions and 1312 deletions

View File

@@ -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.

View File

@@ -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]

View File

@@ -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

View File

@@ -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"

4
.scripts/sl-wrap Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/sh
pgrep -f spotify-lyrics && (killall spotify-lyrics || exit 1)
spotify-lyrics "$@"

View File

@@ -1,35 +1,43 @@
## Screenshots
- desktop with a few widgets:
<img src="https://github.com/Uyanide/backgrounds/blob/master/screenshots/desktop.jpg?raw=true"/>
- Hyprland & Waybar & Eww:
- dynamic colortheme based on Catppuccin Mocha:
<img src="https://github.com/Uyanide/backgrounds/blob/master/screenshots/desktop-alt.jpg?raw=true"/>
<img src="https://github.com/Uyanide/backgrounds/blob/master/screenshots/desktop.jpg?raw=true"/>
- the grub menu looks like:
- Niri & Quickshell
<img src="https://github.com/Uyanide/backgrounds/blob/master/screenshots/desktop-alt.jpg?raw=true"/>
<img src="https://github.com/Uyanide/backgrounds/blob/master/screenshots/backdrop.jpg?raw=true"/>
- Grub menu:
<img src="https://github.com/Uyanide/backgrounds/blob/master/screenshots/grub.jpg?raw=true"/>
## 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 <small>(which also happens to be my frist Golang program :D)</small>.
- `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

View File

@@ -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"; }

View File

@@ -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"; }

4
quickshell/Assets/Config/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
Location.json
GeoInfoToken.txt

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1,4 @@
{
"primaryColor": "#89b4fa",
"showLyricsBar": true
}

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -1,2 +0,0 @@
token.txt
# cache.json

View File

@@ -0,0 +1,25 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Constants
pragma Singleton
Singleton {
id: root
property color mPrimary: Colors.primary
property color mOnPrimary: Colors.base
property color mSecondary: Colors.primary
property color mOnSecondary: Colors.base
property color mTertiary: Colors.primary
property color mOnTertiary: Colors.base
property color mError: Colors.red
property color mOnError: Colors.base
property color mSurface: Colors.base
property color mOnSurface: Colors.text
property color mSurfaceVariant: Colors.surface
property color mOnSurfaceVariant: Colors.text
property color mOutline: Colors.primary
property color mShadow: Colors.crust
property color transparent: "transparent"
}

View File

@@ -1,5 +1,6 @@
import QtQuick
import Quickshell
import qs.Services
pragma Singleton
Singleton {
@@ -29,10 +30,11 @@ Singleton {
readonly property color surface2: "#585b70"
readonly property color surface1: "#45475a"
readonly property color surface0: "#313244"
readonly property color surface: "#292a3c"
readonly property color base: "#1e1e2e"
readonly property color mantle: "#181825"
readonly property color crust: "#11111b"
readonly property color accent: "#89b4fa"
property color primary: SettingsService.primaryColor
readonly property color distroColor: "#74c7ec"
readonly property var cavaList: ["#b4befe", "#89b4fa", "#74c7ec", "#89dceb", "#94e2d5", "#a6e3a1", "#f9e2af", "#fab387"]
}

View File

@@ -1,5 +1,6 @@
import QtQuick
import Quickshell
import qs.Constants
pragma Singleton
Singleton {
@@ -8,8 +9,8 @@ Singleton {
readonly property string primary: "Sour Gummy Light"
readonly property string nerd: "Meslo LGM Nerd Font Mono"
readonly property string sans: "Noto Sans"
readonly property int small: 10
readonly property int medium: 12
readonly property int large: 14
readonly property int icon: 14
readonly property int small: Style.fontSizeS
readonly property int medium: Style.fontSizeM
readonly property int large: Style.fontSizeL
readonly property int icon: 14 // for nerd font
}

View File

@@ -1,5 +1,6 @@
import QtQuick
import Quickshell
import qs.Utils
pragma Singleton
Singleton {
@@ -30,6 +31,11 @@ Singleton {
readonly property string global: ""
readonly property string upload: ""
readonly property string download: ""
readonly property string speedSlower: "󰾆"
readonly property string speedFaster: "󰓅"
readonly property string speedReset: "󰾅"
readonly property string reset: "󰑙"
readonly property string lines: ""
// Expose the font family name for easy access
readonly property string fontFamily: currentFontLoader ? currentFontLoader.name : ""
readonly property string defaultIcon: TablerIcons.defaultIcon
@@ -73,7 +79,7 @@ Singleton {
if (currentFontLoader.status === FontLoader.Ready)
fontReloaded();
else if (currentFontLoader.status === FontLoader.Error)
Logger.error("Font failed to load (version " + fontVersion + ")");
Logger.error("Icons", "Font failed to load (version " + fontVersion + ")");
});
}

View File

@@ -0,0 +1,68 @@
import QtQuick
import Quickshell
import Quickshell.Io
pragma Singleton
Singleton {
/*
Preset sizes for font, radii, ?
*/
id: root
// Font size
property real fontSizeXXS: 8
property real fontSizeXS: 9
property real fontSizeS: 10
property real fontSizeM: 11
property real fontSizeL: 13
property real fontSizeXL: 16
property real fontSizeXXL: 18
property real fontSizeXXXL: 24
// Font weight
property int fontWeightRegular: 400
property int fontWeightMedium: 500
property int fontWeightSemiBold: 600
property int fontWeightBold: 700
// Radii
property int radiusXXS: 4
property int radiusXS: 8
property int radiusS: 12
property int radiusM: 16
property int radiusL: 20
//screen Radii
property int screenRadius: 20
// Border
property int borderS: 2
property int borderM: 3
property int borderL: 4
// Margins (for margins and spacing)
property int marginXXS: 2
property int marginXS: 4
property int marginS: 8
property int marginM: 12
property int marginL: 16
property int marginXL: 24
// Opacity
property real opacityNone: 0
property real opacityLight: 0.25
property real opacityMedium: 0.5
property real opacityHeavy: 0.75
property real opacityAlmost: 0.95
property real opacityFull: 1
// Animation duration (ms)
property int animationFast: 150
property int animationNormal: 300
property int animationSlow: 450
property int animationSlowest: 750
// Delays
property int tooltipDelay: 300
property int tooltipDelayLong: 1200
property int pillDelay: 500
// Settings widgets base size
property real baseWidgetSize: 33
property real sliderWidth: 200
// Bar Dimensions
property real barHeight: 45
property real capsuleHeight: 35
}

View File

@@ -33,7 +33,7 @@ Scope {
screen: modelData
WlrLayershell.namespace: "quickshell-bar"
color: Colors.transparent
implicitHeight: 45
implicitHeight: Style.barHeight
anchors {
left: true
@@ -94,6 +94,9 @@ Scope {
SymbolButton {
symbol: Icons.distro
buttonColor: Colors.distroColor
onClicked: {
PanelService.getPanel("controlCenterPanel")?.toggle(this)
}
onRightClicked: {
if (action.running) {
action.signal(15);
@@ -164,37 +167,49 @@ Scope {
rightMargin: 5
}
NetworkSpeed {
RowLayout {
id: monitorsLayout
visible: !SettingsService.showLyricsBar
height: parent.height
NetworkSpeed {
}
Separator {
}
Item {
width: 10
}
Ip {
showCountryCode: true
}
CpuTemp {
}
MemUsage {
}
CpuUsage {
}
Battery {
}
Brightness {
screen: modelData
}
Volume {
}
}
Separator {
}
Item {
width: 10
}
Ip {
showCountryCode: true
}
CpuTemp {
}
MemUsage {
}
CpuUsage {
}
Battery {
}
Brightness {
screen: modelData
}
Volume {
LyricsBar {
id: lyricsBar
visible: SettingsService.showLyricsBar
width: 600
}
Item {

View File

@@ -2,8 +2,8 @@ import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Constants
import qs.Modules.Misc
import qs.Services
import qs.Utils
Item {
id: root

View File

@@ -78,7 +78,7 @@ Item {
anchors.verticalCenter: parent.verticalCenter
font.pointSize: Fonts.medium
font.family: Fonts.primary
color: Colors.accent
color: Colors.primary
Process {
id: action

View File

@@ -29,7 +29,7 @@ Item {
Text {
text: Icons.global
font.pointSize: Fonts.icon + 5
font.pointSize: Fonts.icon + 6
color: Colors.peach
}
@@ -54,7 +54,7 @@ Item {
Behavior on implicitWidth {
NumberAnimation {
duration: 200
duration: Style.animationFast
easing.type: Easing.InOutCubic
}

View File

@@ -0,0 +1,88 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import qs.Constants
import qs.Noctalia
import qs.Services
Rectangle {
implicitHeight: parent.height
radius: Style.radiusS
color: Colors.base
border.color: Colors.primary
border.width: Style.borderS
Connections {
target: SettingsService
onShowLyricsBarChanged: {
visible = SettingsService.showLyricsBar;
if (visible)
LyricsService.startSyncing();
else
LyricsService.stopSyncing();
}
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: Style.marginM
anchors.rightMargin: Style.marginM
spacing: Style.marginS
Item {
implicitWidth: parent.width - slowerButton.implicitWidth * 3 - parent.spacing * 3 - parent.anchors.leftMargin - parent.anchors.rightMargin
Layout.fillHeight: true
clip: true
NText {
text: LyricsService.lyrics[LyricsService.currentIndex] || ""
family: Fonts.sans
pointSize: Style.fontSizeS
maximumLineCount: 1
anchors.verticalCenter: parent.verticalCenter
}
}
NIconButton {
id: slowerButton
baseSize: 24
colorBg: Color.transparent
colorBgHover: Colors.blue
colorFg: Colors.blue
icon: "rotate-2"
onClicked: {
LyricsService.increaseOffset();
}
}
NIconButton {
id: playPauseButton
baseSize: 24
colorBg: Color.transparent
colorBgHover: Colors.yellow
colorFg: Colors.yellow
icon: "rotate-clockwise-2"
onClicked: {
LyricsService.decreaseOffset();
}
}
NIconButton {
id: nextButton
baseSize: 24
colorBg: Color.transparent
colorBgHover: Colors.green
colorFg: Colors.green
icon: "rotate-clockwise"
onClicked: {
LyricsService.resetOffset();
}
}
}
}

View File

@@ -19,15 +19,15 @@ Item {
Text {
text: Icons.download
font.pointSize: Fonts.icon - 3
color: Colors.accent
color: Colors.primary
Layout.leftMargin: 10
}
Text {
text: SystemStatService.formatSpeed(SystemStatService.txSpeed)
text: SystemStatService.formatSpeed(SystemStatService.rxSpeed)
font.pointSize: Fonts.medium
font.family: Fonts.primary
color: Colors.accent
color: Colors.primary
}
Item {
@@ -37,14 +37,14 @@ Item {
Text {
text: Icons.upload
font.pointSize: Fonts.icon - 3
color: Colors.accent
color: Colors.primary
}
Text {
text: SystemStatService.formatSpeed(SystemStatService.rxSpeed)
text: SystemStatService.formatSpeed(SystemStatService.txSpeed)
font.pointSize: Fonts.medium
font.family: Fonts.primary
color: Colors.accent
color: Colors.primary
}
}

View File

@@ -6,5 +6,13 @@ Text {
text: TimeService.time + " | " + TimeService.dateString
font.pointSize: Fonts.medium
font.family: Fonts.primary
color: Colors.accent
color: Colors.primary
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
PanelService.getPanel("calendarPanel")?.toggle(this)
}
}
}

View File

@@ -16,15 +16,15 @@ Item {
property ListModel localWorkspaces
property real masterProgress: 0
property bool effectsActive: false
property color effectColor: Colors.accent
property color effectColor: Colors.primary
property int horizontalPadding: 16
property int spacingBetweenPills: 8
property bool isDestroying: false
signal workspaceChanged(int workspaceId, color accentColor)
signal workspaceChanged(int workspaceId, color primaryColor)
function triggerUnifiedWave() {
effectColor = Colors.accent;
effectColor = Colors.primary;
masterAnimation.restart();
}
@@ -33,7 +33,7 @@ Item {
const ws = localWorkspaces.get(i);
if (ws.isFocused === true) {
root.triggerUnifiedWave();
root.workspaceChanged(ws.id, Colors.accent);
root.workspaceChanged(ws.id, Colors.primary);
break;
}
}
@@ -180,10 +180,10 @@ Item {
}
color: {
if (model.isFocused)
return Colors.accent;
return Colors.primary;
if (model.isActive)
return Colors.accent.lighter(130);
return Colors.primary.lighter(130);
if (model.isUrgent)
return Theme.error;

View File

@@ -11,7 +11,7 @@ Item {
property real maxValue: 100
property real value: 100
property string textValue: "" // override value in textDisplay if set
property color fillColor: Colors.accent
property color fillColor: Colors.primary
property string textSuffix: ""
property bool pointerCursor: true
property alias hovered: mouseArea.containsMouse
@@ -127,7 +127,7 @@ Item {
Behavior on implicitWidth {
NumberAnimation {
duration: 200
duration: Style.animationNormal
easing.type: Easing.InOutCubic
}

View File

@@ -9,6 +9,8 @@ Item {
required property string symbol
property color buttonColor: Colors.distroColor
readonly property alias hovered: mouseArea.containsMouse
property real iconSize: Fonts.icon
property real radius: Style.radiusS
signal clicked()
signal rightClicked()
@@ -35,7 +37,7 @@ Item {
anchors.fill: parent
text: symbol
font.family: Fonts.nerd
font.pointSize: Fonts.icon
font.pointSize: iconSize
font.bold: false
color: buttonColor
horizontalAlignment: Text.AlignHCenter
@@ -46,11 +48,12 @@ Item {
anchors.fill: parent
color: parent.hovered ? buttonColor : Colors.transparent
opacity: 0.3
radius: 14
radius: root.radius
Behavior on color {
ColorAnimation {
duration: 120
duration: Style.animationNormal
easing.type: Easing.InOutCubic
}
}

View File

@@ -8,6 +8,7 @@ import Quickshell.Widgets
import qs.Modules.Bar.Misc
import qs.Constants
import qs.Services
import qs.Utils
Rectangle {
id: root
@@ -107,8 +108,7 @@ Rectangle {
trayMenu.item.menu = modelData.menu
trayMenu.item.showAt(parent, menuX, menuY)
} else {
// Logger.log("Tray", "No menu available for", modelData.id, "or trayMenu not set")
console.log("No menu available for", modelData.id, "or trayMenu not set")
Logger.log("Tray", "No menu available for", modelData.id, "or trayMenu not set")
}
}
}

View File

@@ -3,6 +3,8 @@ import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import qs.Constants
import qs.Utils
import qs.Noctalia
PopupWindow {
id: root
@@ -19,7 +21,7 @@ PopupWindow {
implicitWidth: menuWidth
// Use the content height of the Flickable for implicit height
implicitHeight: Math.min(screen ? screen.height * 0.9 : Screen.height * 0.9, flickable.contentHeight + 20)
implicitHeight: Math.min(screen ? screen.height * 0.9 : Screen.height * 0.9, flickable.contentHeight + (Style.marginS * 2))
visible: false
color: Colors.transparent
anchor.item: anchorItem
@@ -28,7 +30,7 @@ PopupWindow {
function showAt(item, x, y) {
if (!item) {
console.warn("AnchorItem is undefined, won't show menu.")
Logger.warn("TrayMenu", "AnchorItem is undefined, won't show menu.");
return
}
@@ -84,15 +86,15 @@ PopupWindow {
Rectangle {
anchors.fill: parent
color: Colors.base
border.color: Colors.accent
border.color: Colors.primary
border.width: 2
radius: 14
radius: Style.radiusM
}
Flickable {
id: flickable
anchors.fill: parent
anchors.margins: 10
anchors.margins: Style.marginS
contentHeight: columnLayout.implicitHeight
interactive: true
@@ -115,88 +117,56 @@ PopupWindow {
return 8
} else {
// Calculate based on text content
const textHeight = text.contentHeight || (Fonts.small)
return textHeight + 16
const textHeight = text.contentHeight || (Style.fontSizeS * 1.2)
return Math.max(28, textHeight + (Style.marginS * 2))
}
}
color: Colors.transparent
property var subMenu: null
Rectangle {
width: parent.width - 16
height: 1
NDivider {
anchors.centerIn: parent
width: parent.width - (Style.marginM * 2)
visible: modelData?.isSeparator ?? false
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop {
position: 0.0
color: Colors.transparent
}
GradientStop {
position: 0.1
color: Colors.accent
}
GradientStop {
position: 0.9
color: Colors.accent
}
GradientStop {
position: 1.0
color: Colors.transparent
}
}
}
Rectangle {
anchors.fill: parent
color: mouseArea.containsMouse ? Colors.accent : Colors.transparent
radius: 10
color: mouseArea.containsMouse ? Colors.primary : Colors.transparent
radius: Style.radiusS
visible: !(modelData?.isSeparator ?? false)
RowLayout {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 8
spacing: 8
anchors.leftMargin: Style.marginM
anchors.rightMargin: Style.marginM
spacing: Style.marginS
Text {
NText {
id: text
Layout.fillWidth: true
color: (modelData?.enabled ?? true) ? (mouseArea.containsMouse ? Colors.base : Colors.text) : Colors.text
color: (modelData?.enabled ?? true) ? (mouseArea.containsMouse ? Color.mOnTertiary : Color.mOnSurface) : Color.mOnSurfaceVariant
text: modelData?.text !== "" ? modelData?.text.replace(/[\n\r]+/g, ' ') : "..."
font.pointSize: Fonts.small
pointSize: Style.fontSizeS
verticalAlignment: Text.AlignVCenter
wrapMode: Text.WordWrap
}
Image {
Layout.preferredWidth: 14
Layout.preferredHeight: 14
Layout.preferredWidth: Style.marginL
Layout.preferredHeight: Style.marginL
source: modelData?.icon ?? ""
visible: (modelData?.icon ?? "") !== ""
fillMode: Image.PreserveAspectFit
}
Text {
NIcon {
icon: modelData?.hasChildren ? "menu" : ""
pointSize: Style.fontSizeS
verticalAlignment: Text.AlignVCenter
visible: modelData?.hasChildren ?? false
color: (mouseArea.containsMouse ? Colors.base : Colors.text)
text: {
const icon = modelData?.hasChildren ? "menu" : ""
if ((icon === undefined) || (icon === "")) {
return ""
}
if (Icons.get(icon) === undefined) {
console.warn("Icon", `"${icon}"`, "doesn't exist in the icons font")
return Icons.get(Icons.defaultIcon)
}
return Icons.get(icon)
}
font.family: Icons.fontFamily
font.pointSize: Fonts.small
color: (mouseArea.containsMouse ? Color.mOnTertiary : Color.mOnSurface)
}
}

View File

@@ -0,0 +1,537 @@
import Qt5Compat.GraphicalEffects
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Wayland
import qs.Constants
import qs.Noctalia
import qs.Services
import qs.Utils
NPanel {
id: root
preferredWidth: 400
preferredHeight: 520
panelContent: ColumnLayout {
id: content
readonly property int firstDayOfWeek: Qt.locale().firstDayOfWeek
property bool isCurrentMonth: checkIsCurrentMonth()
readonly property bool weatherReady: (LocationService.data.weather !== null)
function checkIsCurrentMonth() {
return (Time.date.getMonth() === grid.month) && (Time.date.getFullYear() === grid.year);
}
function getISOWeekNumber(date) {
const target = new Date(date.getTime());
target.setHours(0, 0, 0, 0);
const dayOfWeek = target.getDay() || 7;
target.setDate(target.getDate() + 4 - dayOfWeek);
const yearStart = new Date(target.getFullYear(), 0, 1);
const weekNumber = Math.ceil(((target - yearStart) / 8.64e+07 + 1) / 7);
return weekNumber;
}
anchors.fill: parent
anchors.margins: Style.marginL
spacing: Style.marginM
Connections {
function onDateChanged() {
isCurrentMonth = checkIsCurrentMonth();
}
target: Time
}
// Combined blue banner with date/time and weather summary
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: blueColumn.implicitHeight + Style.marginM * 2
radius: Style.radiusL
color: Color.mSurfaceVariant
layer.enabled: true
ColumnLayout {
id: blueColumn
anchors.fill: parent
anchors.margins: Style.marginM
spacing: 0
// Combined layout for weather icon, date, and weather text
RowLayout {
Layout.fillWidth: true
Layout.preferredHeight: 60
spacing: Style.marginS
// Weather icon and temperature
ColumnLayout {
Layout.alignment: Qt.AlignVCenter
spacing: Style.marginXXS
NIcon {
Layout.alignment: Qt.AlignHCenter
icon: weatherReady ? LocationService.weatherSymbolFromCode(LocationService.data.weather.current_weather.weathercode) : "cloud"
pointSize: Style.fontSizeXXL
color: Color.mOnSurfaceVariant
}
NText {
Layout.alignment: Qt.AlignHCenter
text: {
if (!weatherReady)
return "";
var temp = LocationService.data.weather.current_weather.temperature;
var suffix = "C";
temp = Math.round(temp);
return `${temp}°${suffix}`;
}
pointSize: Style.fontSizeM
font.weight: Style.fontWeightBold
color: Color.mOnSurfaceVariant
}
}
// Today day number
NText {
visible: content.isCurrentMonth
Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft
text: Time.date.getDate()
pointSize: Style.fontSizeXXXL * 1.5
font.weight: Style.fontWeightBold
color: Color.mOnSurfaceVariant
}
Item {
visible: !content.isCurrentMonth
}
// Month, year, location
ColumnLayout {
Layout.fillWidth: false
Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft
spacing: -Style.marginXS
RowLayout {
spacing: 0
NText {
text: Qt.locale().monthName(grid.month, Locale.LongFormat).toUpperCase()
pointSize: Style.fontSizeXL * 1.2
font.weight: Style.fontWeightBold
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignBaseline
Layout.maximumWidth: 150
elide: Text.ElideRight
}
NText {
text: ` ${grid.year}`
pointSize: Style.fontSizeL
font.weight: Style.fontWeightBold
color: Qt.alpha(Color.mOnSurfaceVariant, 0.7)
Layout.alignment: Qt.AlignBaseline
}
}
RowLayout {
spacing: 0
NText {
text: {
if (!weatherReady)
return "Weather unavailable";
const chunks = LocationService.data.name.split(",");
return chunks[0];
}
pointSize: Style.fontSizeM
font.weight: Style.fontWeightMedium
color: Color.mOnSurfaceVariant
Layout.maximumWidth: 150
elide: Text.ElideRight
}
NText {
text: weatherReady ? ` (${LocationService.data.weather.timezone_abbreviation})` : ""
pointSize: Style.fontSizeXS
font.weight: Style.fontWeightMedium
color: Qt.alpha(Color.mOnSurfaceVariant, 0.7)
}
}
}
// Spacer between date and clock
Item {
Layout.fillWidth: true
}
// Digital clock with circular progress
Item {
width: Style.fontSizeXXXL * 1.9
height: Style.fontSizeXXXL * 1.9
Layout.alignment: Qt.AlignVCenter
// Seconds circular progress
Canvas {
id: secondsProgress
property real progress: Time.date.getSeconds() / 60
anchors.fill: parent
onProgressChanged: requestPaint()
onPaint: {
var ctx = getContext("2d");
var centerX = width / 2;
var centerY = height / 2;
var radius = Math.min(width, height) / 2 - 3;
ctx.reset();
// Background circle
ctx.beginPath();
ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI);
ctx.lineWidth = 2.5;
ctx.strokeStyle = Qt.alpha(Color.mOnSurfaceVariant, 0.15);
ctx.stroke();
// Progress arc
ctx.beginPath();
ctx.arc(centerX, centerY, radius, -Math.PI / 2, -Math.PI / 2 + progress * 2 * Math.PI);
ctx.lineWidth = 2.5;
ctx.strokeStyle = Color.mOnSurfaceVariant;
ctx.lineCap = "round";
ctx.stroke();
}
Connections {
function onDateChanged() {
secondsProgress.progress = Time.date.getSeconds() / 60;
}
target: Time
}
}
// Digital clock
ColumnLayout {
anchors.centerIn: parent
spacing: -Style.marginXXS
NText {
text: {
var t = Qt.locale().toString(new Date(), "HH");
return t.split(" ")[0];
}
pointSize: Style.fontSizeXS
font.weight: Style.fontWeightBold
color: Color.mOnSurfaceVariant
family: Fonts.sans
Layout.alignment: Qt.AlignHCenter
}
NText {
text: Qt.formatTime(Time.date, "mm")
pointSize: Style.fontSizeXXS
font.weight: Style.fontWeightBold
color: Color.mOnSurfaceVariant
family: Fonts.sans
Layout.alignment: Qt.AlignHCenter
}
}
}
}
}
layer.effect: DropShadow {
horizontalOffset: 6
verticalOffset: 6
radius: 8
samples: 12
color: Qt.rgba(0, 0, 0, 0.3)
}
}
// 6-day forecast (outside blue banner)
RowLayout {
visible: weatherReady
Layout.fillWidth: true
Layout.alignment: Qt.AlignHCenter
spacing: Style.marginL
Repeater {
model: weatherReady ? Math.min(6, LocationService.data.weather.daily.time.length) : 0
delegate: ColumnLayout {
Layout.preferredWidth: 0
Layout.fillWidth: true
Layout.alignment: Qt.AlignHCenter
spacing: Style.marginS
NText {
text: {
var weatherDate = new Date(LocationService.data.weather.daily.time[index].replace(/-/g, "/"));
return Qt.locale().toString(weatherDate, "ddd");
}
color: Color.mOnSurfaceVariant
pointSize: Style.fontSizeM
font.weight: Style.fontWeightMedium
Layout.alignment: Qt.AlignHCenter
}
NIcon {
Layout.alignment: Qt.AlignVCenter | Qt.AlignHCenter
icon: LocationService.weatherSymbolFromCode(LocationService.data.weather.daily.weathercode[index])
pointSize: Style.fontSizeXXL * 1.5
color: LocationService.weatherColorFromCode(LocationService.data.weather.daily.weathercode[index])
}
NText {
Layout.alignment: Qt.AlignHCenter
text: {
var max = LocationService.data.weather.daily.temperature_2m_max[index];
var min = LocationService.data.weather.daily.temperature_2m_min[index];
max = Math.round(max);
min = Math.round(min);
return `${max}°/${min}°`;
}
pointSize: Style.fontSizeXS
color: Color.mOnSurfaceVariant
font.weight: Style.fontWeightMedium
}
}
}
}
// Loading indicator for weather
RowLayout {
visible: !weatherReady
Layout.fillWidth: true
Layout.alignment: Qt.AlignHCenter
NBusyIndicator {
}
}
// Spacer
Item {
}
// Navigation and divider
RowLayout {
Layout.fillWidth: true
spacing: Style.marginS
NDivider {
Layout.fillWidth: true
}
NIconButton {
icon: "chevron-left"
colorBg: Color.transparent
colorBorder: Color.transparent
colorBorderHover: Color.transparent
onClicked: {
let newDate = new Date(grid.year, grid.month - 1, 1);
grid.year = newDate.getFullYear();
grid.month = newDate.getMonth();
content.isCurrentMonth = content.checkIsCurrentMonth();
}
}
NIconButton {
icon: "calendar"
colorBg: Color.transparent
colorBorder: Color.transparent
colorBorderHover: Color.transparent
onClicked: {
grid.month = Time.date.getMonth();
grid.year = Time.date.getFullYear();
content.isCurrentMonth = true;
}
}
NIconButton {
icon: "chevron-right"
colorBg: Color.transparent
colorBorder: Color.transparent
colorBorderHover: Color.transparent
onClicked: {
let newDate = new Date(grid.year, grid.month + 1, 1);
grid.year = newDate.getFullYear();
grid.month = newDate.getMonth();
content.isCurrentMonth = content.checkIsCurrentMonth();
}
}
}
// Names of days of the week
RowLayout {
Layout.fillWidth: true
spacing: 0
Item {
Layout.preferredWidth: visible ? Style.baseWidgetSize * 0.7 : 0
}
GridLayout {
Layout.fillWidth: true
columns: 7
rows: 1
columnSpacing: 0
rowSpacing: 0
Repeater {
model: 7
Item {
Layout.fillWidth: true
Layout.preferredHeight: Style.baseWidgetSize * 0.6
NText {
anchors.centerIn: parent
text: {
let dayIndex = (content.firstDayOfWeek + index) % 7;
const dayNames = ["S", "M", "T", "W", "T", "F", "S"];
return dayNames[dayIndex];
}
color: Color.mPrimary
pointSize: Style.fontSizeS
font.weight: Style.fontWeightBold
horizontalAlignment: Text.AlignHCenter
}
}
}
}
}
// Grid with weeks and days
RowLayout {
Layout.fillWidth: true
Layout.fillHeight: true
spacing: 0
// Column of week numbers
ColumnLayout {
Layout.preferredWidth: visible ? Style.baseWidgetSize * 0.7 : 0
Layout.fillHeight: true
spacing: 0
Repeater {
model: 6
Item {
Layout.fillWidth: true
Layout.fillHeight: true
NText {
anchors.centerIn: parent
color: Color.mOutline
pointSize: Style.fontSizeXXS
font.weight: Style.fontWeightMedium
text: {
let firstOfMonth = new Date(grid.year, grid.month, 1);
let firstDayOfWeek = content.firstDayOfWeek;
let firstOfMonthDayOfWeek = firstOfMonth.getDay();
let daysBeforeFirst = (firstOfMonthDayOfWeek - firstDayOfWeek + 7) % 7;
if (daysBeforeFirst === 0)
daysBeforeFirst = 7;
let gridStartDate = new Date(grid.year, grid.month, 1 - daysBeforeFirst);
let rowStartDate = new Date(gridStartDate);
rowStartDate.setDate(gridStartDate.getDate() + (index * 7));
let thursday = new Date(rowStartDate);
if (firstDayOfWeek === 0) {
thursday.setDate(rowStartDate.getDate() + 4);
} else if (firstDayOfWeek === 1) {
thursday.setDate(rowStartDate.getDate() + 3);
} else {
let daysToThursday = (4 - firstDayOfWeek + 7) % 7;
thursday.setDate(rowStartDate.getDate() + daysToThursday);
}
return `${getISOWeekNumber(thursday)}`;
}
}
}
}
}
// Days Grid
MonthGrid {
id: grid
Layout.fillWidth: true
Layout.fillHeight: true
spacing: Style.marginXXS
month: Time.date.getMonth()
year: Time.date.getFullYear()
locale: Qt.locale()
delegate: Item {
Rectangle {
width: Style.baseWidgetSize * 0.9
height: Style.baseWidgetSize * 0.9
anchors.centerIn: parent
radius: Style.radiusM
color: model.today ? Color.mSecondary : Color.transparent
NText {
anchors.centerIn: parent
text: model.day
color: {
if (model.today)
return Color.mOnSecondary;
if (model.month === grid.month)
return Color.mOnSurface;
return Color.mOnSurfaceVariant;
}
opacity: model.month === grid.month ? 1 : 0.4
pointSize: Style.fontSizeM
font.weight: model.today ? Style.fontWeightBold : Style.fontWeightMedium
}
Behavior on color {
ColorAnimation {
duration: Style.animationFast
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,46 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import qs.Constants
import qs.Noctalia
import qs.Services
import qs.Utils
NBox {
id: lyricsBox
Component.onCompleted: {
LyricsService.startSyncing();
}
Component.onDestruction: {
LyricsService.stopSyncing();
}
ColumnLayout {
id: lyricsColumn
anchors.fill: parent
anchors.margins: Style.marginS
Repeater {
model: LyricsService.lyrics
NText {
Layout.fillWidth: true
text: modelData
font.pointSize: index === LyricsService.currentIndex ? Style.fontSizeM : Style.fontSizeS
font.weight: index === LyricsService.currentIndex ? Style.fontWeightBold : Style.fontWeightRegular
font.family: Fonts.sans
color: index === LyricsService.currentIndex ? Color.mOnSurface : Color.mOnSurfaceVariant
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
wrapMode: Text.WrapAnywhere
maximumLineCount: 1
}
}
}
}

View File

@@ -0,0 +1,96 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import qs.Constants
import qs.Modules.Bar.Misc
import qs.Noctalia
import qs.Services
GridLayout {
id: buttonsGrid
columns: 2
columnSpacing: 10
rowSpacing: 10
Layout.margins: 10
NIconButton {
id: slowerButton
baseSize: 32
colorBg: Color.transparent
colorBgHover: Colors.blue
colorFg: Colors.blue
icon: "arrow-bar-up"
onClicked: {
LyricsService.increaseOffset();
}
}
NIconButton {
id: playPauseButton
baseSize: 32
colorBg: Color.transparent
colorBgHover: Colors.yellow
colorFg: Colors.yellow
icon: "arrow-bar-down"
onClicked: {
LyricsService.decreaseOffset();
}
}
NIconButton {
id: nextButton
baseSize: 32
colorBg: Color.transparent
colorBgHover: Colors.green
colorFg: Colors.green
icon: "rotate-clockwise"
onClicked: {
LyricsService.resetOffset();
}
}
NIconButton {
id: fasterButton
baseSize: 32
colorBg: Color.transparent
colorBgHover: Colors.red
colorFg: Colors.red
icon: "trash"
onClicked: {
LyricsService.clearCache();
}
}
NIconButton {
id: barLyricsButton
baseSize: 32
colorBg: SettingsService.showLyricsBar ? Colors.peach : Color.transparent
colorBgHover: Colors.peach
colorFg: SettingsService.showLyricsBar ? Colors.base : Colors.peach
icon: "app-window"
onClicked: {
SettingsService.showLyricsBar = !SettingsService.showLyricsBar;
}
}
NIconButton {
id: textButton
baseSize: 32
colorBg: Color.transparent
colorBgHover: Colors.subtext1
colorFg: Colors.subtext1
icon: "align-box-left-bottom"
onClicked: {
LyricsService.showLyricsText();
controlCenterPanel.close();
}
}
}

View File

@@ -0,0 +1,458 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Effects
import QtQuick.Layouts
import Quickshell
import qs.Constants
import qs.Noctalia
import qs.Services
import qs.Utils
NBox {
id: root
// Background artwork that covers everything
Item {
anchors.fill: parent
clip: true
NImageRounded {
id: bgArtImage
anchors.fill: parent
imagePath: MusicManager.trackArtUrl
imageRadius: Style.radiusM
visible: MusicManager.trackArtUrl !== ""
}
// Dark overlay for readability
Rectangle {
anchors.fill: parent
color: Color.mSurfaceVariant
opacity: 0.85
radius: Style.radiusM
}
// Border
Rectangle {
anchors.fill: parent
color: Color.transparent
radius: Style.radiusM
}
}
// Background visualizer on top of the artwork
Item {
id: visualizerContainer
anchors.fill: parent
layer.enabled: true
Item {
anchors.fill: parent
Cava {
id: cava
count: 32
}
Repeater {
model: cava.values
Rectangle {
anchors.bottom: parent.bottom
width: (parent.width - (cava.count - 1) * Style.marginXS) / cava.count
height: modelData * parent.height
x: index * (width + Style.marginXS)
color: Color.mPrimary
radius: width / 2
opacity: 0.25
}
}
}
layer.effect: MultiEffect {
maskEnabled: true
maskThresholdMin: 0.5
maskSpreadAtMin: 0
maskSource: ShaderEffectSource {
sourceItem: Rectangle {
width: root.width
height: root.height
radius: Style.radiusM
color: "white"
}
}
}
}
// Player selector - positioned at the very top
Rectangle {
id: playerSelectorButton
property var currentPlayer: MusicManager.getAvailablePlayers()[MusicManager.selectedPlayerIndex]
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.topMargin: Style.marginXS
anchors.leftMargin: Style.marginM
anchors.rightMargin: Style.marginM
height: Style.barHeight
visible: MusicManager.getAvailablePlayers().length > 1
radius: Style.radiusM
color: Color.transparent
Component.onCompleted: {
MusicManager.selectedPlayerIndex = -1;
}
Component.onDestruction: {
MusicManager.selectedPlayerIndex = -1;
}
RowLayout {
anchors.fill: parent
spacing: Style.marginS
NIcon {
icon: "caret-down"
pointSize: Style.fontSizeXXL
color: Color.mOnSurfaceVariant
}
NText {
text: playerSelectorButton.currentPlayer ? playerSelectorButton.currentPlayer.identity : ""
pointSize: Style.fontSizeXS
color: Color.mOnSurfaceVariant
Layout.fillWidth: true
}
}
MouseArea {
id: playerSelectorMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
var menuItems = [];
var players = MusicManager.getAvailablePlayers();
for (var i = 0; i < players.length; i++) {
menuItems.push({
"label": players[i].identity,
"action": i.toString(),
"icon": "disc",
"enabled": true,
"visible": true
});
}
playerContextMenu.model = menuItems;
playerContextMenu.openAtItem(playerSelectorButton, playerSelectorButton.width - playerContextMenu.width, playerSelectorButton.height);
}
}
NContextMenu {
id: playerContextMenu
parent: root
width: 200
onTriggered: function(action) {
var index = parseInt(action);
if (!isNaN(index)) {
MusicManager.selectedPlayerIndex = index;
MusicManager.updateCurrentPlayer();
}
}
}
}
ColumnLayout {
anchors.fill: parent
anchors.margins: Style.marginM
// No media player detected
ColumnLayout {
id: fallback
visible: !main.visible
spacing: Style.marginS
Item {
Layout.fillWidth: true
Layout.fillHeight: true
}
Item {
Layout.fillWidth: true
Layout.fillHeight: true
ColumnLayout {
anchors.centerIn: parent
spacing: Style.marginL
Item {
Layout.alignment: Qt.AlignHCenter
Layout.preferredWidth: Style.fontSizeXXXL * 4
Layout.preferredHeight: Style.fontSizeXXXL * 4
// Pulsating audio circles (background)
Repeater {
model: 3
Rectangle {
anchors.centerIn: parent
width: parent.width * (1 + index * 0.2)
height: width
radius: width / 2
color: "transparent"
border.color: Color.mOnSurfaceVariant
border.width: 2
opacity: 0
SequentialAnimation on opacity {
running: true
loops: Animation.Infinite
PauseAnimation {
duration: index * 600
}
NumberAnimation {
from: 1
to: 0
duration: 2000
easing.type: Easing.OutQuad
}
}
SequentialAnimation on scale {
running: true
loops: Animation.Infinite
PauseAnimation {
duration: index * 600
}
NumberAnimation {
from: 0.5
to: 1.2
duration: 2000
easing.type: Easing.OutQuad
}
}
}
}
// Spinning disc
NIcon {
anchors.centerIn: parent
icon: "disc"
pointSize: Style.fontSizeXXXL * 3
color: Color.mOnSurfaceVariant
RotationAnimator on rotation {
from: 0
to: 360
duration: 8000
loops: Animation.Infinite
running: true
}
}
}
// Descriptive text
ColumnLayout {
Layout.alignment: Qt.AlignHCenter
spacing: Style.marginXS
}
}
}
Item {
Layout.fillWidth: true
Layout.fillHeight: true
}
}
// MediaPlayer Main Content
ColumnLayout {
id: main
visible: MusicManager.currentPlayer && MusicManager.canPlay
spacing: Style.marginS
// Spacer to push content down
Item {
Layout.preferredHeight: Style.marginM
}
// Metadata at the bottom left
ColumnLayout {
Layout.fillWidth: true
Layout.alignment: Qt.AlignLeft
spacing: Style.marginXS
NText {
visible: MusicManager.trackTitle !== ""
text: MusicManager.trackTitle
pointSize: Style.fontSizeM
font.weight: Style.fontWeightBold
elide: Text.ElideRight
wrapMode: Text.Wrap
Layout.fillWidth: true
maximumLineCount: 1
}
NText {
visible: MusicManager.trackArtist !== ""
text: MusicManager.trackArtist
color: Color.mPrimary
pointSize: Style.fontSizeS
elide: Text.ElideRight
Layout.fillWidth: true
maximumLineCount: 1
}
NText {
visible: MusicManager.trackAlbum !== ""
text: MusicManager.trackAlbum
color: Color.mOnSurfaceVariant
pointSize: Style.fontSizeM
elide: Text.ElideRight
Layout.fillWidth: true
maximumLineCount: 1
}
}
// Progress slider
Item {
id: progressWrapper
property real localSeekRatio: -1
property real lastSentSeekRatio: -1
property real seekEpsilon: 0.01
property real progressRatio: {
if (!MusicManager.currentPlayer || MusicManager.trackLength <= 0)
return 0;
const r = MusicManager.currentPosition / MusicManager.trackLength;
if (isNaN(r) || !isFinite(r))
return 0;
return Math.max(0, Math.min(1, r));
}
property real effectiveRatio: (MusicManager.isSeeking && localSeekRatio >= 0) ? Math.max(0, Math.min(1, localSeekRatio)) : progressRatio
visible: (MusicManager.currentPlayer && MusicManager.trackLength > 0)
Layout.fillWidth: true
height: Style.baseWidgetSize * 0.5
Timer {
id: seekDebounce
interval: 75
repeat: false
onTriggered: {
if (MusicManager.isSeeking && progressWrapper.localSeekRatio >= 0) {
const next = Math.max(0, Math.min(1, progressWrapper.localSeekRatio));
if (progressWrapper.lastSentSeekRatio < 0 || Math.abs(next - progressWrapper.lastSentSeekRatio) >= progressWrapper.seekEpsilon) {
MusicManager.seekByRatio(next);
progressWrapper.lastSentSeekRatio = next;
}
}
}
}
NSlider {
id: progressSlider
anchors.fill: parent
from: 0
to: 1
stepSize: 0
snapAlways: false
enabled: MusicManager.trackLength > 0 && MusicManager.canSeek
heightRatio: 0.65
onMoved: {
progressWrapper.localSeekRatio = value;
seekDebounce.restart();
}
onPressedChanged: {
if (pressed) {
MusicManager.isSeeking = true;
progressWrapper.localSeekRatio = value;
MusicManager.seekByRatio(value);
progressWrapper.lastSentSeekRatio = value;
} else {
seekDebounce.stop();
MusicManager.seekByRatio(value);
MusicManager.isSeeking = false;
progressWrapper.localSeekRatio = -1;
progressWrapper.lastSentSeekRatio = -1;
}
}
}
Binding {
target: progressSlider
property: "value"
value: progressWrapper.progressRatio
when: !MusicManager.isSeeking
}
}
// Media controls
RowLayout {
spacing: Style.marginS
Layout.fillWidth: true
Layout.alignment: Qt.AlignHCenter
NIconButton {
icon: "media-prev"
visible: MusicManager.canGoPrevious
onClicked: MusicManager.canGoPrevious ? MusicManager.previous() : {
}
}
NIconButton {
icon: MusicManager.isPlaying ? "media-pause" : "media-play"
visible: (MusicManager.canPlay || MusicManager.canPause)
onClicked: (MusicManager.canPlay || MusicManager.canPause) ? MusicManager.playPause() : {
}
}
NIconButton {
icon: "media-next"
visible: MusicManager.canGoNext
onClicked: MusicManager.canGoNext ? MusicManager.next() : {
}
}
}
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}
}
}
}

View File

@@ -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
}
}
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}
}
}
}

View File

@@ -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
}
}
}
}
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}
}
}
}
}

View File

@@ -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
}
}
}
}
}
}

View File

@@ -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
}
}
}
}
}

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -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)
}
}
}

View File

@@ -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);
}
}

View File

@@ -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"
}
}

View File

@@ -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";
}
}
}
}

View File

@@ -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 + "&current_weather=true&current=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()
}
}

View File

@@ -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.");
}
}
}

View File

@@ -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

View File

@@ -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);
}
}
}

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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()
}
}

View File

@@ -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

View File

@@ -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);
}
}

View File

Binary file not shown.

View File

Binary file not shown.

View File

@@ -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;
});
}
}

View File

@@ -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", "--------------------------")
}
}

84
quickshell/Utils/Time.qml Normal file
View File

@@ -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()
}
}

View File

@@ -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"
}
}

View File

@@ -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 -----*****/

View File

@@ -20,7 +20,7 @@
peach: #fab387;
search: rgba(49, 50, 68, 0.5); // alpha(@surface, 0.5)
accent: #<FLAVOR_HEX>;
primary: #<FLAVOR_HEX>;
}
@@ -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 -----*****/

View File

@@ -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": "<big>{:%Y %B}</big>\n<tt>{calendar}</tt>",
"today-format": "<b>{}</b>",
"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": "<span foreground='#fab387'> </span>",
//"format-icons": [ "<span foreground='#fab387'></span>" ]
"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
}
}

View File

@@ -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;

View File

@@ -1,3 +0,0 @@
publicip.conf
publicip.cache
publicip.log

View File

@@ -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

View File

@@ -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("&", "&amp;")
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()

View File

@@ -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: <ip>
# 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>/$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}'

View File

@@ -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;
}

View File

@@ -1,222 +0,0 @@
@import 'mocha.css';
@define-color flavor #<FLAVOR_HEX>;
/* @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;
}

View File

@@ -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 &