better structure

This commit is contained in:
2025-10-19 00:14:19 +02:00
parent 057afc086e
commit 8733656ed9
630 changed files with 81 additions and 137 deletions

View File

@@ -0,0 +1,2 @@
# some sensitive files
GeoInfoToken.txt

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1,8 @@
{
"location": "Munich",
"notifications": {
"doNotDisturb": false
},
"primaryColor": "#89b4fa",
"showLyricsBar": false
}

View File

@@ -0,0 +1,16 @@
Tabler Licenses - Detailed Usage Rights and Guidelines
This is a legal agreement between you, the Purchaser, and Tabler. Purchasing or downloading of any Tabler product (Tabler Admin Template, Tabler Icons, Tabler Emails, Tabler Illustrations), constitutes your acceptance of the terms of this license, Tabler terms of service and Tabler private policy.
Tabler Admin Template and Tabler Icons License*
Tabler Admin Template and Tabler Icons are available under MIT License.
Copyright (c) 2018-2025 Tabler
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.
See more at Tabler Admin Template MIT License See more at Tabler Icons MIT License

View File

Binary file not shown.

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -0,0 +1,26 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Constants
pragma Singleton
Singleton {
id: root
// Compatibility colors for noctalia modules
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.overlay1
property color mOutline: Colors.primary
property color mShadow: Colors.crust
property color transparent: "transparent"
}

View File

@@ -0,0 +1,40 @@
import QtQuick
import Quickshell
import qs.Services
pragma Singleton
Singleton {
id: root
readonly property color transparent: "transparent"
readonly property color rosewater: "#f5e0dc"
readonly property color flamingo: "#f2cdcd"
readonly property color pink: "#f5c2e7"
readonly property color mauve: "#cba6f7"
readonly property color red: "#f38ba8"
readonly property color maroon: "#eba0ac"
readonly property color peach: "#fab387"
readonly property color yellow: "#f9e2af"
readonly property color green: "#a6e3a1"
readonly property color teal: "#94e2d5"
readonly property color sky: "#89dceb"
readonly property color sapphire: "#74c7ec"
readonly property color blue: "#89b4fa"
readonly property color lavender: "#b4befe"
readonly property color text: "#cdd6f4"
readonly property color subtext1: "#bac2de"
readonly property color subtext0: "#a6adc8"
readonly property color overlay2: "#9399b2"
readonly property color overlay1: "#7f849c"
readonly property color overlay0: "#6c7086"
readonly property color surface2: "#585b70"
readonly property color surface1: "#45475a"
readonly property color surface0: "#313244"
readonly property color surface: "#292a3c"
readonly property color base: "#1e1e2e"
readonly property color mantle: "#181825"
readonly property color crust: "#11111b"
property color primary: SettingsService.primaryColor
readonly property color distroColor: "#74c7ec"
readonly property var cavaList: ["#b4befe", "#89b4fa", "#74c7ec", "#89dceb", "#94e2d5", "#a6e3a1", "#f9e2af", "#fab387"]
}

View File

@@ -0,0 +1,16 @@
import QtQuick
import Quickshell
import qs.Constants
pragma Singleton
Singleton {
id: root
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: Style.fontSizeS
readonly property int medium: Style.fontSizeM
readonly property int large: Style.fontSizeL
readonly property int icon: 14 // for nerd font
}

View File

@@ -0,0 +1,105 @@
import QtQuick
import Quickshell
import qs.Utils
pragma Singleton
Singleton {
id: root
// Nerd fonts icons
readonly property string distro: "󰣇"
readonly property string tray: ""
readonly property string idleInhibitorActivated: ""
readonly property string idleInhibitorDeactivated: ""
readonly property string powerMenu: "󰐥"
readonly property string volumeHigh: ""
readonly property string volumeMedium: ""
readonly property string volumeLow: ""
readonly property string volumeMuted: "󰝟"
readonly property string brightness: ""
readonly property string charging: ""
readonly property string battery100: ""
readonly property string battery75: ""
readonly property string battery50: ""
readonly property string battery25: ""
readonly property string battery00: ""
readonly property string cpu: "󰘚"
readonly property string memory: "󰍛"
readonly property string tempHigh: ""
readonly property string tempMedium: ""
readonly property string tempLow: ""
readonly property string 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: ""
readonly property string record: ""
// Tabler icons
// Expose the font family name for easy access
readonly property string fontFamily: currentFontLoader ? currentFontLoader.name : ""
readonly property string defaultIcon: TablerIcons.defaultIcon
readonly property var icons: TablerIcons.icons
readonly property var aliases: TablerIcons.aliases
readonly property string fontPath: "/Assets/Fonts/tabler/tabler-icons.ttf"
// Current active font loader
property FontLoader currentFontLoader: null
property int fontVersion: 0
// Create a unique cache-busting path
readonly property string cacheBustingPath: Quickshell.shellDir + fontPath + "?v=" + fontVersion + "&t=" + Date.now()
// Signal emitted when font is reloaded
signal fontReloaded()
// ---------------------------------------
function get(iconName) {
// Check in aliases first
if (aliases[iconName] !== undefined)
iconName = aliases[iconName];
// Find the appropriate codepoint
return icons[iconName];
}
function loadFontWithCacheBusting() {
// Destroy old loader first
if (currentFontLoader) {
currentFontLoader.destroy();
currentFontLoader = null;
}
// Create new loader with cache-busting URL
currentFontLoader = Qt.createQmlObject(`
import QtQuick
FontLoader {
source: "${cacheBustingPath}"
}
`, root, "dynamicFontLoader_" + fontVersion);
// Connect to the new loader's status changes
currentFontLoader.statusChanged.connect(function() {
if (currentFontLoader.status === FontLoader.Ready)
fontReloaded();
else if (currentFontLoader.status === FontLoader.Error)
Logger.error("Icons", "Font failed to load (version " + fontVersion + ")");
});
}
function reloadFont() {
fontVersion++;
loadFontWithCacheBusting();
}
Component.onCompleted: {
loadFontWithCacheBusting();
}
Connections {
function onReloadCompleted() {
reloadFont();
}
target: Quickshell
}
}

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: 1000
// 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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,256 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Services.UPower
import Quickshell.Wayland
import qs.Constants
import qs.Modules.Bar.Components
import qs.Modules.Bar.Misc
import qs.Modules.Misc
import qs.Services
Variants {
model: Quickshell.screens
Item {
property var modelData
PanelWindow {
id: panel
screen: modelData
WlrLayershell.namespace: "quickshell-bar"
color: Colors.transparent
implicitHeight: Style.barHeight
anchors {
left: true
right: true
top: true
}
Rectangle {
id: barBackground
anchors.fill: parent
color: Niri.noFocus ? null : Colors.base
gradient: Gradient {
GradientStop {
position: 0
color: Qt.rgba(Colors.base.r, Colors.base.g, Colors.base.b, Niri.noFocus ? 0.8 : 1)
Behavior on color {
ColorAnimation {
duration: Style.animationSlowest
easing.type: Easing.InOutCubic
}
}
}
GradientStop {
position: 1
color: Qt.rgba(Colors.base.r, Colors.base.g, Colors.base.b, Niri.noFocus ? 0 : 1)
Behavior on color {
ColorAnimation {
duration: Style.animationSlowest
easing.type: Easing.InOutCubic
}
}
}
}
}
RowLayout {
id: leftLayout
height: parent.height - 10
anchors {
left: parent.left
verticalCenter: parent.verticalCenter
leftMargin: 5
}
SymbolButton {
symbol: Icons.distro
buttonColor: Colors.distroColor
onClicked: {
PanelService.getPanel("controlCenterPanel")?.toggle(this)
}
onRightClicked: {
if (action.running) {
action.signal(15);
return ;
}
action.exec(["rofi", "-show", "drun"]);
}
}
Separator {
}
Workspace {
screen: modelData
}
Separator {
}
Item {
width: 10
}
CavaBar {
count: 6
}
Item {
width: 10
}
Separator {
}
Item {
width: 10
}
FocusedWindow {
maxWidth: 400
}
}
RowLayout {
id: middleLayout
height: parent.height - 10
anchors {
horizontalCenter: parent.horizontalCenter
verticalCenter: parent.verticalCenter
}
Time {
}
}
RowLayout {
id: rightLayout
height: parent.height - 10
anchors {
right: parent.right
verticalCenter: parent.verticalCenter
rightMargin: 5
}
RowLayout {
id: monitorsLayout
visible: !SettingsService.showLyricsBar
height: parent.height
NetworkSpeed {
}
Separator {
}
Item {
width: 10
}
RecordIndicator {
}
Ip {
showCountryCode: true
}
CpuTemp {
}
MemUsage {
}
CpuUsage {
}
Battery {
}
Brightness {
screen: modelData
}
Volume {
}
}
LyricsBar {
id: lyricsBar
visible: SettingsService.showLyricsBar
width: 600
}
Item {
width: 5
}
Separator {
}
Item {
width: 5
}
TrayExpander {
screen: modelData
}
SymbolButton {
symbol: Caffeine.isInhibited ? Icons.idleInhibitorActivated : Icons.idleInhibitorDeactivated
buttonColor: Caffeine.isInhibited ? Colors.peach : Colors.yellow
onClicked: {
Caffeine.manualToggle();
}
}
SymbolButton {
symbol: Icons.powerMenu
buttonColor: Colors.red
onClicked: {
if (action.running) {
action.signal(15);
return ;
}
Quickshell.execDetached(["wlogout"]);
}
}
}
Process {
id: action
running: false
}
}
}
}

View File

@@ -0,0 +1,23 @@
import QtQuick
import Quickshell.Services.UPower
import qs.Constants
import qs.Modules.Bar.Misc
import qs.Services
MonitorItem {
readonly property var battery: UPower.displayDevice
readonly property bool isReady: (battery && battery.ready && battery.isLaptopBattery && battery.isPresent)
readonly property real percent: (isReady ? (battery.percentage * 100) : 0)
readonly property bool charging: (isReady ? battery.state === UPowerDeviceState.Charging : false)
property int lowBatteryThreshold: 20
symbol: {
return charging ? Icons.charging : percent >= 80 ? Icons.battery100 : percent >= 60 ? Icons.battery75 : percent >= 40 ? Icons.battery50 : percent >= 20 ? Icons.battery25 : Icons.battery00;
}
fillColor: Colors.sapphire
value: percent
critical: isReady && !charging && percent <= lowBatteryThreshold
maxValue: 100
textSuffix: "%"
pointerCursor: false
}

View File

@@ -0,0 +1,35 @@
import QtQuick
import Quickshell
import qs.Constants
import qs.Modules.Bar.Misc
import qs.Services
MonitorItem {
property ShellScreen screen: null
function getMonitor() {
return BrightnessService.getMonitorForScreen(screen) || null;
}
symbol: Icons.brightness
fillColor: Colors.blue
value: {
const monitor = getMonitor();
return monitor ? Math.round(monitor.brightness * 100) : "N/A";
}
maxValue: 100
textSuffix: "%"
expandOnValueChange: true
onWheelUp: {
const monitor = getMonitor();
if (monitor)
monitor.increaseBrightness();
}
onWheelDown: {
const monitor = getMonitor();
if (monitor)
monitor.decreaseBrightness();
}
}

View File

@@ -0,0 +1,73 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Constants
import qs.Services
import qs.Utils
Item {
id: root
property int count: 6
property int barWidth: 5
property int barSpacing: 3
implicitWidth: root.barWidth * root.count + root.barSpacing * (root.count - 1)
implicitHeight: parent.height - 10
Cava {
id: cavaProcess
count: root.count
}
RowLayout {
anchors.fill: parent
spacing: root.barSpacing
anchors {
verticalCenter: parent.verticalCenter
}
Repeater {
model: cavaProcess.values
Rectangle {
width: root.barWidth
implicitHeight: Math.max(1, modelData * (parent.height - 10))
color: Colors.cavaList[Math.min(Math.floor(modelData * (Colors.cavaList.length - 1)), Colors.cavaList.length - 1)]
Behavior on height {
NumberAnimation {
duration: 100
easing.type: Easing.InOutCubic
}
}
}
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: (mouse) => {
if (mouse.button === Qt.LeftButton)
MusicManager.playPause();
else if (mouse.button === Qt.RightButton)
SettingsService.showLyricsBar = !SettingsService.showLyricsBar;
}
onWheel: function(wheel) {
if (wheel.angleDelta.y > 0)
MusicManager.previous();
else if (wheel.angleDelta.y < 0)
MusicManager.next();
}
}
}

View File

@@ -0,0 +1,28 @@
import QtQuick
import Quickshell.Io
import qs.Constants
import qs.Modules.Bar.Misc
import qs.Services
MonitorItem {
symbol: SystemStatService.cpuTemp > 80 ? Icons.tempHigh : SystemStatService.cpuTemp > 50 ? Icons.tempMedium : Icons.tempLow
fillColor: Colors.yellow
critical: SystemStatService.cpuTemp > 80
value: Math.round(SystemStatService.cpuTemp)
maxValue: 100
textSuffix: "°C"
onClicked: {
if (action.running) {
action.signal(15);
return ;
}
action.exec(["ghostty", "-e", "btop"]);
}
Process {
id: action
running: false
}
}

View File

@@ -0,0 +1,28 @@
import QtQuick
import Quickshell.Io
import qs.Constants
import qs.Modules.Bar.Misc
import qs.Services
MonitorItem {
symbol: Icons.cpu
fillColor: Colors.teal
critical: SystemStatService.cpuUsage > 90
value: Math.round(SystemStatService.cpuUsage)
maxValue: 100
textSuffix: "%"
onClicked: {
if (action.running) {
action.signal(15);
return ;
}
action.exec(["ghostty", "-e", "btop"]);
}
Process {
id: action
running: false
}
}

View File

@@ -0,0 +1,129 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Widgets
import qs.Constants
import qs.Services
import qs.Utils
Item {
id: root
property real maxWidth: 250
property string fallbackIcon: "application-x-executable"
function getAppIcon(appId) {
try {
if (appId) {
try {
const normalizedId = (typeof appId === 'string') ? appId : String(appId);
const iconResult = ThemeIcons.iconForAppId(normalizedId.toLowerCase());
if (iconResult && iconResult !== "")
return iconResult;
} catch (iconError) {
Logger.warn("FocusedWindow", "Error getting icon from CompositorService: " + iconError);
}
}
return ThemeIcons.iconFromName(root.fallbackIcon);
} catch (e) {
Logger.warn("FocusedWindow", "Error in getAppIcon:", e);
return ThemeIcons.iconFromName(root.fallbackIcon);
}
}
implicitHeight: parent.height
RowLayout {
id: layout
anchors.fill: parent
spacing: 10
visible: Niri.focusedWindowId !== -1
Item {
// Layout.alignment: Qt.AlignVCenter
id: iconContainer
implicitWidth: 18
implicitHeight: 18
IconImage {
id: windowIcon
anchors.fill: parent
source: getAppIcon(Niri.focusedWindowAppId)
asynchronous: true
smooth: true
visible: source !== ""
}
}
Item {
id: titleContainer
implicitWidth: root.maxWidth
implicitHeight: parent.height
// Layout.alignment: Qt.AlignVCenter
clip: true
Text {
id: windowTitle
text: Niri.focusedWindowTitle
anchors.verticalCenter: parent.verticalCenter
font.pointSize: Fonts.medium
font.family: Fonts.primary
color: Colors.primary
MouseArea {
id: mouseArea
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.MiddleButton
onEntered: {
if (windowTitle.implicitWidth > titleContainer.width)
windowTitle.x = titleContainer.width - windowTitle.implicitWidth;
}
onExited: {
windowTitle.x = 0;
}
onClicked: function(mouse) {
if (mouse.button === Qt.MiddleButton)
Quickshell.execDetached(["niri", "msg", "action", "close-window"]);
else if (mouse.button === Qt.LeftButton)
Quickshell.execDetached(["niri", "msg", "action", "center-window"]);
}
onWheel: function(wheel) {
if (wheel.angleDelta.y > 0)
Quickshell.execDetached(["niri", "msg", "action", "set-column-width", "+10%"]);
else if (wheel.angleDelta.y < 0)
Quickshell.execDetached(["niri", "msg", "action", "set-column-width", "-10%"]);
else if (wheel.angleDelta.x > 0)
Quickshell.execDetached(["niri", "msg", "action", "focus-column-left"]);
else if (wheel.angleDelta.x < 0)
Quickshell.execDetached(["niri", "msg", "action", "focus-column-right"]);
}
}
Behavior on x {
NumberAnimation {
duration: 1000
easing.type: Easing.OutCubic
}
}
}
}
}
}

View File

@@ -0,0 +1,85 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import qs.Constants
import qs.Services
Item {
// Text {
// id: ipText
// anchors.verticalCenter: parent.verticalCenter
// text: Icons.global + " " + (showCountryCode ? IpService.countryCode : IpService.ip)
// font.pixelSize: Fonts.medium
// color: Colors.peach
// }
id: root
property bool showCountryCode: true
implicitHeight: parent.height
implicitWidth: layout.width + 10
RowLayout {
id: layout
anchors.top: parent.top
anchors.bottom: parent.bottom
spacing: 0
Text {
text: Icons.global
font.pointSize: Fonts.icon + 6
color: Colors.peach
}
Item {
id: expander
implicitWidth: mouseArea.containsMouse ? ipText.implicitWidth + 10 : 0
implicitHeight: parent.height
clip: true
Text {
id: ipText
text: showCountryCode ? IpService.countryCode : IpService.ip
font.pointSize: showCountryCode ? Fonts.medium : Fonts.small
font.family: Fonts.primary
color: Colors.peach
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: 5
}
Behavior on implicitWidth {
NumberAnimation {
duration: Style.animationFast
easing.type: Easing.InOutCubic
}
}
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
onClicked: (mouse) => {
if (mouse.button === Qt.LeftButton) {
WriteClipboard.write(showCountryCode ? IpService.countryCode : IpService.ip);
SendNotification.show("Copied to clipboard", showCountryCode ? IpService.countryCode : IpService.ip);
} else if (mouse.button === Qt.RightButton)
showCountryCode = !showCountryCode;
else if (mouse.button === Qt.MiddleButton)
IpService.refresh();
}
}
}

View File

@@ -0,0 +1,89 @@
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 {
function onShowLyricsBarChanged() {
visible = SettingsService.showLyricsBar;
if (visible)
LyricsService.startSyncing();
else
LyricsService.stopSyncing();
}
target: SettingsService
}
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

@@ -0,0 +1,34 @@
import QtQuick
import Quickshell.Io
import qs.Constants
import qs.Modules.Bar.Misc
import qs.Services
MonitorItem {
property bool showPercent: false
symbol: Icons.memory
fillColor: Colors.green
critical: SystemStatService.memPercent > 90
value: Math.round(SystemStatService.memPercent)
maxValue: 100
textValue: showPercent ? SystemStatService.memPercent : SystemStatService.memGb
textSuffix: showPercent ? "%" : "GB"
onClicked: {
if (action.running) {
action.signal(15);
return ;
}
action.exec(["ghostty", "-e", "btop"]);
}
onRightClicked: {
showPercent = !showPercent;
}
Process {
id: action
running: false
}
}

View File

@@ -0,0 +1,52 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import qs.Constants
import qs.Services
Item {
implicitHeight: parent.height
implicitWidth: layout.width + 10
RowLayout {
id: layout
anchors.top: parent.top
anchors.bottom: parent.bottom
spacing: 5
Text {
text: Icons.download
font.pointSize: Fonts.icon - 3
color: Colors.primary
Layout.leftMargin: 10
}
Text {
text: SystemStatService.formatSpeed(SystemStatService.rxSpeed)
font.pointSize: Fonts.medium
font.family: Fonts.primary
color: Colors.primary
}
Item {
width: 5
}
Text {
text: Icons.upload
font.pointSize: Fonts.icon - 3
color: Colors.primary
}
Text {
text: SystemStatService.formatSpeed(SystemStatService.txSpeed)
font.pointSize: Fonts.medium
font.family: Fonts.primary
color: Colors.primary
}
}
}

View File

@@ -0,0 +1,107 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import qs.Constants
import qs.Services
Item {
id: root
property color fillColor: Colors.red
property color _actualColor: Colors.red
visible: RecordService.isRecording
implicitHeight: parent.height
implicitWidth: layout.width + 10
SequentialAnimation {
id: blinkAnimation
running: RecordService.isRecording
loops: Animation.Infinite
ColorAnimation {
target: root
property: "_actualColor"
to: Qt.rgba(fillColor.r, fillColor.g, fillColor.b, 0)
duration: Style.animationSlowest
easing.type: Easing.InOutCubic
}
ColorAnimation {
target: root
property: "_actualColor"
to: fillColor
duration: Style.animationSlowest
easing.type: Easing.InOutCubic
}
}
RowLayout {
id: layout
anchors.top: parent.top
anchors.bottom: parent.bottom
spacing: 0
Text {
text: Icons.record
font.pointSize: Fonts.icon + 6
color: _actualColor
}
Item {
id: expander
implicitWidth: mouseArea.containsMouse ? ipText.implicitWidth + 10 : 0
implicitHeight: parent.height
clip: true
Text {
id: ipText
text: RecordService.recordingDisplay
font.pointSize: Fonts.medium
font.family: Fonts.primary
color: fillColor
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: 5
}
Behavior on implicitWidth {
NumberAnimation {
duration: Style.animationFast
easing.type: Easing.InOutCubic
}
}
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
onClicked: (mouse) => {
if (mouse.button === Qt.LeftButton)
RecordService.startOrStop();
}
}
Behavior on _actualColor {
ColorAnimation {
duration: Style.animationFast
easing.type: Easing.InOutCubic
}
}
}

View File

@@ -0,0 +1,18 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Constants
Item {
id: root
implicitHeight: parent.height
Rectangle {
anchors.centerIn: parent
width: 1.5
height: parent.height * 0.32
color: Colors.text
}
}

View File

@@ -0,0 +1,22 @@
import QtQuick
import qs.Constants
import qs.Services
Text {
text: TimeService.time + " | " + TimeService.dateString
font.pointSize: Fonts.medium
font.family: Fonts.primary
color: Colors.primary
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: (mouse) => {
if (mouse.button === Qt.LeftButton)
PanelService.getPanel("calendarPanel")?.toggle(this)
else if (mouse.button === Qt.RightButton)
PanelService.getPanel("notificationHistoryPanel")?.toggle(this)
}
}
}

View File

@@ -0,0 +1,61 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import qs.Constants
import qs.Modules.Bar.Misc
Item {
id: root
property ShellScreen screen
implicitHeight: parent.height
implicitWidth: layout.implicitWidth
RowLayout {
id: layout
anchors.top: parent.top
anchors.bottom: parent.bottom
SymbolButton {
symbol: Icons.tray
buttonColor: Colors.green
disabledHover: true
}
Item {
id: trayContainer
implicitHeight: parent.height
implicitWidth: mouseArea.containsMouse ? expandedTray.implicitWidth : 0
clip: true
SystemTray {
id: expandedTray
screen: root.screen
}
Behavior on implicitWidth {
NumberAnimation {
duration: 200
easing.type: Easing.InOutCubic
}
}
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.NoButton
}
}

View File

@@ -0,0 +1,26 @@
import QtQuick
import Quickshell
import qs.Constants
import qs.Modules.Bar.Misc
import qs.Services
MonitorItem {
symbol: AudioService.muted ? Icons.volumeMuted : (AudioService.volume >= 0.5 ? Icons.volumeHigh : (AudioService.volume >= 0.2 ? Icons.volumeMedium : Icons.volumeLow))
fillColor: Colors.lavender
value: Math.round(AudioService.volume * 100)
maxValue: 100
textSuffix: "%"
expandOnValueChange: true
onWheelUp: {
AudioService.increaseVolume();
}
onWheelDown: {
AudioService.decreaseVolume();
}
onClicked: {
AudioService.toggleMute();
}
onRightClicked: {
Quickshell.execDetached(["sh", "-c", "pkill -x -n pavucontrol || pavucontrol"]);
}
}

View File

@@ -0,0 +1,299 @@
import Qt5Compat.GraphicalEffects
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Window
import Quickshell
import Quickshell.Io
import qs.Constants
import qs.Services
Item {
id: root
required property ShellScreen screen
property bool hovered: false
property ListModel localWorkspaces
property real masterProgress: 0
property bool effectsActive: false
property color effectColor: Colors.primary
property int horizontalPadding: 16
property int spacingBetweenPills: 8
property bool isDestroying: false
signal workspaceChanged(int workspaceId, color primaryColor)
function triggerUnifiedWave() {
effectColor = Colors.primary;
masterAnimation.restart();
}
function updateWorkspaceFocus() {
for (let i = 0; i < localWorkspaces.count; i++) {
const ws = localWorkspaces.get(i);
if (ws.isFocused === true) {
root.triggerUnifiedWave();
root.workspaceChanged(ws.id, Colors.primary);
break;
}
}
}
implicitWidth: {
let total = 0;
for (let i = 0; i < localWorkspaces.count; i++) {
const ws = localWorkspaces.get(i);
if (ws.isFocused)
total += 44;
else if (ws.isActive)
total += 28;
else
total += 16;
}
total += Math.max(localWorkspaces.count - 1, 0) * spacingBetweenPills;
total += horizontalPadding * 2;
return total;
}
height: parent.height
Component.onCompleted: {
localWorkspaces.clear();
for (let i = 0; i < WorkspaceManager.workspaces.count; i++) {
const ws = WorkspaceManager.workspaces.get(i);
if (ws.output.toLowerCase() === screen.name.toLowerCase())
localWorkspaces.append(ws);
}
workspaceRepeater.model = localWorkspaces;
updateWorkspaceFocus();
}
Component.onDestruction: {
root.isDestroying = true;
}
Connections {
function onWorkspacesChanged() {
localWorkspaces.clear();
for (let i = 0; i < WorkspaceManager.workspaces.count; i++) {
const ws = WorkspaceManager.workspaces.get(i);
if (ws.output.toLowerCase() === screen.name.toLowerCase())
localWorkspaces.append(ws);
}
workspaceRepeater.model = localWorkspaces;
updateWorkspaceFocus();
}
target: WorkspaceManager
}
SequentialAnimation {
id: masterAnimation
PropertyAction {
target: root
property: "effectsActive"
value: true
}
NumberAnimation {
target: root
property: "masterProgress"
from: 0
to: 1
duration: 1000
easing.type: Easing.OutQuint
}
PropertyAction {
target: root
property: "effectsActive"
value: false
}
PropertyAction {
target: root
property: "masterProgress"
value: 0
}
}
Rectangle {
id: workspaceBackground
width: parent.width - 15
height: 26
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
radius: 12
color: Colors.transparent
layer.enabled: true
layer.effect: DropShadow {
color: "black"
radius: 12
samples: 24
verticalOffset: 0
horizontalOffset: 0
opacity: 0.1
}
}
Row {
id: pillRow
spacing: spacingBetweenPills
anchors.verticalCenter: workspaceBackground.verticalCenter
width: root.width - horizontalPadding * 2
x: horizontalPadding
Repeater {
id: workspaceRepeater
model: localWorkspaces
Item {
id: workspacePillContainer
height: 12
width: {
if (model.isFocused)
return 44;
else if (model.isActive)
return 28;
else
return 16;
}
Rectangle {
// half of focused height (if you want to animate this too)
id: workspacePill
anchors.fill: parent
radius: {
if (model.isFocused)
return 12;
else
return 6;
}
color: {
if (model.isFocused)
return Colors.primary;
if (model.isActive)
return Colors.primary.lighter(130);
if (model.isUrgent)
return Theme.error;
return Colors.surface2;
}
scale: model.isFocused ? 1 : 0.9
z: 0
MouseArea {
id: pillMouseArea
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
WorkspaceManager.switchToWorkspace(model.idx);
}
z: 20
hoverEnabled: true
}
// Material 3-inspired smooth animation for width, height, scale, color, opacity, and radius
Behavior on width {
NumberAnimation {
duration: 350
easing.type: Easing.OutBack
}
}
Behavior on height {
NumberAnimation {
duration: 350
easing.type: Easing.OutBack
}
}
Behavior on scale {
NumberAnimation {
duration: 300
easing.type: Easing.OutBack
}
}
Behavior on color {
ColorAnimation {
duration: 200
easing.type: Easing.InOutCubic
}
}
Behavior on opacity {
NumberAnimation {
duration: 200
easing.type: Easing.InOutCubic
}
}
Behavior on radius {
NumberAnimation {
duration: 350
easing.type: Easing.OutBack
}
}
}
Rectangle {
id: pillBurst
anchors.centerIn: workspacePillContainer
width: workspacePillContainer.width + 18 * root.masterProgress
height: workspacePillContainer.height + 18 * root.masterProgress
radius: width / 2
color: "transparent"
border.color: root.effectColor
border.width: 2 + 6 * (1 - root.masterProgress)
opacity: root.effectsActive && model.isFocused ? (1 - root.masterProgress) * 0.7 : 0
visible: root.effectsActive && model.isFocused
z: 1
}
Behavior on width {
NumberAnimation {
duration: 350
easing.type: Easing.OutBack
}
}
Behavior on height {
NumberAnimation {
duration: 350
easing.type: Easing.OutBack
}
}
}
}
}
localWorkspaces: ListModel {
}
}

View File

@@ -0,0 +1,193 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import qs.Constants
Item {
id: root
required property string symbol
property real maxValue: 100
property real value: 100
property string textValue: "" // override value in textDisplay if set
property color fillColor: Colors.primary
property string textSuffix: ""
property bool pointerCursor: true
property bool expandOnValueChange: false
property real hideTimeOut: 2000 // ms
property bool forceExpand: false
property bool _expand: forceExpand || mouseArea.containsMouse
property bool _isFirst: true
property bool disableHover: false
property bool critical: false
property color criticalColor: Colors.red
readonly property real ratio: value / maxValue
property color realColor: critical ? criticalColor : fillColor
signal wheelUp()
signal wheelDown()
signal clicked()
signal rightClicked()
implicitHeight: parent.height - 5
implicitWidth: parent.height + (_expand ? textDisplay.width : 0)
Loader {
id: connectionLoader
active: expandOnValueChange
sourceComponent: Connections {
function onValueChanged() {
// No need to expand (again) if already hovering
if (mouseArea.containsMouse)
return ;
// Skip first change (which is most likely initialization)
if (root._isFirst) {
root._isFirst = false;
return ;
}
root.forceExpand = true;
hideTimer.restart();
}
target: root
}
}
Timer {
id: hideTimer
interval: parent.hideTimeOut
running: false
repeat: false
onTriggered: {
root.forceExpand = false;
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: !disableHover
acceptedButtons: Qt.LeftButton | Qt.RightButton
cursorShape: pointerCursor ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: (mouse) => {
if (mouse.button === Qt.LeftButton)
root.clicked();
else if (mouse.button === Qt.RightButton)
root.rightClicked();
}
onWheel: (wheel) => {
if (wheel.angleDelta.y > 0)
root.wheelUp();
else if (wheel.angleDelta.y < 0)
root.wheelDown();
}
}
RowLayout {
anchors.top: parent.top
anchors.bottom: parent.bottom
spacing: 0
Item {
id: progressDisplay
Layout.preferredHeight: parent.height
Layout.preferredWidth: parent.height
Canvas {
id: progressCircle
anchors.fill: parent
anchors.centerIn: parent
onPaint: {
var ctx = getContext("2d");
ctx.reset();
var centerX = width / 2;
var centerY = height / 2;
var radius = width / 2 - 3;
var startAngle = -Math.PI / 2;
var endAngle = startAngle - (2 * Math.PI * root.ratio);
ctx.beginPath();
ctx.arc(centerX, centerY, radius, endAngle, startAngle, false);
ctx.lineWidth = 3;
ctx.strokeStyle = root.realColor;
ctx.lineCap = "round";
ctx.stroke();
}
Connections {
function onRatioChanged() {
progressCircle.requestPaint();
}
function onRealColorChanged() {
progressCircle.requestPaint();
}
target: root
}
}
Text {
id: symbolText
anchors.fill: parent
text: symbol
font.family: Fonts.nerd
font.pointSize: Fonts.icon
color: root.realColor
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
Item {
id: textDisplay
implicitHeight: parent.height
implicitWidth: root._expand ? textLabel.implicitWidth + 10 : 0
clip: true
Text {
id: textLabel
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: 5
text: (textValue || Math.round(root.value)) + root.textSuffix
font.pointSize: Fonts.small
font.family: Fonts.primary
color: root.realColor
opacity: root._expand ? 1 : 0
}
Behavior on implicitWidth {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.InOutCubic
}
}
}
}
Behavior on realColor {
ColorAnimation {
duration: Style.animationNormal
easing.type: Easing.InOutCubic
}
}
}

View File

@@ -0,0 +1,64 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Constants
Item {
id: root
required property string symbol
property color buttonColor: Colors.distroColor
readonly property alias hovered: mouseArea.containsMouse
property real iconSize: Fonts.icon
property real radius: Style.radiusS
property bool disabledHover: false
signal clicked()
signal rightClicked()
implicitHeight: parent.height
implicitWidth: parent.height
MouseArea {
id: mouseArea
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
hoverEnabled: !disabledHover
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: (mouse) => {
if (mouse.button === Qt.RightButton)
root.rightClicked();
else if (mouse.button === Qt.LeftButton)
root.clicked();
}
}
Text {
anchors.fill: parent
text: symbol
font.family: Fonts.nerd
font.pointSize: iconSize
font.bold: false
color: buttonColor
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
Rectangle {
anchors.fill: parent
color: parent.hovered ? buttonColor : Colors.transparent
opacity: 0.3
radius: root.radius
Behavior on color {
ColorAnimation {
duration: Style.animationNormal
easing.type: Easing.InOutCubic
}
}
}
}

View File

@@ -0,0 +1,160 @@
import QtQuick
import QtQuick.Effects
import QtQuick.Layouts
import QtQuick.Controls
import Quickshell
import Quickshell.Services.SystemTray
import Quickshell.Widgets
import qs.Modules.Bar.Misc
import qs.Constants
import qs.Services
import qs.Utils
Rectangle {
id: root
property ShellScreen screen
implicitWidth: trayFlow.implicitWidth + 20
implicitHeight: parent.height
radius: 0
color: Colors.transparent
Layout.alignment: Qt.AlignVCenter
Flow {
id: trayFlow
anchors.centerIn: parent
spacing: 8
flow: Flow.LeftToRight
Repeater {
id: repeater
model: SystemTray.items
delegate: Item {
width: 18
height: 18
visible: modelData
IconImage {
id: trayIcon
property ShellScreen screen: root.screen
anchors.centerIn: parent
width: 14
height: 14
smooth: false
asynchronous: true
backer.fillMode: Image.PreserveAspectFit
source: {
let icon = modelData?.icon || ""
if (!icon) {
return ""
}
// Process icon path
if (icon.includes("?path=")) {
// Seems qmlfmt does not support the following ES6 syntax: const[name, path] = icon.split
const chunks = icon.split("?path=")
const name = chunks[0]
const path = chunks[1]
const fileName = name.substring(name.lastIndexOf("/") + 1)
return `file://${path}/${fileName}`
}
return icon
}
opacity: status === Image.Ready ? 1 : 0
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
onClicked: mouse => {
if (!modelData) {
return
}
if (mouse.button === Qt.LeftButton) {
// Close any open menu first
trayPanel.close()
if (!modelData.onlyMenu) {
modelData.activate()
}
} else if (mouse.button === Qt.MiddleButton) {
// Close any open menu first
trayPanel.close()
modelData.secondaryActivate && modelData.secondaryActivate()
} else if (mouse.button === Qt.RightButton) {
// Close the menu if it was visible
if (trayPanel && trayPanel.visible) {
trayPanel.close()
return
}
if (modelData.hasMenu && modelData.menu && trayMenu.item) {
trayPanel.open()
// Position menu based on bar position
let menuX, menuY
// For horizontal bars: center horizontally and position below
menuX = (width / 2) - (trayMenu.item.width / 2)
menuY = root.height
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")
}
}
}
onEntered: {
trayPanel.close()
}
}
}
}
}
PanelWindow {
id: trayPanel
anchors.top: true
anchors.left: true
anchors.right: true
anchors.bottom: true
visible: false
color: Colors.transparent
screen: root.screen
function open() {
visible = true
PanelService.willOpenPanel(trayPanel)
}
function close() {
visible = false
if (trayMenu.item) {
trayMenu.item.hideMenu()
}
}
// Clicking outside of the rectangle to close
MouseArea {
anchors.fill: parent
onClicked: trayPanel.close()
}
Loader {
id: trayMenu
Component.onCompleted: {
setSource("../Misc/TrayMenu.qml", {
"screen": root.screen
})
}
}
}
}

View File

@@ -0,0 +1,253 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import qs.Constants
import qs.Utils
import qs.Noctalia
PopupWindow {
id: root
property QsMenuHandle menu
property var anchorItem: null
property real anchorX
property real anchorY
property bool isSubMenu: false
property bool isHovered: rootMouseArea.containsMouse
property ShellScreen screen
readonly property int menuWidth: 180
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 + (Style.marginS * 2))
visible: false
color: Colors.transparent
anchor.item: anchorItem
anchor.rect.x: anchorX
anchor.rect.y: anchorY - (isSubMenu ? 0 : 4)
function showAt(item, x, y) {
if (!item) {
Logger.warn("TrayMenu", "AnchorItem is undefined, won't show menu.");
return
}
if (!opener.children || opener.children.values.length === 0) {
Qt.callLater(() => showAt(item, x, y))
return
}
anchorItem = item
anchorX = x
anchorY = y
visible = true
forceActiveFocus()
// Force update after showing.
Qt.callLater(() => {
root.anchor.updateAnchor()
})
}
function hideMenu() {
visible = false
// Clean up all submenus recursively
for (var i = 0; i < columnLayout.children.length; i++) {
const child = columnLayout.children[i]
if (child?.subMenu) {
child.subMenu.hideMenu()
child.subMenu.destroy()
child.subMenu = null
}
}
}
// Full-sized, transparent MouseArea to track the mouse.
MouseArea {
id: rootMouseArea
anchors.fill: parent
hoverEnabled: true
}
Item {
anchors.fill: parent
Keys.onEscapePressed: root.hideMenu()
}
QsMenuOpener {
id: opener
menu: root.menu
}
Rectangle {
anchors.fill: parent
color: Colors.base
border.color: Colors.primary
border.width: 2
radius: Style.radiusM
}
Flickable {
id: flickable
anchors.fill: parent
anchors.margins: Style.marginS
contentHeight: columnLayout.implicitHeight
interactive: true
// Use a ColumnLayout to handle menu item arrangement
ColumnLayout {
id: columnLayout
width: flickable.width
spacing: 0
Repeater {
model: opener.children ? [...opener.children.values] : []
delegate: Rectangle {
id: entry
required property var modelData
Layout.preferredWidth: parent.width
Layout.preferredHeight: {
if (modelData?.isSeparator) {
return 8
} else {
// Calculate based on text content
const textHeight = text.contentHeight || (Style.fontSizeS * 1.2)
return Math.max(28, textHeight + (Style.marginS * 2))
}
}
color: Colors.transparent
property var subMenu: null
NDivider {
anchors.centerIn: parent
width: parent.width - (Style.marginM * 2)
visible: modelData?.isSeparator ?? false
}
Rectangle {
anchors.fill: parent
color: mouseArea.containsMouse ? Colors.primary : Colors.transparent
radius: Style.radiusS
visible: !(modelData?.isSeparator ?? false)
RowLayout {
anchors.fill: parent
anchors.leftMargin: Style.marginM
anchors.rightMargin: Style.marginM
spacing: Style.marginS
NText {
id: text
Layout.fillWidth: true
color: (modelData?.enabled ?? true) ? (mouseArea.containsMouse ? Color.mOnTertiary : Color.mOnSurface) : Color.mOnSurfaceVariant
text: modelData?.text !== "" ? modelData?.text.replace(/[\n\r]+/g, ' ') : "..."
pointSize: Style.fontSizeS
verticalAlignment: Text.AlignVCenter
wrapMode: Text.WordWrap
family: Fonts.sans
}
Image {
Layout.preferredWidth: Style.marginL
Layout.preferredHeight: Style.marginL
source: modelData?.icon ?? ""
visible: (modelData?.icon ?? "") !== ""
fillMode: Image.PreserveAspectFit
}
NIcon {
icon: modelData?.hasChildren ? "menu" : ""
pointSize: Style.fontSizeS
verticalAlignment: Text.AlignVCenter
visible: modelData?.hasChildren ?? false
color: (mouseArea.containsMouse ? Color.mOnTertiary : Color.mOnSurface)
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
enabled: (modelData?.enabled ?? true) && !(modelData?.isSeparator ?? false) && root.visible
onClicked: {
if (modelData && !modelData.isSeparator && !modelData.hasChildren) {
modelData.triggered()
root.hideMenu()
}
}
onEntered: {
if (!root.visible)
return
// Close all sibling submenus
for (var i = 0; i < columnLayout.children.length; i++) {
const sibling = columnLayout.children[i]
if (sibling !== entry && sibling?.subMenu) {
sibling.subMenu.hideMenu()
sibling.subMenu.destroy()
sibling.subMenu = null
}
}
// Create submenu if needed
if (modelData?.hasChildren) {
if (entry.subMenu) {
entry.subMenu.hideMenu()
entry.subMenu.destroy()
}
// Need a slight overlap so that menu don't close when moving the mouse to a submenu
const submenuWidth = menuWidth // Assuming a similar width as the parent
const overlap = 4 // A small overlap to bridge the mouse path
// Position with overlap
const anchorX = -submenuWidth + overlap
// Create submenu
entry.subMenu = Qt.createComponent("TrayMenu.qml").createObject(root, {
"menu": modelData,
"anchorItem": entry,
"anchorX": anchorX,
"anchorY": 0,
"isSubMenu": true,
"screen": root.screen
})
if (entry.subMenu) {
entry.subMenu.showAt(entry, anchorX, 0)
}
}
}
onExited: {
Qt.callLater(() => {
if (entry.subMenu && !entry.subMenu.isHovered) {
entry.subMenu.hideMenu()
entry.subMenu.destroy()
entry.subMenu = null
}
})
}
}
}
Component.onDestruction: {
if (subMenu) {
subMenu.destroy()
subMenu = null
}
}
}
}
}
}
}

View File

@@ -0,0 +1,87 @@
import QtQuick
import QtQuick.Shapes
import qs.Constants
Shape {
id: root
property string position: "topleft" // Corner position: topleft/topright/bottomleft/bottomright
property real size: 1 // Scale multiplier for entire corner
property int concaveWidth: 100 * size
property int concaveHeight: 60 * size
property int offsetX: -20
property int offsetY: -20
property color fillColor: Colors.base
property int arcRadius: 20 * size
property var modelData: null
// Position flags derived from position string
property bool _isTop: position.includes("top")
property bool _isLeft: position.includes("left")
property bool _isRight: position.includes("right")
property bool _isBottom: position.includes("bottom")
// Shift the path vertically if offsetY is negative to pull shape up
property real pathOffsetY: Math.min(offsetY, 0)
// Base coordinates for left corner shape, shifted by pathOffsetY vertically
property real _baseStartX: 30 * size
property real _baseStartY: (_isTop ? 20 * size : 0) + pathOffsetY
property real _baseLineX: 30 * size
property real _baseLineY: (_isTop ? 0 : 20 * size) + pathOffsetY
property real _baseArcX: 50 * size
property real _baseArcY: (_isTop ? 20 * size : 0) + pathOffsetY
// Mirror coordinates for right corners
property real _startX: _isRight ? (concaveWidth - _baseStartX) : _baseStartX
property real _startY: _baseStartY
property real _lineX: _isRight ? (concaveWidth - _baseLineX) : _baseLineX
property real _lineY: _baseLineY
property real _arcX: _isRight ? (concaveWidth - _baseArcX) : _baseArcX
property real _arcY: _baseArcY
// Arc direction varies by corner to maintain proper concave shape
property int _arcDirection: {
if (_isTop && _isLeft)
return PathArc.Counterclockwise;
if (_isTop && _isRight)
return PathArc.Clockwise;
if (_isBottom && _isLeft)
return PathArc.Clockwise;
if (_isBottom && _isRight)
return PathArc.Counterclockwise;
return PathArc.Counterclockwise;
}
width: concaveWidth
height: concaveHeight
// Position relative to parent based on corner type
x: _isLeft ? offsetX : (parent ? parent.width - width + offsetX : 0)
y: _isTop ? offsetY : (parent ? parent.height - height + offsetY : 0)
preferredRendererType: Shape.CurveRenderer
layer.enabled: true
layer.samples: 4
ShapePath {
strokeWidth: 0
fillColor: root.fillColor
strokeColor: root.fillColor
startX: root._startX
startY: root._startY
PathLine {
x: root._lineX
y: root._lineY
}
PathArc {
x: root._arcX
y: root._arcY
radiusX: root.arcRadius
radiusY: root.arcRadius
useLargeArc: false
direction: root._arcDirection
}
}
}

View File

@@ -0,0 +1,178 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Wayland
import qs.Constants
import qs.Modules.Misc
import qs.Services
Scope {
id: rootScope
property var shell
property string namespace: "quickshell-corners"
property int topMargin: 45
property int cornerHeight: 20
property real cornerSize: 1
property real opacity: Niri.noFocus ? 0 : 1
Item {
id: cornersRootItem
anchors.fill: parent
Variants {
model: Quickshell.screens
Item {
property var modelData
PanelWindow {
id: fakeBar
anchors.top: true
anchors.left: true
anchors.right: true
color: "transparent"
screen: modelData
WlrLayershell.exclusionMode: ExclusionMode.Ignore
visible: true
WlrLayershell.layer: WlrLayer.Background
aboveWindows: false
WlrLayershell.namespace: namespace
implicitHeight: topMargin
Rectangle {
anchors.fill: parent
color: Colors.base
opacity: rootScope.opacity
}
}
PanelWindow {
id: topLeftPanel
anchors.top: true
anchors.left: true
color: "transparent"
screen: modelData
margins.top: topMargin
WlrLayershell.exclusionMode: ExclusionMode.Ignore
visible: true
WlrLayershell.layer: WlrLayer.Background
aboveWindows: false
WlrLayershell.namespace: namespace
implicitHeight: cornerHeight
Corner {
id: topLeftCorner
position: "bottomleft"
size: rootScope.cornerSize
offsetX: -32
offsetY: 0
anchors.top: parent.top
opacity: rootScope.opacity
}
}
PanelWindow {
id: topRightPanel
anchors.top: true
anchors.right: true
color: "transparent"
screen: modelData
margins.top: topMargin
WlrLayershell.exclusionMode: ExclusionMode.Ignore
visible: true
WlrLayershell.layer: WlrLayer.Background
aboveWindows: false
WlrLayershell.namespace: namespace
implicitHeight: cornerHeight
Corner {
id: topRightCorner
position: "bottomright"
size: rootScope.cornerSize
offsetX: 32
offsetY: 0
anchors.top: parent.top
opacity: rootScope.opacity
}
}
PanelWindow {
id: bottomLeftPanel
anchors.bottom: true
anchors.left: true
color: "transparent"
screen: modelData
WlrLayershell.exclusionMode: ExclusionMode.Ignore
visible: true
WlrLayershell.layer: WlrLayer.Background
aboveWindows: false
WlrLayershell.namespace: namespace
implicitHeight: cornerHeight
Corner {
id: bottomLeftCorner
position: "topleft"
size: rootScope.cornerSize
offsetX: -32
offsetY: 0
anchors.top: parent.top
opacity: rootScope.opacity
}
}
PanelWindow {
id: bottomRightPanel
anchors.bottom: true
anchors.right: true
color: "transparent"
screen: modelData
WlrLayershell.exclusionMode: ExclusionMode.Ignore
visible: true
WlrLayershell.layer: WlrLayer.Background
aboveWindows: false
WlrLayershell.namespace: namespace
implicitHeight: cornerHeight
Corner {
id: bottomRightCorner
position: "topright"
size: rootScope.cornerSize
offsetX: 32
offsetY: 0
anchors.top: parent.top
opacity: rootScope.opacity
}
}
}
}
}
Behavior on opacity {
NumberAnimation {
duration: 1000
easing.type: Easing.InOutCubic
}
}
}

View File

@@ -0,0 +1,439 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Services.Notifications
import Quickshell.Wayland
import Quickshell.Widgets
import qs.Constants
import qs.Noctalia
import qs.Services
import qs.Utils
// Simple notification popup - displays multiple notifications
Variants {
// Force removal without animation as fallback
// If no notification display activated in settings, then show them all
model: Quickshell.screens
delegate: Loader {
id: root
required property ShellScreen modelData
property real scaling: 1
// Access the notification model from the service
property ListModel notificationModel: NotificationService.activeList
// Loader is active when there are notifications
active: notificationModel.count > 0 || delayTimer.running
// Keep loader active briefly after last notification to allow animations to complete
Timer {
id: delayTimer
interval: Style.animationSlow + 200 // Animation duration + buffer
repeat: false
}
// Start delay timer when last notification is removed
Connections {
function onCountChanged() {
if (notificationModel.count === 0 && root.active)
delayTimer.restart();
}
target: notificationModel
}
sourceComponent: PanelWindow {
readonly property string location: "top_right"
readonly property bool isTop: (location === "top") || (location.length >= 3 && location.substring(0, 3) === "top")
readonly property bool isBottom: (location === "bottom") || (location.length >= 6 && location.substring(0, 6) === "bottom")
readonly property bool isLeft: location.indexOf("_left") >= 0
readonly property bool isRight: location.indexOf("_right") >= 0
readonly property bool isCentered: (location === "top" || location === "bottom")
// Store connection for cleanup
property var animateConnection: null
screen: modelData
WlrLayershell.namespace: "noctalia-notifications"
WlrLayershell.layer: WlrLayer.Overlay
color: Color.transparent
// Anchor selection based on location (window edges)
anchors.top: isTop
anchors.bottom: isBottom
anchors.left: isLeft
anchors.right: isRight
// Margins depending on bar position and chosen location
margins.top: Style.barHeight + Style.marginM
margins.bottom: 0
margins.left: 0
margins.right: Style.marginM
implicitWidth: 360
implicitHeight: notificationStack.implicitHeight
WlrLayershell.exclusionMode: ExclusionMode.Ignore
// Connect to animation signal from service
Component.onCompleted: {
animateConnection = NotificationService.animateAndRemove.connect(function(notificationId) {
// Find the delegate by notification ID
var delegate = null;
if (notificationStack && notificationStack.children && notificationStack.children.length > 0) {
for (var i = 0; i < notificationStack.children.length; i++) {
var child = notificationStack.children[i];
if (child && child.notificationId === notificationId) {
delegate = child;
break;
}
}
}
if (delegate && delegate.animateOut)
delegate.animateOut();
else
NotificationService.dismissActiveNotification(notificationId);
});
}
// Disconnect when destroyed to prevent memory leaks
Component.onDestruction: {
if (animateConnection) {
NotificationService.animateAndRemove.disconnect(animateConnection);
animateConnection = null;
}
}
// Main notification container
ColumnLayout {
id: notificationStack
// Anchor the stack inside the window based on chosen location
anchors.top: parent.isTop ? parent.top : undefined
anchors.bottom: parent.isBottom ? parent.bottom : undefined
anchors.left: parent.isLeft ? parent.left : undefined
anchors.right: parent.isRight ? parent.right : undefined
anchors.horizontalCenter: parent.isCentered ? parent.horizontalCenter : undefined
spacing: Style.marginS
width: 360
visible: true
// Multiple notifications display
Repeater {
model: notificationModel
delegate: Rectangle {
id: card
// Store the notification ID and data for reference
property string notificationId: model.id
property var notificationData: model
// Animation properties
property real scaleValue: 0.8
property real opacityValue: 0
property bool isRemoving: false
// Animate out when being removed
function animateOut() {
if (isRemoving)
return ;
// Prevent multiple animations
isRemoving = true;
scaleValue = 0.8;
opacityValue = 0;
}
Layout.preferredWidth: 360
Layout.preferredHeight: notificationLayout.implicitHeight + (Style.marginL * 2)
Layout.maximumHeight: Layout.preferredHeight
radius: Style.radiusL
border.color: Colors.overlay0
border.width: Math.max(1, Style.borderS)
color: Color.mSurface
// Scale and fade-in animation
scale: scaleValue
opacity: opacityValue
// Animate in when the item is created
Component.onCompleted: {
scaleValue = 1;
opacityValue = 1;
}
// Check if this notification is being removed
onIsRemovingChanged: {
if (isRemoving)
removalTimer.start();
}
// Optimized progress bar container
Rectangle {
id: progressBarContainer
// Pre-calculate available width for the progress bar
readonly property real availableWidth: parent.width - (2 * parent.radius)
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
height: 2
color: Color.transparent
// Actual progress bar - centered and symmetric
Rectangle {
id: progressBar
height: parent.height
// Center the bar and make it shrink symmetrically
x: parent.parent.radius + (parent.availableWidth * (1 - model.progress)) / 2
width: parent.availableWidth * model.progress
color: {
if (model.urgency === NotificationUrgency.Critical || model.urgency === 2)
return Colors.red;
else if (model.urgency === NotificationUrgency.Low || model.urgency === 0)
return Colors.green;
else
return Colors.primary;
}
antialiasing: true
// Smooth progress animation
Behavior on width {
enabled: !card.isRemoving // Disable during removal animation
NumberAnimation {
duration: 100 // Quick but smooth
easing.type: Easing.Linear
}
}
Behavior on x {
enabled: !card.isRemoving
NumberAnimation {
duration: 100
easing.type: Easing.Linear
}
}
}
}
// Right-click to dismiss
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.RightButton
onClicked: (mouse) => {
if (mouse.button === Qt.RightButton)
animateOut();
}
}
// Timer for delayed removal after animation
Timer {
id: removalTimer
interval: Style.animationSlow
repeat: false
onTriggered: {
NotificationService.dismissActiveNotification(notificationId);
}
}
ColumnLayout {
id: notificationLayout
anchors.fill: parent
anchors.margins: Style.marginM
anchors.rightMargin: (Style.marginM + 32) // Leave space for close button
spacing: Style.marginM
// Main content section
RowLayout {
Layout.fillWidth: true
spacing: Style.marginM
ColumnLayout {
// For real-time notification always show the original image
// as the cached version is most likely still processing.
NImageCircled {
Layout.preferredWidth: 40
Layout.preferredHeight: 40
Layout.alignment: Qt.AlignTop
Layout.topMargin: 30
imagePath: model.originalImage || ""
borderColor: Color.transparent
borderWidth: 0
fallbackIcon: "bell"
fallbackIconSize: 24
}
Item {
Layout.fillHeight: true
}
}
// Text content
ColumnLayout {
Layout.fillWidth: true
spacing: Style.marginS
// Header section with app name and timestamp
RowLayout {
Layout.fillWidth: true
spacing: Style.marginS
Rectangle {
Layout.preferredWidth: 6
Layout.preferredHeight: 6
radius: Style.radiusXS
color: {
if (model.urgency === NotificationUrgency.Critical || model.urgency === 2)
return Color.mError;
else if (model.urgency === NotificationUrgency.Low || model.urgency === 0)
return Color.mOnSurface;
else
return Color.mPrimary;
}
Layout.alignment: Qt.AlignVCenter
}
NText {
text: `${model.appName || I18n.tr("system.unknown-app")} · ${Time.formatRelativeTime(model.timestamp)}`
color: Color.mSecondary
pointSize: Style.fontSizeXS
family: Fonts.sans
}
Item {
Layout.fillWidth: true
}
}
NText {
text: model.summary || I18n.tr("general.no-summary")
pointSize: Style.fontSizeL
font.weight: Style.fontWeightMedium
color: Color.mOnSurface
textFormat: Text.PlainText
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
Layout.fillWidth: true
maximumLineCount: 3
elide: Text.ElideRight
family: Fonts.sans
visible: text.length > 0
}
NText {
text: model.body || ""
pointSize: Style.fontSizeM
color: Color.mOnSurface
textFormat: Text.PlainText
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
Layout.fillWidth: true
maximumLineCount: 5
elide: Text.ElideRight
family: Fonts.sans
visible: text.length > 0
}
// Notification actions
Flow {
// Store the notification ID for access in button delegates
property string parentNotificationId: notificationId
// Parse actions from JSON string
property var parsedActions: {
try {
return model.actionsJson ? JSON.parse(model.actionsJson) : [];
} catch (e) {
return [];
}
}
Layout.fillWidth: true
spacing: Style.marginS
Layout.topMargin: Style.marginM
flow: Flow.LeftToRight
layoutDirection: Qt.LeftToRight
visible: parsedActions.length > 0
Repeater {
model: parent.parsedActions
delegate: NButton {
property var actionData: modelData
text: {
var actionText = actionData.text || "Open";
// If text contains comma, take the part after the comma (the display text)
if (actionText.includes(","))
return actionText.split(",")[1] || actionText;
return actionText;
}
fontFamily: Fonts.sans
fontSize: Style.fontSizeS
backgroundColor: Color.mPrimary
textColor: hovered ? Color.mOnTertiary : Color.mOnPrimary
hoverColor: Color.mTertiary
outlined: false
implicitHeight: 24
onClicked: {
NotificationService.invokeAction(parent.parentNotificationId, actionData.identifier);
}
}
}
}
}
}
}
// Close button positioned absolutely
NIconButton {
icon: "close"
baseSize: Style.baseWidgetSize * 0.6
anchors.top: parent.top
anchors.topMargin: Style.marginM
anchors.right: parent.right
anchors.rightMargin: Style.marginM
onClicked: {
animateOut();
}
}
// Animation behaviors
Behavior on scale {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.OutExpo
}
}
Behavior on opacity {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.OutQuad
}
}
}
}
}
}
}
}

View File

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

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,87 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Services.Pipewire
import qs.Constants
import qs.Modules.Panel.Cards
import qs.Noctalia
import qs.Services
import qs.Utils
NPanel {
id: root
// Positioning
readonly property string controlCenterPosition: "top_left"
property real topCardHeight: 120
property real middleCardHeight: 100
property real bottomCardHeight: 200
preferredWidth: 480
preferredHeight: topCardHeight + middleCardHeight + bottomCardHeight + Style.marginL * 4
panelKeyboardFocus: false
panelAnchorHorizontalCenter: controlCenterPosition !== "close_to_bar_button" && controlCenterPosition.endsWith("_center")
panelAnchorVerticalCenter: false
panelAnchorLeft: controlCenterPosition !== "close_to_bar_button" && controlCenterPosition.endsWith("_left")
panelAnchorRight: controlCenterPosition !== "close_to_bar_button" && controlCenterPosition.endsWith("_right")
panelAnchorBottom: controlCenterPosition !== "close_to_bar_button" && controlCenterPosition.startsWith("bottom_")
panelAnchorTop: controlCenterPosition !== "close_to_bar_button" && controlCenterPosition.startsWith("top_")
panelContent: Item {
id: content
property real cardSpacing: Style.marginL
// Layout content
ColumnLayout {
id: layout
anchors.fill: parent
anchors.margins: content.cardSpacing
spacing: content.cardSpacing
// Top Card: profile + utilities
RowLayout {
Layout.fillWidth: true
Layout.preferredHeight: topCardHeight
TopLeftCard {
Layout.fillWidth: true
Layout.maximumHeight: topCardHeight
}
LyricsControl {
Layout.preferredHeight: topCardHeight
}
}
LyricsCard {
Layout.fillWidth: true
Layout.preferredHeight: middleCardHeight
}
// Media + stats column
RowLayout {
Layout.fillWidth: true
Layout.preferredHeight: bottomCardHeight
spacing: content.cardSpacing
SystemMonitorCard {
Layout.fillWidth: true
Layout.preferredHeight: bottomCardHeight
}
MediaCard {
Layout.preferredWidth: 270
Layout.preferredHeight: bottomCardHeight
}
}
}
}
}

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,341 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Services.Notifications
import Quickshell.Wayland
import qs.Constants
import qs.Noctalia
import qs.Services
import qs.Utils
// Notification History panel
NPanel {
id: root
preferredWidth: 380
preferredHeight: 480
panelContent: Rectangle {
id: notificationRect
color: Color.transparent
ColumnLayout {
anchors.fill: parent
anchors.margins: Style.marginL
spacing: Style.marginM
// Header section
RowLayout {
Layout.fillWidth: true
spacing: Style.marginM
NIcon {
icon: "bell"
pointSize: Style.fontSizeXXL
color: Color.mPrimary
}
NText {
text: "Notifications"
pointSize: Style.fontSizeL
font.weight: Style.fontWeightBold
color: Color.mOnSurface
Layout.fillWidth: true
}
NIconButton {
icon: SettingsService.notifications.doNotDisturb ? "bell-off" : "bell"
baseSize: Style.baseWidgetSize * 0.8
onClicked: SettingsService.notifications.doNotDisturb = !SettingsService.notifications.doNotDisturb
colorFg: SettingsService.notifications.doNotDisturb ? Colors.base : Colors.green
colorBg: SettingsService.notifications.doNotDisturb ? Colors.green : Color.transparent
colorFgHover: Colors.base
colorBgHover: Colors.green
}
NIconButton {
icon: "trash"
baseSize: Style.baseWidgetSize * 0.8
onClicked: {
NotificationService.clearHistory();
// Close panel as there is nothing more to see.
root.close();
}
colorFg: Colors.red
colorBg: Color.transparent
colorFgHover: Colors.base
colorBgHover: Colors.red
}
NIconButton {
icon: "close"
baseSize: Style.baseWidgetSize * 0.8
onClicked: root.close()
colorFg: Colors.blue
colorBg: Color.transparent
colorFgHover: Colors.base
colorBgHover: Colors.blue
}
}
NDivider {
Layout.fillWidth: true
}
// Empty state when no notifications
ColumnLayout {
Layout.fillWidth: true
Layout.fillHeight: true
Layout.alignment: Qt.AlignHCenter
visible: NotificationService.historyList.count === 0
spacing: Style.marginL
Item {
Layout.fillHeight: true
}
NIcon {
icon: "bell-off"
pointSize: 64
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
NText {
text: "No Notifications"
pointSize: Style.fontSizeL
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
Item {
Layout.fillHeight: true
}
}
// Notification list
NListView {
id: notificationList
// Track which notification is expanded
property string expandedId: ""
Layout.fillWidth: true
Layout.fillHeight: true
horizontalPolicy: ScrollBar.AlwaysOff
verticalPolicy: ScrollBar.AsNeeded
model: NotificationService.historyList
spacing: Style.marginM
clip: true
boundsBehavior: Flickable.StopAtBounds
visible: NotificationService.historyList.count > 0
delegate: NBox {
property string notificationId: model.id
property bool isExpanded: notificationList.expandedId === notificationId
width: notificationList.width
height: notificationLayout.implicitHeight + (Style.marginM * 2)
// Click to expand/collapse
MouseArea {
anchors.fill: parent
// Don't capture clicks on the delete button
anchors.rightMargin: 48
enabled: (summaryText.truncated || bodyText.truncated)
onClicked: {
if (notificationList.expandedId === notificationId)
notificationList.expandedId = "";
else
notificationList.expandedId = notificationId;
}
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
}
RowLayout {
id: notificationLayout
anchors.fill: parent
anchors.margins: Style.marginM
spacing: Style.marginM
ColumnLayout {
NImageCircled {
Layout.preferredWidth: 40
Layout.preferredHeight: 40
Layout.alignment: Qt.AlignTop
Layout.topMargin: 20
imagePath: model.cachedImage || model.originalImage || ""
borderColor: Color.transparent
borderWidth: 0
fallbackIcon: "bell"
fallbackIconSize: 24
}
Item {
Layout.fillHeight: true
}
}
// Notification content column
ColumnLayout {
Layout.fillWidth: true
Layout.alignment: Qt.AlignTop
spacing: Style.marginXS
Layout.rightMargin: -(Style.marginM + Style.baseWidgetSize * 0.6)
// Header row with app name and timestamp
RowLayout {
Layout.fillWidth: true
spacing: Style.marginS
// Urgency indicator
Rectangle {
Layout.preferredWidth: 6
Layout.preferredHeight: 6
Layout.alignment: Qt.AlignVCenter
radius: 3
visible: model.urgency !== 1
color: {
if (model.urgency === 2)
return Color.mError;
else if (model.urgency === 0)
return Color.mOnSurfaceVariant;
else
return Color.transparent;
}
}
NText {
text: model.appName || "Unknown App"
pointSize: Style.fontSizeXS
color: Color.mSecondary
family: Fonts.sans
}
NText {
text: Time.formatRelativeTime(model.timestamp)
pointSize: Style.fontSizeXS
color: Color.mSecondary
family: Fonts.sans
}
Item {
Layout.fillWidth: true
}
}
// Summary
NText {
id: summaryText
text: model.summary || "No Summary"
pointSize: Style.fontSizeM
font.weight: Font.Medium
color: Color.mOnSurface
textFormat: Text.PlainText
wrapMode: Text.Wrap
Layout.fillWidth: true
maximumLineCount: isExpanded ? 999 : 2
family: Fonts.sans
elide: Text.ElideRight
}
// Body
NText {
id: bodyText
text: model.body || ""
pointSize: Style.fontSizeS
color: Color.mOnSurfaceVariant
textFormat: Text.PlainText
wrapMode: Text.Wrap
Layout.fillWidth: true
maximumLineCount: isExpanded ? 999 : 3
elide: Text.ElideRight
family: Fonts.sans
visible: text.length > 0
}
// Spacer for expand indicator
Item {
Layout.fillWidth: true
Layout.preferredHeight: (!isExpanded && (summaryText.truncated || bodyText.truncated)) ? (Style.marginS) : 0
}
// Expand indicator
RowLayout {
Layout.fillWidth: true
visible: !isExpanded && (summaryText.truncated || bodyText.truncated)
spacing: Style.marginXS
Item {
Layout.fillWidth: true
}
NText {
text: "Click to expand"
pointSize: Style.fontSizeXS
color: Color.mPrimary
family: Fonts.sans
font.weight: Font.Medium
}
NIcon {
icon: "chevron-down"
pointSize: Style.fontSizeS
color: Color.mPrimary
}
}
}
// Delete button
NIconButton {
icon: "trash"
baseSize: Style.baseWidgetSize * 0.7
Layout.alignment: Qt.AlignTop
onClicked: {
// Remove from history using the service API
NotificationService.removeFromHistory(notificationId);
}
colorFg: Colors.red
colorBg: Color.transparent
colorFgHover: Colors.base
colorBgHover: Colors.red
}
}
Behavior on height {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.InOutQuad
}
}
// Smooth color transition on hover
Behavior on color {
ColorAnimation {
duration: Style.animationFast
}
}
}
}
}
}
}

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,183 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Constants
import qs.Services
Rectangle {
id: root
// Public properties
property string text: ""
property string icon: ""
property string tooltipText
property color backgroundColor: Color.mPrimary
property color textColor: Color.mOnPrimary
property color hoverColor: Color.mTertiary
property bool enabled: true
property real fontSize: Style.fontSizeM * scaling
property int fontWeight: Style.fontWeightBold
property string fontFamily: Fonts.primary
property real iconSize: Style.fontSizeL * scaling
property bool outlined: false
// Internal properties
property bool hovered: false
property bool pressed: false
// Signals
signal clicked()
signal rightClicked()
signal middleClicked()
// Dimensions
implicitWidth: contentRow.implicitWidth + (Style.marginL * 2 * scaling)
implicitHeight: Math.max(Style.baseWidgetSize * scaling, contentRow.implicitHeight + (Style.marginM * scaling))
// Appearance
radius: Style.radiusS * scaling
color: {
if (!enabled)
return outlined ? Color.transparent : Qt.lighter(Color.mSurfaceVariant, 1.2);
if (hovered)
return hoverColor;
return outlined ? Color.transparent : backgroundColor;
}
border.width: outlined ? Math.max(1, Style.borderS * scaling) : 0
border.color: {
if (!enabled)
return Color.mOutline;
if (pressed || hovered)
return backgroundColor;
return outlined ? backgroundColor : Color.transparent;
}
opacity: enabled ? 1 : 0.6
// Content
RowLayout {
id: contentRow
anchors.centerIn: parent
spacing: Style.marginXS * scaling
// Icon (optional)
NIcon {
Layout.alignment: Qt.AlignVCenter
visible: root.icon !== ""
icon: root.icon
pointSize: root.iconSize
color: {
if (!root.enabled)
return Color.mOnSurfaceVariant;
if (root.outlined) {
if (root.pressed || root.hovered)
return root.backgroundColor;
return root.backgroundColor;
}
return root.textColor;
}
Behavior on color {
ColorAnimation {
duration: Style.animationFast
easing.type: Easing.OutCubic
}
}
}
// Text
NText {
Layout.alignment: Qt.AlignVCenter
visible: root.text !== ""
text: root.text
pointSize: root.fontSize
font.weight: root.fontWeight
family: root.fontFamily
color: {
if (!root.enabled)
return Color.mOnSurfaceVariant;
if (root.outlined) {
if (root.hovered)
return root.textColor;
return root.backgroundColor;
}
return root.textColor;
}
Behavior on color {
ColorAnimation {
duration: Style.animationFast
easing.type: Easing.OutCubic
}
}
}
}
// Mouse interaction
MouseArea {
id: mouseArea
anchors.fill: parent
enabled: root.enabled
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
cursorShape: root.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onEntered: {
root.hovered = true;
if (tooltipText)
TooltipService.show(Screen, root, root.tooltipText);
}
onExited: {
root.hovered = false;
if (tooltipText)
TooltipService.hide();
}
onPressed: (mouse) => {
if (tooltipText)
TooltipService.hide();
if (mouse.button === Qt.LeftButton)
root.clicked();
else if (mouse.button == Qt.RightButton)
root.rightClicked();
else if (mouse.button == Qt.MiddleButton)
root.middleClicked();
}
onCanceled: {
root.hovered = false;
if (tooltipText)
TooltipService.hide();
}
}
Behavior on color {
ColorAnimation {
duration: Style.animationFast
easing.type: Easing.OutCubic
}
}
Behavior on border.color {
ColorAnimation {
duration: Style.animationFast
easing.type: Easing.OutCubic
}
}
}

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

@@ -0,0 +1,146 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.Pipewire
import qs.Utils
Singleton {
id: root
readonly property var nodes: Pipewire.nodes.values.reduce((acc, node) => {
if (!node.isStream) {
if (node.isSink) {
acc.sinks.push(node)
} else if (node.audio) {
acc.sources.push(node)
}
}
return acc
}, {
"sources": [],
"sinks": []
})
readonly property PwNode sink: Pipewire.defaultAudioSink
readonly property PwNode source: Pipewire.defaultAudioSource
readonly property list<PwNode> sinks: nodes.sinks
readonly property list<PwNode> sources: nodes.sources
// Volume [0..1] is readonly from outside
readonly property alias volume: root._volume
property real _volume: sink?.audio?.volume ?? 0
readonly property alias muted: root._muted
property bool _muted: !!sink?.audio?.muted
// Input volume [0..1] is readonly from outside
readonly property alias inputVolume: root._inputVolume
property real _inputVolume: source?.audio?.volume ?? 0
readonly property alias inputMuted: root._inputMuted
property bool _inputMuted: !!source?.audio?.muted
readonly property real stepVolume: 5 / 100.0
PwObjectTracker {
objects: [...root.sinks, ...root.sources]
}
Connections {
target: sink?.audio ? sink?.audio : null
function onVolumeChanged() {
var vol = (sink?.audio.volume ?? 0)
if (isNaN(vol)) {
vol = 0
}
root._volume = vol
}
function onMutedChanged() {
root._muted = (sink?.audio.muted ?? true)
Logger.log("AudioService", "OnMuteChanged:", root._muted)
}
}
Connections {
target: source?.audio ? source?.audio : null
function onVolumeChanged() {
var vol = (source?.audio.volume ?? 0)
if (isNaN(vol)) {
vol = 0
}
root._inputVolume = vol
}
function onMutedChanged() {
root._inputMuted = (source?.audio.muted ?? true)
Logger.log("AudioService", "OnInputMuteChanged:", root._inputMuted)
}
}
function increaseVolume() {
setVolume(volume + stepVolume)
}
function decreaseVolume() {
setVolume(volume - stepVolume)
}
function setVolume(newVolume: real) {
if (sink?.ready && sink?.audio) {
// Clamp it accordingly
sink.audio.muted = false
sink.audio.volume = Math.max(0, Math.min(1.0, newVolume))
} else {
Logger.warn("AudioService", "No sink available")
}
}
function setOutputMuted(muted: bool) {
if (sink?.ready && sink?.audio) {
sink.audio.muted = muted
} else {
Logger.warn("AudioService", "No sink available")
}
}
function setInputVolume(newVolume: real) {
if (source?.ready && source?.audio) {
// Clamp it accordingly
source.audio.muted = false
source.audio.volume = Math.max(0, Math.min(1.0, newVolume))
} else {
Logger.warn("AudioService", "No source available")
}
}
function setInputMuted(muted: bool) {
if (source?.ready && source?.audio) {
source.audio.muted = muted
} else {
Logger.warn("AudioService", "No source available")
}
}
function setAudioSink(newSink: PwNode): void {
Pipewire.preferredDefaultAudioSink = newSink
// Volume is changed by the sink change
root._volume = newSink?.audio?.volume ?? 0
root._muted = !!newSink?.audio?.muted
}
function setAudioSource(newSource: PwNode): void {
Pipewire.preferredDefaultAudioSource = newSource
// Volume is changed by the source change
root._inputVolume = newSource?.audio?.volume ?? 0
root._inputMuted = !!newSource?.audio?.muted
}
function toggleMute() {
setOutputMuted(!muted)
}
}

View File

@@ -0,0 +1,308 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Utils
Singleton {
id: root
property list<var> ddcMonitors: []
readonly property list<Monitor> monitors: variants.instances
property bool appleDisplayPresent: false
function getMonitorForScreen(screen: ShellScreen): var {
return monitors.find(m => m.modelData === screen)
}
// Signal emitted when a specific monitor's brightness changes, includes monitor context
signal monitorBrightnessChanged(var monitor, real newBrightness)
function getAvailableMethods(): list<string> {
var methods = []
if (monitors.some(m => m.isDdc))
methods.push("ddcutil")
if (monitors.some(m => !m.isDdc))
methods.push("internal")
if (appleDisplayPresent)
methods.push("apple")
return methods
}
// Global helpers for IPC and shortcuts
function increaseBrightness(): void {
monitors.forEach(m => m.increaseBrightness())
}
function decreaseBrightness(): void {
monitors.forEach(m => m.decreaseBrightness())
}
function getDetectedDisplays(): list<var> {
return detectedDisplays
}
reloadableId: "brightness"
Component.onCompleted: {
Logger.log("Brightness", "Service started")
}
onMonitorsChanged: {
ddcMonitors = []
ddcProc.running = true
}
Variants {
id: variants
model: Quickshell.screens
Monitor {}
}
// Check for Apple Display support
Process {
running: true
command: ["sh", "-c", "which asdbctl >/dev/null 2>&1 && asdbctl get || echo ''"]
stdout: StdioCollector {
onStreamFinished: root.appleDisplayPresent = text.trim().length > 0
}
}
// Detect DDC monitors
Process {
id: ddcProc
property list<var> ddcMonitors: []
command: ["ddcutil", "detect", "--sleep-multiplier=0.5"]
stdout: StdioCollector {
onStreamFinished: {
var displays = text.trim().split("\n\n")
ddcProc.ddcMonitors = displays.map(d => {
var ddcModelMatch = d.match(/(This monitor does not support DDC\/CI|Invalid display)/)
var modelMatch = d.match(/Model:\s*(.*)/)
var busMatch = d.match(/I2C bus:[ ]*\/dev\/i2c-([0-9]+)/)
var ddcModel = ddcModelMatch ? ddcModelMatch.length > 0 : false
var model = modelMatch ? modelMatch[1] : "Unknown"
var bus = busMatch ? busMatch[1] : "Unknown"
Logger.log("Brigthness", "Detected DDC Monitor:", model, "on bus", bus, "is DDC:", !ddcModel)
return {
"model": model,
"busNum": bus,
"isDdc": !ddcModel
}
})
root.ddcMonitors = ddcProc.ddcMonitors.filter(m => m.isDdc)
}
}
}
component Monitor: QtObject {
id: monitor
required property ShellScreen modelData
readonly property bool isDdc: root.ddcMonitors.some(m => m.model === modelData.model)
readonly property string busNum: root.ddcMonitors.find(m => m.model === modelData.model)?.busNum ?? ""
readonly property bool isAppleDisplay: root.appleDisplayPresent && modelData.model.startsWith("StudioDisplay")
readonly property string method: isAppleDisplay ? "apple" : (isDdc ? "ddcutil" : "internal")
property real brightness
property real lastBrightness: 0
property real queuedBrightness: NaN
// For internal displays - store the backlight device path
property string backlightDevice: ""
property string brightnessPath: ""
property string maxBrightnessPath: ""
property int maxBrightness: 100
property bool ignoreNextChange: false
// Signal for brightness changes
signal brightnessUpdated(real newBrightness)
// Execute a system command to get the current brightness value directly
readonly property Process refreshProc: Process {
stdout: StdioCollector {
onStreamFinished: {
var dataText = text.trim()
if (dataText === "") {
return
}
var lines = dataText.split("\n")
if (lines.length >= 2) {
var current = parseInt(lines[0].trim())
var max = parseInt(lines[1].trim())
if (!isNaN(current) && !isNaN(max) && max > 0) {
var newBrightness = current / max
// Only update if it's actually different (avoid feedback loops)
if (Math.abs(newBrightness - monitor.brightness) > 0.01) {
// Update internal value to match system state
monitor.brightness = newBrightness
monitor.brightnessUpdated(monitor.brightness)
root.monitorBrightnessChanged(monitor, monitor.brightness)
}
}
}
}
}
}
// Function to actively refresh the brightness from system
function refreshBrightnessFromSystem() {
if (!monitor.isDdc && !monitor.isAppleDisplay) {
// For internal displays, query the system directly
refreshProc.command = ["sh", "-c", "cat " + monitor.brightnessPath + " && " + "cat " + monitor.maxBrightnessPath]
refreshProc.running = true
} else if (monitor.isDdc) {
// For DDC displays, get the current value
refreshProc.command = ["ddcutil", "-b", monitor.busNum, "getvcp", "10", "--brief"]
refreshProc.running = true
} else if (monitor.isAppleDisplay) {
// For Apple displays, get the current value
refreshProc.command = ["asdbctl", "get"]
refreshProc.running = true
}
}
// FileView to watch for external brightness changes (internal displays only)
readonly property FileView brightnessWatcher: FileView {
id: brightnessWatcher
// Only set path for internal displays with a valid brightness path
path: (!monitor.isDdc && !monitor.isAppleDisplay && monitor.brightnessPath !== "") ? monitor.brightnessPath : ""
watchChanges: path !== ""
onFileChanged: {
// When a file change is detected, actively refresh from system
// to ensure we get the most up-to-date value
Qt.callLater(() => {
monitor.refreshBrightnessFromSystem()
})
}
}
// Initialize brightness
readonly property Process initProc: Process {
stdout: StdioCollector {
onStreamFinished: {
var dataText = text.trim()
if (dataText === "") {
return
}
if (monitor.isAppleDisplay) {
var val = parseInt(dataText)
if (!isNaN(val)) {
monitor.brightness = val / 101
Logger.log("Brightness", "Apple display brightness:", monitor.brightness)
}
} else if (monitor.isDdc) {
var parts = dataText.split(" ")
if (parts.length >= 4) {
var current = parseInt(parts[3])
var max = parseInt(parts[4])
if (!isNaN(current) && !isNaN(max) && max > 0) {
monitor.brightness = current / max
Logger.log("Brightness", "DDC brightness:", current + "/" + max + " =", monitor.brightness)
}
}
} else {
// Internal backlight - parse the response which includes device path
var lines = dataText.split("\n")
if (lines.length >= 3) {
monitor.backlightDevice = lines[0]
monitor.brightnessPath = monitor.backlightDevice + "/brightness"
monitor.maxBrightnessPath = monitor.backlightDevice + "/max_brightness"
var current = parseInt(lines[1])
var max = parseInt(lines[2])
if (!isNaN(current) && !isNaN(max) && max > 0) {
monitor.maxBrightness = max
monitor.brightness = current / max
Logger.log("Brightness", "Internal brightness:", current + "/" + max + " =", monitor.brightness)
Logger.log("Brightness", "Using backlight device:", monitor.backlightDevice)
}
}
}
// Always update
monitor.brightnessUpdated(monitor.brightness)
root.monitorBrightnessChanged(monitor, monitor.brightness)
}
}
}
readonly property real stepSize: 5.0 / 100.0
// Timer for debouncing rapid changes
readonly property Timer timer: Timer {
interval: 100
onTriggered: {
if (!isNaN(monitor.queuedBrightness)) {
monitor.setBrightness(monitor.queuedBrightness)
monitor.queuedBrightness = NaN
}
}
}
function setBrightnessDebounced(value: real): void {
monitor.queuedBrightness = value
timer.start()
}
function increaseBrightness(): void {
const value = !isNaN(monitor.queuedBrightness) ? monitor.queuedBrightness : monitor.brightness
setBrightnessDebounced(value + stepSize)
}
function decreaseBrightness(): void {
const value = !isNaN(monitor.queuedBrightness) ? monitor.queuedBrightness : monitor.brightness
setBrightnessDebounced(value - stepSize)
}
function setBrightness(value: real): void {
value = Math.max(0, Math.min(1, value))
var rounded = Math.round(value * 100)
if (timer.running) {
monitor.queuedBrightness = value
return
}
// Update internal value and trigger UI feedback
monitor.brightness = value
monitor.brightnessUpdated(value)
root.monitorBrightnessChanged(monitor, monitor.brightness)
if (isAppleDisplay) {
monitor.ignoreNextChange = true
Quickshell.execDetached(["asdbctl", "set", rounded])
} else if (isDdc) {
monitor.ignoreNextChange = true
Quickshell.execDetached(["ddcutil", "-b", busNum, "setvcp", "10", rounded])
} else {
monitor.ignoreNextChange = true
Quickshell.execDetached(["set-brightness", rounded + "%"])
}
if (isDdc) {
timer.restart()
}
}
function initBrightness(): void {
if (isAppleDisplay) {
initProc.command = ["asdbctl", "get"]
} else if (isDdc) {
initProc.command = ["ddcutil", "-b", busNum, "getvcp", "10", "--brief"]
} else {
// Internal backlight - find the first available backlight device and get its info
// This now returns: device_path, current_brightness, max_brightness (on separate lines)
initProc.command = ["sh", "-c", "for dev in /sys/class/backlight/*; do " + " if [ -f \"$dev/brightness\" ] && [ -f \"$dev/max_brightness\" ]; then " + " echo \"$dev\"; " + " cat \"$dev/brightness\"; " + " cat \"$dev/max_brightness\"; " + " break; " + " fi; " + "done"]
}
initProc.running = true
}
onBusNumChanged: initBrightness()
Component.onCompleted: initBrightness()
}
}

View File

@@ -0,0 +1,32 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Utils
pragma Singleton
Singleton {
id: root
property string cacheDir: Quickshell.env("HOME") + "/.cache/quickshell/"
property string recordingDir: Quickshell.env("HOME") + "/Videos/recordings/"
property var cacheFiles: ["Location.json", "Ip.json", "Notifications.json", "LyricsOffset.txt"]
property bool loaded: false
property string locationCacheFile: cacheDir + "Location.json"
property string ipCacheFile: cacheDir + "Ip.json"
property string notificationsCacheFile: cacheDir + "Notifications.json"
property string lyricsOffsetCacheFile: cacheDir + "LyricsOffset.txt"
Process {
id: process
running: true
command: ["sh", "-c", `mkdir -p ${cacheDir} && mkdir -p ${recordingDir} && touch ${cacheDir + cacheFiles.join(` && touch ${cacheDir}`)}`]
onExited: (code, status) => {
if (code === 0)
root.loaded = true;
else
Logger.error("CacheService", `Failed to create cache files: ${command.join(" ")}`);
}
}
}

View File

@@ -0,0 +1,143 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Services
import qs.Utils
pragma Singleton
Singleton {
id: root
property string reason: "Application request"
property bool isInhibited: false
property var activeInhibitors: []
// Different inhibitor strategies
property string strategy: "systemd"
// Auto-detect the best strategy
function detectStrategy() {
if (strategy === "auto") {
// Check if systemd-inhibit is available
try {
var systemdResult = Quickshell.execDetached(["which", "systemd-inhibit"]);
strategy = "systemd";
return ;
} catch (e) {
}
try {
var waylandResult = Quickshell.execDetached(["which", "wayhibitor"]);
strategy = "wayland";
return ;
} catch (e) {
}
strategy = "systemd"; // Fallback to systemd even if not detected
}
}
// Add an inhibitor
function addInhibitor(id, reason = "Application request") {
if (activeInhibitors.includes(id))
return false;
activeInhibitors.push(id);
updateInhibition(reason);
return true;
}
// Remove an inhibitor
function removeInhibitor(id) {
const index = activeInhibitors.indexOf(id);
if (index === -1) {
console.log("Inhibitor not found:", id);
return false;
}
activeInhibitors.splice(index, 1);
updateInhibition();
return true;
}
// Update the actual system inhibition
function updateInhibition(newReason = reason) {
const shouldInhibit = activeInhibitors.length > 0;
if (shouldInhibit === isInhibited)
return ;
// No change needed
if (shouldInhibit)
startInhibition(newReason);
else
stopInhibition();
}
// Start system inhibition
function startInhibition(newReason) {
reason = newReason;
if (strategy === "systemd")
startSystemdInhibition();
else if (strategy === "wayland")
startWaylandInhibition();
else
return ;
isInhibited = true;
}
// Stop system inhibition
function stopInhibition() {
if (!isInhibited)
return ;
// SIGTERM
if (inhibitorProcess.running)
inhibitorProcess.signal(15);
isInhibited = false;
}
// Systemd inhibition using systemd-inhibit
function startSystemdInhibition() {
inhibitorProcess.command = ["systemd-inhibit", "--what=idle", "--why=" + reason, "--mode=block", "sleep", "infinity"];
inhibitorProcess.running = true;
}
// Wayland inhibition using wayhibitor or similar
function startWaylandInhibition() {
inhibitorProcess.command = ["wayhibitor"];
inhibitorProcess.running = true;
}
// Manual toggle for user control
function manualToggle() {
if (activeInhibitors.includes("manual")) {
removeInhibitor("manual");
return false;
} else {
addInhibitor("manual", "Manually activated by user");
return true;
}
}
Component.onCompleted: {
detectStrategy();
}
// Clean up on shutdown
Component.onDestruction: {
stopInhibition();
}
// Process for maintaining the inhibition
Process {
id: inhibitorProcess
running: false
onExited: function(exitCode, exitStatus) {
if (isInhibited)
isInhibited = false;
Logger.log("Caffeine", "Inhibitor process exited with code:", exitCode, "status:", exitStatus);
}
onStarted: function() {
Logger.log("Caffeine", "Inhibitor process started with PID:", inhibitorProcess.processId);
}
}
}

View File

@@ -0,0 +1,51 @@
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"
}
IpcHandler {
function toggleInhibitor() {
Caffeine.manualToggle();
}
target: "idleInhibitor"
}
IpcHandler {
function startOrStopRecording() {
RecordService.startOrStop();
}
target: "recording"
}
}

View File

@@ -0,0 +1,165 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Services
import qs.Utils
pragma Singleton
Singleton {
property alias ip: cacheFileAdapter.ip
property string cacheFilePath: CacheService.ipCacheFile
property string countryCode: "N/A"
property real fetchInterval: 120 // in s
property real fetchTimeout: 10 // in s
property string ipURL: "https://api.uyanide.com/ip"
property string geoURL: "https://api.ipinfo.io/lite/"
property string geoURLToken: ""
function fetchIP() {
curl.fetch(ipURL, function(success, data) {
if (success) {
try {
const response = JSON.parse(data);
if (response && response.ip) {
let newIP = response.ip;
Logger.log("IpService", "Fetched IP: " + newIP);
if (newIP !== ip) {
ip = newIP;
countryCode = "N/A";
fetchGeoInfo(true); // Fetch geo info only if IP has changed
}
} else {
Logger.error("IpService", "IP response does not contain 'ip' field");
}
} catch (e) {
Logger.error("IpService", "Failed to parse IP response: " + e);
}
} else {
Logger.error("IpService", "Failed to fetch IP");
}
});
}
function fetchGeoInfo(notify) {
if (!ip || ip === "N/A") {
countryCode = "N/A";
return ;
}
let url = geoURL + ip;
if (geoURLToken)
url += "?token=" + geoURLToken;
cacheFileAdapter.geoInfo = null
curl.fetch(url, function(success, data) {
if (success) {
try {
const response = JSON.parse(data);
if (response && (response.country_code || response.country)) {
let newCountryCode = response.country_code || response.country;
Logger.log("IpService", "Fetched country code: " + newCountryCode);
countryCode = newCountryCode;
} else {
Logger.error("IpService", "Geo response does not contain 'country_code' field");
}
cacheFileAdapter.geoInfo = response;
} catch (e) {
Logger.error("IpService", "Failed to parse geo response: " + e);
}
} else {
Logger.error("IpService", "Failed to fetch geo info");
}
SendNotification.show("New IP", `IP: ${ip}\nCountry: ${countryCode}`);
cacheFile.writeAdapter();
});
}
function refresh() {
fetchTimer.stop();
ip = "N/A";
fetchIP();
fetchTimer.start();
}
Component.onCompleted: {
}
NetworkFetch {
id: curl
}
Process {
id: ipMonitor
command: ["ip", "monitor", "address", "route"]
running: true
stdout: SplitParser {
splitMarker: "\n"
onRead: {
ipMonitorDebounce.restart();
}
}
}
Timer {
id: ipMonitorDebounce
interval: 1000
repeat: false
running: false
onTriggered: {
fetchIP();
}
}
Timer {
id: fetchTimer
interval: fetchInterval * 1000
repeat: true
running: true
onTriggered: {
fetchTimer.stop();
fetchIP();
fetchTimer.start();
}
}
FileView {
id: tokenFile
path: Qt.resolvedUrl("../Assets/Config/GeoInfoToken.txt")
onLoaded: {
geoURLToken = tokenFile.text();
if (!geoURLToken)
Logger.warn("IpService", "No token found for geoIP service, assuming none is required");
fetchIP();
fetchTimer.start();
}
}
FileView {
id: cacheFile
path: cacheFilePath
watchChanges: false
onLoaded: {
Logger.log("IpService", "Loaded IP from cache file: " + cacheFileAdapter.ip);
if (cacheFileAdapter.geoInfo) {
countryCode = cacheFileAdapter.geoInfo.country_code || cacheFileAdapter.country || "N/A";
Logger.log("IpService", "Loaded country code from cache file: " + countryCode);
}
}
JsonAdapter {
id: cacheFileAdapter
property string ip: "N/A"
property var geoInfo: null
}
}
}

View File

@@ -0,0 +1,318 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Constants
import qs.Services
import qs.Utils
pragma Singleton
// Weather logic and caching with stable UI properties
Singleton {
//console.log(JSON.stringify(weatherData))
id: root
property string locationName: SettingsService.location
property string locationFile: CacheService.locationCacheFile
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";
curl.fetch(geoUrl, function(success, data) {
if (success) {
try {
var geoData = JSON.parse(data);
if (geoData.lat != null)
callback(geoData.lat, geoData.lng, geoData.name, geoData.country);
else
errorCallback("Location", "could not resolve location name");
} catch (e) {
errorCallback("Location", "Failed to parse geocoding data: " + e);
}
} else {
errorCallback("Location", "Geocoding error");
}
});
}
// --------------------------------
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";
curl.fetch(url, function(success, fetchedData) {
if (success) {
try {
var weatherData = JSON.parse(fetchedData);
// Save core data
data.weather = weatherData;
data.weatherLastFetch = Time.timestamp;
// Update stable display values only when complete and successful
root.stableLatitude = data.latitude = weatherData.latitude.toString();
root.stableLongitude = data.longitude = weatherData.longitude.toString();
root.coordinatesReady = true;
isFetchingWeather = false;
Logger.log("Location", "Cached weather to disk - stable coordinates updated");
} catch (e) {
errorCallback("Location", "Failed to parse weather data: " + e);
}
} else {
errorCallback("Location", "Weather fetch error");
}
});
}
// --------------------------------
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()
}
NetworkFetch {
id: curl
}
}

View File

@@ -0,0 +1,141 @@
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: CacheService.lyricsOffsetCacheFile
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}`]);
}
}
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`);
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.error("LyricsService", "Error reading offset file:", e);
}
}
onLoadFailed: {
Logger.error("LyricsService", "Error loading offset file:", errorString);
}
onSaveFailed: {
Logger.error("LyricsService", "Error saving offset file:", errorString);
}
}
}

View File

@@ -0,0 +1,180 @@
import QtQuick
import Quickshell
import Quickshell.Services.Mpris
import qs.Modules.Misc
import qs.Utils
pragma Singleton
Singleton {
id: manager
// Properties
property var currentPlayer: null
property real currentPosition: 0
property bool isPlaying: currentPlayer ? currentPlayer.isPlaying : false
property int selectedPlayerIndex: -1
property string trackTitle: currentPlayer ? (currentPlayer.trackTitle || "Unknown Track") : ""
property string trackArtist: currentPlayer ? (currentPlayer.trackArtist || "Unknown Artist") : ""
property string trackAlbum: currentPlayer ? (currentPlayer.trackAlbum || "Unknown Album") : ""
property string trackArtUrl: currentPlayer ? (currentPlayer.trackArtUrl || "") : ""
property real trackLength: currentPlayer ? currentPlayer.length : 0
property bool canPlay: currentPlayer ? currentPlayer.canPlay : false
property bool canPause: currentPlayer ? currentPlayer.canPause : false
property bool canGoNext: currentPlayer ? currentPlayer.canGoNext : false
property bool canGoPrevious: currentPlayer ? currentPlayer.canGoPrevious : false
property bool canSeek: currentPlayer ? currentPlayer.canSeek : false
property bool hasPlayer: getAvailablePlayers().length > 0
// Expose cava values
property alias cavaValues: cava.values
// Returns available MPRIS players
function getAvailablePlayers() {
if (!Mpris.players || !Mpris.players.values)
return [];
let allPlayers = Mpris.players.values;
let controllablePlayers = [];
for (let i = 0; i < allPlayers.length; i++) {
let player = allPlayers[i];
if (player && player.canControl)
controllablePlayers.push(player);
}
return controllablePlayers;
}
// Returns active player or first available
function findActivePlayer() {
let availablePlayers = getAvailablePlayers();
if (availablePlayers.length === 0)
return null;
// Get the first playing player
for (let i = availablePlayers.length - 1; i >= 0; i--) {
if (availablePlayers[i].isPlaying)
return availablePlayers[i];
}
// Fallback to last player
return availablePlayers[availablePlayers.length - 1];
}
// Updates currentPlayer and currentPosition
function updateCurrentPlayer() {
// Use selected player if index is valid
if (selectedPlayerIndex >= 0) {
let availablePlayers = getAvailablePlayers();
if (selectedPlayerIndex < availablePlayers.length) {
currentPlayer = availablePlayers[selectedPlayerIndex];
currentPosition = currentPlayer.position;
Logger.log("MusicManager", "Current player set by index:", currentPlayer ? currentPlayer.identity : "None");
return ;
} else {
selectedPlayerIndex = -1; // Reset if index is out of range
}
}
// Otherwise, find active player
let newPlayer = findActivePlayer();
if (newPlayer !== currentPlayer) {
currentPlayer = newPlayer;
currentPosition = currentPlayer ? currentPlayer.position : 0;
}
Logger.log("MusicManager", "Current player updated:", currentPlayer ? currentPlayer.identity : "None");
}
// Player control functions
function playPause() {
if (currentPlayer) {
if (currentPlayer.isPlaying)
currentPlayer.pause();
else
currentPlayer.play();
}
}
function isAllPaused() {
let availablePlayers = getAvailablePlayers();
for (let i = 0; i < availablePlayers.length; i++) {
if (availablePlayers[i].isPlaying)
return false;
}
return true;
}
function play() {
if (currentPlayer && currentPlayer.canPlay)
currentPlayer.play();
}
function pause() {
if (currentPlayer && currentPlayer.canPause)
currentPlayer.pause();
}
function next() {
if (currentPlayer && currentPlayer.canGoNext)
currentPlayer.next();
}
function previous() {
if (currentPlayer && currentPlayer.canGoPrevious)
currentPlayer.previous();
}
function seek(position) {
if (currentPlayer && currentPlayer.canSeek) {
currentPlayer.position = position;
currentPosition = position;
}
}
function seekByRatio(ratio) {
if (currentPlayer && currentPlayer.canSeek && currentPlayer.length > 0) {
let seekPosition = ratio * currentPlayer.length;
currentPlayer.position = seekPosition;
currentPosition = seekPosition;
}
}
// Initialize
Item {
Component.onCompleted: {
updateCurrentPlayer();
}
}
// Updates progress bar every second
Timer {
id: positionTimer
interval: 1000
running: currentPlayer && currentPlayer.isPlaying && currentPlayer.length > 0
repeat: true
onTriggered: {
if (currentPlayer && currentPlayer.isPlaying)
currentPosition = currentPlayer.position;
}
}
// Reacts to player list changes
Connections {
function onValuesChanged() {
updateCurrentPlayer();
}
target: Mpris.players
}
Cava {
id: cava
count: 44
}
}

View File

@@ -0,0 +1,65 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Utils
Item {
id: root
property real fetchTimeout: 10 // in seconds
property string fetchedData: ""
property var fetchingCallback: null
function fetch(url, callback) {
if (curlProcess.running) {
Logger.warn("NetworkFetch", "A fetch operation is already in progress.");
return ;
}
fetchedData = "";
fetchingCallback = callback;
curlProcess.command = ["curl", "-s", "-L", "-m", fetchTimeout.toString(), url];
curlProcess.running = true;
}
function fakeFetch(resp, callback) {
if (curlProcess.running) {
Logger.warn("NetworkFetch", "A fetch operation is already in progress.");
return ;
}
fetchedData = "";
fetchingCallback = callback;
curlProcess.command = ["echo", resp];
curlProcess.running = true;
}
Process {
id: curlProcess
running: false
onStarted: {
Logger.log("NetworkFetch", "Process started with command: " + curlProcess.command.join(" "));
}
onExited: function(exitCode, exitStatus) {
if (!fetchingCallback) {
Logger.error("NetworkFetch", "No callback defined for fetch operation.");
return ;
}
if (exitCode === 0) {
Logger.log("NetworkFetch", "Fetched data: " + fetchedData);
fetchingCallback(true, fetchedData);
} else {
Logger.error("NetworkFetch", "Fetch failed with exit code: " + exitCode);
fetchingCallback(false, "");
}
}
stdout: SplitParser {
splitMarker: ""
onRead: (data) => {
fetchedData += data;
}
}
}
}

View File

@@ -0,0 +1,190 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Utils
pragma Singleton
pragma ComponentBehavior: Bound
Singleton {
id: root
property var workspaces: []
property var windows: {}
property int focusedWindowId: -1
property bool noFocus: focusedWindowId === -1
property bool inOverview: false
property string focusedWindowTitle: ""
property string focusedWindowAppId: ""
function updateFocusedWindowTitle() {
if (windows && windows[focusedWindowId]) {
focusedWindowTitle = windows[focusedWindowId].title || "";
focusedWindowAppId = windows[focusedWindowId].appId || "";
} else {
focusedWindowTitle = "";
focusedWindowAppId = "";
}
}
function getFocusedWindow() {
return (windows && windows[focusedWindowId]) || null;
}
Component.onCompleted: {
eventStream.running = true;
}
Process {
id: workspaceProcess
running: false
command: ["niri", "msg", "--json", "workspaces"]
stdout: SplitParser {
onRead: function(line) {
try {
const workspacesData = JSON.parse(line);
const workspacesList = [];
for (const ws of workspacesData) {
workspacesList.push({
"id": ws.id,
"idx": ws.idx,
"name": ws.name || "",
"output": ws.output || "",
"isFocused": ws.is_focused === true,
"isActive": ws.is_active === true,
"isUrgent": ws.is_urgent === true,
"activeWindowId": ws.active_window_id
});
}
workspacesList.sort((a, b) => {
if (a.output !== b.output)
return a.output.localeCompare(b.output);
return a.id - b.id;
});
root.workspaces = workspacesList;
} catch (e) {
Logger.error("Niri", "Failed to parse workspaces:", e, line);
}
}
}
}
Process {
id: eventStream
running: false
command: ["niri", "msg", "--json", "event-stream"]
stdout: SplitParser {
onRead: (data) => {
try {
const event = JSON.parse(data.trim());
if (event.WorkspacesChanged) {
workspaceProcess.running = true;
} else if (event.WindowsChanged) {
try {
const windowsData = event.WindowsChanged.windows;
const windowsMap = {};
for (const win of windowsData) {
if (win.is_focused === true) {
root.focusedWindowId = win.id;
}
windowsMap[win.id] = {
"title": win.title || "",
"appId": win.app_id || "",
"workspaceId": win.workspace_id || null,
"isFocused": win.is_focused === true
};
}
root.windows = windowsMap;
root.updateFocusedWindowTitle();
} catch (e) {
Logger.error("Niri", "Error parsing windows event:", e);
}
} else if (event.WorkspaceActivated) {
workspaceProcess.running = true;
} else if (event.WindowFocusChanged) {
try {
const focusedId = event.WindowFocusChanged.id;
if (focusedId) {
if (root.windows[focusedId]) {
root.focusedWindowId = focusedId;
} else {
root.focusedWindowId = -1;
}
} else {
root.focusedWindowId = -1;
}
root.updateFocusedWindowTitle();
} catch (e) {
Logger.error("Niri", "Error parsing window focus event:", e);
}
} else if (event.OverviewOpenedOrClosed) {
try {
root.inOverview = event.OverviewOpenedOrClosed.is_open === true;
} catch (e) {
Logger.error("Niri", "Error parsing overview state:", e);
}
} else if (event.WindowOpenedOrChanged) {
try {
const targetWin = event.WindowOpenedOrChanged.window;
const id = targetWin.id;
const isFocused = targetWin.is_focused === true;
let needUpdateTitle = false;
if (id) {
if (root.windows && root.windows[id]) {
const win = root.windows[id];
// Update existing window
needUpdateTitle = win.title !== targetWin.title;
win.title = targetWin.title || win.title;
win.appId = targetWin.app_id || win.appId;
win.workspaceId = targetWin.workspace_id || win.workspaceId;
win.isFocused = isFocused;
} else {
// New window
const newWin = {
"title": targetWin.title || "",
"appId": targetWin.app_id || "",
"workspaceId": targetWin.workspace_id || null,
"isFocused": isFocused
};
root.windows[id] = targetWin;
}
if (isFocused) {
if (root.focusedWindowId !== id || needUpdateTitle){
root.focusedWindowId = id;
root.updateFocusedWindowTitle();
}
}
}
} catch (e) {
Logger.error("Niri", "Error parsing window opened/changed event:", e);
}
} else if (event.windowClosed) {
try {
const closedId = event.windowClosed.id;
if (closedId && (root.windows && root.windows[closedId])) {
delete root.windows[closedId];
if (root.focusedWindowId === closedId) {
root.focusedWindowId = -1;
root.updateFocusedWindowTitle();
}
}
} catch (e) {
Logger.error("Niri", "Error parsing window closed event:", e);
}
}
} catch (e) {
Logger.error("Niri", "Error parsing event stream:", e, data);
}
}
}
}
}

View File

@@ -0,0 +1,474 @@
pragma Singleton
import QtQuick
import QtQuick.Window
import Quickshell
import Quickshell.Io
import Quickshell.Services.Notifications
import qs.Utils
import qs.Services
import qs.Constants
import "../Utils/sha256.js" as Checksum
Singleton {
id: root
// Configuration
property int maxVisible: 5
property int maxHistory: 100
property string historyFile: CacheService.notificationsCacheFile
property string cacheDirImagesNotifications: Quickshell.env("HOME") + "/.cache/quickshell/notifications/"
property real lowUrgencyDuration: 3
property real normalUrgencyDuration: 8
property real criticalUrgencyDuration: 15
// Models
property ListModel activeList: ListModel {}
property ListModel historyList: ListModel {}
// Internal state
property var activeMap: ({})
property var imageQueue: []
// Performance optimization: Track notification metadata separately
property var notificationMetadata: ({}) // Stores timestamp and duration for each notification
PanelWindow {
implicitHeight: 1
implicitWidth: 1
color: Color.transparent
mask: Region {}
Image {
id: cacher
width: 64
height: 64
visible: true
cache: false
asynchronous: true
mipmap: true
antialiasing: true
onStatusChanged: {
if (imageQueue.length === 0)
return
const req = imageQueue[0]
if (status === Image.Ready) {
Quickshell.execDetached(["mkdir", "-p", cacheDirImagesNotifications])
grabToImage(result => {
if (result.saveToFile(req.dest))
updateImagePath(req.imageId, req.dest)
processNextImage()
})
} else if (status === Image.Error) {
processNextImage()
}
}
function processNextImage() {
imageQueue.shift()
if (imageQueue.length > 0) {
source = imageQueue[0].src
} else {
source = ""
}
}
}
}
// Notification server
NotificationServer {
keepOnReload: false
imageSupported: true
actionsSupported: true
onNotification: notification => handleNotification(notification)
}
// Main handler
function handleNotification(notification) {
const data = createData(notification)
addToHistory(data)
if (SettingsService.notifications.doNotDisturb)
return
activeMap[data.id] = notification
notification.tracked = true
notification.closed.connect(() => removeActive(data.id))
// Store metadata for efficient progress calculation
const durations = [lowUrgencyDuration * 1000, normalUrgencyDuration * 1000, criticalUrgencyDuration * 1000]
let expire = 0
if (data.expireTimeout === 0) {
expire = -1 // Never expire
} else if (data.expireTimeout > 0) {
expire = data.expireTimeout
} else {
expire = durations[data.urgency]
}
notificationMetadata[data.id] = {
"timestamp": data.timestamp.getTime(),
"duration": expire,
"urgency": data.urgency
}
activeList.insert(0, data)
while (activeList.count > maxVisible) {
const last = activeList.get(activeList.count - 1)
activeMap[last.id]?.dismiss()
activeList.remove(activeList.count - 1)
delete notificationMetadata[last.id]
}
}
function createData(n) {
const time = new Date()
const id = Checksum.sha256(JSON.stringify({
"summary": n.summary,
"body": n.body,
"app": n.appName,
"time": time.getTime()
}))
const image = n.image || getIcon(n.appIcon)
const imageId = generateImageId(n, image)
queueImage(image, imageId)
return {
"id": id,
"summary": (n.summary || ""),
"body": stripTags(n.body || ""),
"appName": getAppName(n.appName),
"urgency": n.urgency < 0 || n.urgency > 2 ? 1 : n.urgency,
"expireTimeout": n.expireTimeout,
"timestamp": time,
"progress": 1.0,
"originalImage": image,
"cachedImage": imageId ? (cacheDirImagesNotifications + imageId + ".png") : image,
"actionsJson": JSON.stringify((n.actions || []).map(a => ({
"text": a.text || "Action",
"identifier": a.identifier || ""
})))
}
}
function queueImage(path, imageId) {
if (!path || !path.startsWith("image://") || !imageId)
return
const dest = cacheDirImagesNotifications + imageId + ".png"
for (const req of imageQueue) {
if (req.imageId === imageId)
return
}
imageQueue.push({
"src": path,
"dest": dest,
"imageId": imageId
})
if (imageQueue.length === 1)
cacher.source = path
}
function updateImagePath(id, path) {
updateModel(activeList, id, "cachedImage", path)
updateModel(historyList, id, "cachedImage", path)
saveHistory()
}
function updateModel(model, id, prop, value) {
for (var i = 0; i < model.count; i++) {
if (model.get(i).id === id) {
model.setProperty(i, prop, value)
break
}
}
}
function removeActive(id) {
for (var i = 0; i < activeList.count; i++) {
if (activeList.get(i).id === id) {
activeList.remove(i)
delete activeMap[id]
delete notificationMetadata[id]
break
}
}
}
// Optimized batch progress update
Timer {
interval: 50 // Reduced from 10ms to 50ms (20 updates/sec instead of 100)
repeat: true
running: activeList.count > 0
onTriggered: updateAllProgress()
}
function updateAllProgress() {
const now = Date.now()
const toRemove = []
const updates = [] // Batch updates
// Collect all updates first
for (var i = 0; i < activeList.count; i++) {
const notif = activeList.get(i)
const meta = notificationMetadata[notif.id]
if (!meta || meta.duration === -1)
continue
// Skip infinite notifications
const elapsed = now - meta.timestamp
const progress = Math.max(1.0 - (elapsed / meta.duration), 0.0)
if (progress <= 0) {
toRemove.push(notif.id)
} else if (Math.abs(notif.progress - progress) > 0.005) {
// Only update if change is significant
updates.push({
"index": i,
"progress": progress
})
}
}
// Apply batch updates
for (const update of updates) {
activeList.setProperty(update.index, "progress", update.progress)
}
// Remove expired notifications (one at a time to allow animation)
if (toRemove.length > 0) {
animateAndRemove(toRemove[0])
}
}
// History management
function addToHistory(data) {
historyList.insert(0, data)
while (historyList.count > maxHistory) {
const old = historyList.get(historyList.count - 1)
if (old.cachedImage && !old.cachedImage.startsWith("image://")) {
Quickshell.execDetached(["rm", "-f", old.cachedImage])
}
historyList.remove(historyList.count - 1)
}
saveHistory()
}
// Persistence
FileView {
id: historyFileView
path: historyFile
printErrors: false
onLoaded: loadHistory()
onLoadFailed: error => {
if (error === 2)
writeAdapter()
}
JsonAdapter {
id: adapter
property var notifications: []
}
}
Timer {
id: saveTimer
interval: 200
onTriggered: performSaveHistory()
}
function saveHistory() {
saveTimer.restart()
}
function performSaveHistory() {
try {
const items = []
for (var i = 0; i < historyList.count; i++) {
const n = historyList.get(i)
const copy = Object.assign({}, n)
copy.timestamp = n.timestamp.getTime()
items.push(copy)
}
adapter.notifications = items
historyFileView.writeAdapter()
} catch (e) {
Logger.error("Notifications", "Save history failed:", e)
}
}
function loadHistory() {
try {
historyList.clear()
for (const item of adapter.notifications || []) {
const time = new Date(item.timestamp)
let cachedImage = item.cachedImage || ""
if (item.originalImage && item.originalImage.startsWith("image://") && !cachedImage) {
const imageId = generateImageId(item, item.originalImage)
if (imageId) {
cachedImage = cacheDirImagesNotifications + imageId + ".png"
}
}
historyList.append({
"id": item.id || "",
"summary": item.summary || "",
"body": item.body || "",
"appName": item.appName || "",
"urgency": item.urgency < 0 || item.urgency > 2 ? 1 : item.urgency,
"timestamp": time,
"originalImage": item.originalImage || "",
"cachedImage": cachedImage
})
}
} catch (e) {
Logger.error("Notifications", "Load failed:", e)
}
}
function getAppName(name) {
if (!name || name.trim() === "")
return "Unknown"
name = name.trim()
if (name.includes(".") && (name.startsWith("com.") || name.startsWith("org.") || name.startsWith("io.") || name.startsWith("net."))) {
const parts = name.split(".")
let appPart = parts[parts.length - 1]
if (!appPart || appPart === "app" || appPart === "desktop") {
appPart = parts[parts.length - 2] || parts[0]
}
if (appPart) {
name = appPart
}
}
if (name.includes(".")) {
const parts = name.split(".")
let displayName = parts[parts.length - 1]
if (!displayName || /^\d+$/.test(displayName)) {
displayName = parts[parts.length - 2] || parts[0]
}
if (displayName) {
displayName = displayName.charAt(0).toUpperCase() + displayName.slice(1)
displayName = displayName.replace(/([a-z])([A-Z])/g, '$1 $2')
displayName = displayName.replace(/app$/i, '').trim()
displayName = displayName.replace(/desktop$/i, '').trim()
displayName = displayName.replace(/flatpak$/i, '').trim()
if (!displayName) {
displayName = parts[parts.length - 1].charAt(0).toUpperCase() + parts[parts.length - 1].slice(1)
}
}
return displayName || name
}
let displayName = name.charAt(0).toUpperCase() + name.slice(1)
displayName = displayName.replace(/([a-z])([A-Z])/g, '$1 $2')
displayName = displayName.replace(/app$/i, '').trim()
displayName = displayName.replace(/desktop$/i, '').trim()
return displayName || name
}
function getIcon(icon) {
if (!icon)
return ""
if (icon.startsWith("/") || icon.startsWith("file://"))
return icon
return ThemeIcons.iconFromName(icon)
}
function stripTags(text) {
return text.replace(/<[^>]*>?/gm, '')
}
function generateImageId(notification, image) {
if (image && image.startsWith("image://")) {
if (image.startsWith("image://qsimage/")) {
const key = (notification.appName || "") + "|" + (notification.summary || "")
return Checksum.sha256(key)
}
return Checksum.sha256(image)
}
return ""
}
// Public API
function dismissActiveNotification(id) {
activeMap[id]?.dismiss()
removeActive(id)
}
function dismissAllActive() {
Object.values(activeMap).forEach(n => n.dismiss())
activeList.clear()
activeMap = {}
notificationMetadata = {}
}
function invokeAction(id, actionId) {
const n = activeMap[id]
if (!n?.actions)
return false
for (const action of n.actions) {
if (action.identifier === actionId && action.invoke) {
action.invoke()
return true
}
}
return false
}
function removeFromHistory(notificationId) {
for (var i = 0; i < historyList.count; i++) {
const notif = historyList.get(i)
if (notif.id === notificationId) {
if (notif.cachedImage && !notif.cachedImage.startsWith("image://")) {
Quickshell.execDetached(["rm", "-f", notif.cachedImage])
}
historyList.remove(i)
saveHistory()
return true
}
}
return false
}
function clearHistory() {
try {
Quickshell.execDetached(["sh", "-c", `rm -rf "${cacheDirImagesNotifications}"*`])
} catch (e) {
Logger.error("Notifications", "Failed to clear cache directory:", e)
}
historyList.clear()
saveHistory()
}
// Signals & connections
signal animateAndRemove(string notificationId)
Connections {
target: SettingsService.notifications
function onDoNotDisturbChanged() {
const enabled = SettingsService.notifications.doNotDisturb
}
}
}

View File

@@ -0,0 +1,23 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Utils
pragma Singleton
Singleton {
property bool done: false
Process {
id: process
running: true
command: ["kquitapp6", "kded6"]
onExited: (code, status) => {
if (code !== 0)
Logger.warn("NukeKded6", `Failed to kill kded6: ${code}`);
done = true;
}
}
}

View File

@@ -0,0 +1,67 @@
pragma Singleton
import Quickshell
Singleton {
id: root
// A ref. to the lockScreen, so it's accessible from anywhere
// This is not a panel...
property var lockScreen: null
// Panels
property var registeredPanels: ({})
property var openedPanel: null
signal willOpen
// Currently opened popups, can have more than one.
// ex: when opening an NIconPicker from a widget setting.
property var openedPopups: []
property bool hasOpenedPopup: false
signal popupChanged
// Register this panel
function registerPanel(panel) {
registeredPanels[panel.objectName] = panel
}
// Returns a panel
function getPanel(name) {
return registeredPanels[name] || null
}
// Check if a panel exists
function hasPanel(name) {
return name in registeredPanels
}
// Helper to keep only one panel open at any time
function willOpenPanel(panel) {
if (openedPanel && openedPanel !== panel) {
openedPanel.close()
}
openedPanel = panel
// emit signal
willOpen()
}
function closedPanel(panel) {
if (openedPanel && openedPanel === panel) {
openedPanel = null
}
}
// Popups
function willOpenPopup(popup) {
openedPopups.push(popup)
hasOpenedPopup = (openedPopups.length !== 0)
popupChanged()
}
function willClosePopup(popup) {
openedPopups = openedPopups.filter(p => p !== popup)
hasOpenedPopup = (openedPopups.length !== 0)
popupChanged()
}
}

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

@@ -0,0 +1,163 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Services
import qs.Utils
pragma Singleton
Singleton {
property string recordingDir: CacheService.recordingDir
property bool isRecording: false
property bool isStopping: false
property string codec: "av1_nvenc"
property string container: "mkv"
property string pixelFormat: "p010le"
property string recordingDisplay: ""
property int framerate: 60
property var codecParams: ["preset=p5", "rc=vbr", "cq=18", "b:v=80M", "maxrate=120M", "bufsize=160M", "color_range=tv"]
property var filterArgs: []
function getFilename() {
var d = new Date();
var year = d.getFullYear();
var month = ("0" + (d.getMonth() + 1)).slice(-2);
var day = ("0" + d.getDate()).slice(-2);
var hours = ("0" + d.getHours()).slice(-2);
var minutes = ("0" + d.getMinutes()).slice(-2);
var seconds = ("0" + d.getSeconds()).slice(-2);
return "recording_" + year + "-" + month + "-" + day + "_" + hours + "." + minutes + "." + seconds + "." + container;
}
function getAudioSink() {
return AudioService.sink ? AudioService.sink.name + '.monitor' : null; // this works on my machine :)
}
function getVideoSource(callback) {
if (niriFocusedOutputProcess.running) {
Logger.warn("RecordService", "Already fetching focused output, returning null.");
callback(null);
}
niriFocusedOutputProcess.onGetName = callback;
niriFocusedOutputProcess.running = true;
}
function startOrStop() {
if (isRecording)
stop();
else
start();
}
function stop() {
if (!isRecording) {
Logger.warn("RecordService", "Not currently recording, cannot stop.");
return ;
}
if (isStopping) {
Logger.warn("RecordService", "Already stopping, please wait.");
return ;
}
isStopping = true;
recordProcess.signal(15);
}
function start() {
if (isRecording || isStopping) {
Logger.warn("RecordService", "Already recording, cannot start.");
return ;
}
isRecording = true;
getVideoSource((source) => {
if (!source) {
SendNotification.show("Recording failed", "Could not determine which display to record from.");
return ;
}
recordingDisplay = source;
const audioSink = getAudioSink();
if (!audioSink) {
SendNotification.show("Recording failed", "No audio sink available to record from.");
return ;
}
recordProcess.filePath = recordingDir + getFilename();
recordProcess.command = ["wf-recorder", "--audio=" + audioSink, "-o", source, "--codec", codec, "--pixel-format", pixelFormat, "--framerate", framerate.toString(), "-f", recordProcess.filePath];
for (const param of codecParams) {
recordProcess.command.push("-p");
recordProcess.command.push(param);
}
for (const filter of filterArgs) {
recordProcess.command.push("-F");
recordProcess.command.push(filter);
}
Logger.log("RecordService", "Starting recording with command: " + recordProcess.command.join(" "));
recordProcess.onErrorExit = function() {
SendNotification.show("Recording failed", "An error occurred while trying to record the screen.");
};
recordProcess.onNormalExit = function() {
SendNotification.show("Recording stopped", recordProcess.filePath);
};
recordProcess.running = true;
SendNotification.show("Recording started", "Recording to " + recordProcess.filePath);
});
}
Process {
id: recordProcess
property string filePath: ""
property var onNormalExit: null
property var onErrorExit: null
running: false
onExited: function(exitCode, exitStatus) {
if (exitCode === 0) {
Logger.log("RecordService", "Recording stopped successfully.");
if (onNormalExit) {
onNormalExit();
onNormalExit = null;
}
} else {
Logger.error("RecordService", "Recording process exited with error code: " + exitCode);
if (onErrorExit) {
onErrorExit();
onErrorExit = null;
}
}
isRecording = false;
isStopping = false;
recordingDisplay = "";
}
}
Process {
id: niriFocusedOutputProcess
property var onGetName: null
running: false
command: ["niri", "msg", "focused-output"]
onExited: function(exitCode, exitStatus) {
if (exitCode !== 0) {
Logger.error("RecordService", "Failed to get focused output via niri.");
if (niriFocusedOutputProcess.onGetName) {
niriFocusedOutputProcess.onGetName(null);
niriFocusedOutputProcess.onGetName = null;
}
}
}
stdout: SplitParser {
splitMarker: "\n"
onRead: (data) => {
if (niriFocusedOutputProcess.onGetName) {
const parts = data.split(' ');
const name = parts.length > 0 ? parts[parts.length - 1].slice(1)?.slice(0, -1) : null;
name ? Logger.log("RecordService", "Focused output is: " + name) : Logger.warn("RecordService", "No focused output found.");
niriFocusedOutputProcess.onGetName(name);
niriFocusedOutputProcess.onGetName = null;
}
}
}
}
}

View File

@@ -0,0 +1,16 @@
import QtQuick
import Quickshell
import Quickshell.Io
pragma Singleton
Singleton {
id: root
function show(title, message, icon = "", urgency = "normal") {
if (icon)
Quickshell.execDetached(["notify-send", "-u", urgency, "-i", icon, title, message, "-a", "quickshell"]);
else
Quickshell.execDetached(["notify-send", "-u", urgency, title, message, "-a", "quickshell"]);
}
}

View File

@@ -0,0 +1,39 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Constants
import qs.Services
pragma Singleton
Singleton {
property alias primaryColor: adapter.primaryColor
property alias showLyricsBar: adapter.showLyricsBar
property alias notifications: adapter.notifications
property alias location: adapter.location
property string settingsFilePath: Qt.resolvedUrl("../Assets/Config/Settings.json")
FileView {
id: settingsFile
path: settingsFilePath
watchChanges: true
onFileChanged: reload()
onAdapterUpdated: writeAdapter()
JsonAdapter {
id: adapter
property string primaryColor: "#89b4fa"
property bool showLyricsBar: false
property JsonObject notifications
property string location: "New York"
notifications: JsonObject {
property bool doNotDisturb: false
}
}
}
}

View File

@@ -0,0 +1,392 @@
import Qt.labs.folderlistmodel
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Utils
pragma Singleton
Singleton {
// For Intel coretemp, start averaging all available sensors/cores
id: root
// Public values
property real cpuUsage: 0
property real cpuTemp: 0
property real memGb: 0
property real memPercent: 0
property real diskPercent: 0
property real rxSpeed: 0
property real txSpeed: 0
// Configuration
property int sleepDuration: 3000
property int fasterSleepDuration: 1000
// Internal state for CPU calculation
property var prevCpuStats: null
// Internal state for network speed calculation
// Previous Bytes need to be stored as 'real' as they represent the total of bytes transfered
// since the computer started, so their value will easily overlfow a 32bit int.
property real prevRxBytes: 0
property real prevTxBytes: 0
property real prevTime: 0
// Cpu temperature is the most complex
readonly property var supportedTempCpuSensorNames: ["coretemp", "k10temp", "zenpower"]
property string cpuTempSensorName: ""
property string cpuTempHwmonPath: ""
// For Intel coretemp averaging of all cores/sensors
property var intelTempValues: []
property int intelTempFilesChecked: 0
property int intelTempMaxFiles: 20 // Will test up to temp20_input
// -------------------------------------------------------
// -------------------------------------------------------
// Parse memory info from /proc/meminfo
function parseMemoryInfo(text) {
if (!text)
return ;
const lines = text.split('\n');
let memTotal = 0;
let memAvailable = 0;
for (const line of lines) {
if (line.startsWith('MemTotal:'))
memTotal = parseInt(line.split(/\s+/)[1]) || 0;
else if (line.startsWith('MemAvailable:'))
memAvailable = parseInt(line.split(/\s+/)[1]) || 0;
}
if (memTotal > 0) {
const usageKb = memTotal - memAvailable;
root.memGb = (usageKb / 1e+06).toFixed(1);
root.memPercent = Math.round((usageKb / memTotal) * 100);
}
}
// -------------------------------------------------------
// Calculate CPU usage from /proc/stat
function calculateCpuUsage(text) {
if (!text)
return ;
const lines = text.split('\n');
const cpuLine = lines[0];
// First line is total CPU
if (!cpuLine.startsWith('cpu '))
return ;
const parts = cpuLine.split(/\s+/);
const stats = {
"user": parseInt(parts[1]) || 0,
"nice": parseInt(parts[2]) || 0,
"system": parseInt(parts[3]) || 0,
"idle": parseInt(parts[4]) || 0,
"iowait": parseInt(parts[5]) || 0,
"irq": parseInt(parts[6]) || 0,
"softirq": parseInt(parts[7]) || 0,
"steal": parseInt(parts[8]) || 0,
"guest": parseInt(parts[9]) || 0,
"guestNice": parseInt(parts[10]) || 0
};
const totalIdle = stats.idle + stats.iowait;
const total = Object.values(stats).reduce((sum, val) => {
return sum + val;
}, 0);
if (root.prevCpuStats) {
const prevTotalIdle = root.prevCpuStats.idle + root.prevCpuStats.iowait;
const prevTotal = Object.values(root.prevCpuStats).reduce((sum, val) => {
return sum + val;
}, 0);
const diffTotal = total - prevTotal;
const diffIdle = totalIdle - prevTotalIdle;
if (diffTotal > 0)
root.cpuUsage = (((diffTotal - diffIdle) / diffTotal) * 100).toFixed(1);
}
root.prevCpuStats = stats;
}
// -------------------------------------------------------
// Calculate RX and TX speed from /proc/net/dev
// Average speed of all interfaces excepted 'lo'
function calculateNetworkSpeed(text) {
if (!text)
return ;
const currentTime = Date.now() / 1000;
const lines = text.split('\n');
let totalRx = 0;
let totalTx = 0;
for (var i = 2; i < lines.length; i++) {
const line = lines[i].trim();
if (!line)
continue;
const colonIndex = line.indexOf(':');
if (colonIndex === -1)
continue;
const iface = line.substring(0, colonIndex).trim();
if (iface === 'lo')
continue;
const statsLine = line.substring(colonIndex + 1).trim();
const stats = statsLine.split(/\s+/);
const rxBytes = parseInt(stats[0], 10) || 0;
const txBytes = parseInt(stats[8], 10) || 0;
totalRx += rxBytes;
totalTx += txBytes;
}
// Compute only if we have a previous run to compare to.
if (root.prevTime > 0) {
const timeDiff = currentTime - root.prevTime;
// Avoid division by zero if time hasn't passed.
if (timeDiff > 0) {
let rxDiff = totalRx - root.prevRxBytes;
let txDiff = totalTx - root.prevTxBytes;
// Handle counter resets (e.g., WiFi reconnect), which would cause a negative value.
if (rxDiff < 0)
rxDiff = 0;
if (txDiff < 0)
txDiff = 0;
root.rxSpeed = Math.round(rxDiff / timeDiff); // Speed in Bytes/s
root.txSpeed = Math.round(txDiff / timeDiff);
}
}
root.prevRxBytes = totalRx;
root.prevTxBytes = totalTx;
root.prevTime = currentTime;
}
// -------------------------------------------------------
// Helper function to format network speeds
function formatSpeed(bytesPerSecond) {
if (bytesPerSecond < 1024 * 1024) {
const kb = bytesPerSecond / 1024;
if (kb < 10)
return kb.toFixed(1) + "KB";
else
return Math.round(kb) + "KB";
} else if (bytesPerSecond < 1024 * 1024 * 1024) {
return (bytesPerSecond / (1024 * 1024)).toFixed(1) + "MB";
} else {
return (bytesPerSecond / (1024 * 1024 * 1024)).toFixed(1) + "GB";
}
}
// -------------------------------------------------------
// Compact speed formatter for vertical bar display
function formatCompactSpeed(bytesPerSecond) {
if (!bytesPerSecond || bytesPerSecond <= 0)
return "0";
const units = ["", "K", "M", "G"];
let value = bytesPerSecond;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value = value / 1024;
unitIndex++;
}
// Promote at ~100 of current unit (e.g., 100k -> ~0.1M shown as 0.1M or 0M if rounded)
if (unitIndex < units.length - 1 && value >= 100) {
value = value / 1024;
unitIndex++;
}
const display = Math.round(value).toString();
return display + units[unitIndex];
}
// -------------------------------------------------------
// Function to start fetching and computing the cpu temperature
function updateCpuTemperature() {
// For AMD sensors (k10temp and zenpower), only use Tctl sensor
// temp1_input corresponds to Tctl (Temperature Control) on these sensors
if (root.cpuTempSensorName === "k10temp" || root.cpuTempSensorName === "zenpower") {
cpuTempReader.path = `${root.cpuTempHwmonPath}/temp1_input`;
cpuTempReader.reload();
} else if (root.cpuTempSensorName === "coretemp") {
root.intelTempValues = [];
root.intelTempFilesChecked = 0;
checkNextIntelTemp();
}
}
// -------------------------------------------------------
// Function to check next Intel temperature sensor
function checkNextIntelTemp() {
if (root.intelTempFilesChecked >= root.intelTempMaxFiles) {
// Calculate average of all found temperatures
if (root.intelTempValues.length > 0) {
let sum = 0;
for (var i = 0; i < root.intelTempValues.length; i++) {
sum += root.intelTempValues[i];
}
root.cpuTemp = Math.round(sum / root.intelTempValues.length);
} else {
Logger.warn("SystemStatService", "No temperature sensors found for coretemp");
root.cpuTemp = 0;
}
return ;
}
// Check next temperature file
root.intelTempFilesChecked++;
cpuTempReader.path = `${root.cpuTempHwmonPath}/temp${root.intelTempFilesChecked}_input`;
cpuTempReader.reload();
}
// --------------------------------------------
Component.onCompleted: {
// Kickoff the cpu name detection for temperature
cpuTempNameReader.checkNext();
}
// --------------------------------------------
// Timer for periodic updates
Timer {
id: updateTimer
interval: root.sleepDuration
repeat: true
running: true
triggeredOnStart: true
onTriggered: {
// Trigger all direct system files reads
memInfoFile.reload();
cpuStatFile.reload();
// Run df (disk free) one time
dfProcess.running = true;
updateCpuTemperature();
}
}
Timer {
id: fasterUpdateTimer
interval: root.fasterSleepDuration
repeat: true
running: true
triggeredOnStart: true
onTriggered: {
netDevFile.reload();
}
}
// --------------------------------------------
// FileView components for reading system files
FileView {
id: memInfoFile
path: "/proc/meminfo"
onLoaded: parseMemoryInfo(text())
}
FileView {
id: cpuStatFile
path: "/proc/stat"
onLoaded: calculateCpuUsage(text())
}
FileView {
id: netDevFile
path: "/proc/net/dev"
onLoaded: calculateNetworkSpeed(text())
}
// --------------------------------------------
// Process to fetch disk usage in percent
// Uses 'df' aka 'disk free'
Process {
id: dfProcess
command: ["df", "--output=pcent", "/"]
running: false
stdout: StdioCollector {
onStreamFinished: {
const lines = text.trim().split('\n');
if (lines.length >= 2) {
const percent = lines[1].replace(/[^0-9]/g, '');
root.diskPercent = parseInt(percent) || 0;
}
}
}
}
// --------------------------------------------
// --------------------------------------------
// CPU Temperature
// It's more complex.
// ----
// #1 - Find a common cpu sensor name ie: "coretemp", "k10temp", "zenpower"
FileView {
id: cpuTempNameReader
property int currentIndex: 0
function checkNext() {
if (currentIndex >= 16) {
// Check up to hwmon10
Logger.warn("SystemStatService", "No supported temperature sensor found");
return ;
}
cpuTempNameReader.path = `/sys/class/hwmon/hwmon${currentIndex}/name`;
cpuTempNameReader.reload();
}
printErrors: false
onLoaded: {
const name = text().trim();
if (root.supportedTempCpuSensorNames.includes(name)) {
root.cpuTempSensorName = name;
root.cpuTempHwmonPath = `/sys/class/hwmon/hwmon${currentIndex}`;
Logger.log("SystemStatService", `Found ${root.cpuTempSensorName} CPU thermal sensor at ${root.cpuTempHwmonPath}`);
} else {
currentIndex++;
Qt.callLater(() => {
// Qt.callLater is mandatory
checkNext();
});
}
}
onLoadFailed: function(error) {
currentIndex++;
Qt.callLater(() => {
// Qt.callLater is mandatory
checkNext();
});
}
}
// ----
// #2 - Read sensor value
FileView {
id: cpuTempReader
printErrors: false
onLoaded: {
const data = text().trim();
if (root.cpuTempSensorName === "coretemp") {
// For Intel, collect all temperature values
const temp = parseInt(data) / 1000;
root.intelTempValues.push(temp);
Qt.callLater(() => {
// Qt.callLater is mandatory
checkNextIntelTemp();
});
} else {
// For AMD sensors (k10temp and zenpower), directly set the temperature
root.cpuTemp = Math.round(parseInt(data) / 1000);
}
}
onLoadFailed: function(error) {
Qt.callLater(() => {
// Qt.callLater is mandatory
checkNextIntelTemp();
});
}
}
}

View File

@@ -0,0 +1,46 @@
import QtQuick
import Quickshell
pragma Singleton
Singleton {
// ignore and fall back
id: root
function iconFromName(iconName, fallbackName) {
const fallback = fallbackName || "application-x-executable";
try {
if (iconName && typeof Quickshell !== 'undefined' && Quickshell.iconPath) {
const p = Quickshell.iconPath(iconName, fallback);
if (p && p !== "")
return p;
}
} catch (e) {
}
try {
return Quickshell.iconPath ? (Quickshell.iconPath(fallback, true) || "") : "";
} catch (e2) {
return "";
}
}
// Resolve icon path for a DesktopEntries appId - safe on missing entries
function iconForAppId(appId, fallbackName) {
const fallback = fallbackName || "application-x-executable";
if (!appId)
return iconFromName(fallback, fallback);
try {
if (typeof DesktopEntries === 'undefined' || !DesktopEntries.byId)
return iconFromName(fallback, fallback);
const entry = (DesktopEntries.heuristicLookup) ? DesktopEntries.heuristicLookup(appId) : DesktopEntries.byId(appId);
const name = entry && entry.icon ? entry.icon : "";
return iconFromName(name || fallback, fallback);
} catch (e) {
return iconFromName(fallback, fallback);
}
}
}

View File

@@ -0,0 +1,44 @@
import QtQuick
import Quickshell
pragma Singleton
Singleton {
id: root
property var date: new Date()
property string time: Qt.formatDateTime(date, "HH:mm")
property string dateString: {
let now = date;
let dayName = now.toLocaleDateString(Qt.locale(), "ddd");
dayName = dayName.charAt(0).toUpperCase() + dayName.slice(1);
let day = now.getDate();
let suffix;
if (day > 3 && day < 21)
suffix = 'th';
else
switch (day % 10) {
case 1:
suffix = "st";
break;
case 2:
suffix = "nd";
break;
case 3:
suffix = "rd";
break;
default:
suffix = "th";
};
let month = now.toLocaleDateString(Qt.locale(), "MMMM");
let year = now.toLocaleDateString(Qt.locale(), "yyyy");
return `${dayName}, ` + `${day}${suffix} ${month} ${year}`;
}
Timer {
interval: 1000
repeat: true
running: true
onTriggered: root.date = new Date()
}
}

View File

@@ -0,0 +1,56 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Services
import qs.Utils
Singleton {
id: root
property ListModel workspaces
workspaces: ListModel {
}
function initNiri() {
updateNiriWorkspaces();
}
function updateNiriWorkspaces() {
const niriWorkspaces = Niri.workspaces || [];
workspaces.clear();
for (let i = 0; i < niriWorkspaces.length; i++) {
const ws = niriWorkspaces[i];
workspaces.append({
"id": ws.id,
"idx": ws.idx || 1,
"name": ws.name || "",
"output": ws.output || "",
"isFocused": ws.isFocused === true,
"isActive": ws.isActive === true,
"isUrgent": ws.isUrgent === true
});
}
workspacesChanged();
}
function switchToWorkspace(workspaceId) {
try {
Quickshell.execDetached(["niri", "msg", "action", "focus-workspace", workspaceId.toString()]);
} catch (e) {
Logger.error("WorkspaceManager", "Error switching Niri workspace:", e);
}
}
Connections {
function onWorkspacesChanged() {
updateNiriWorkspaces();
}
target: Niri
}
}

View File

@@ -0,0 +1,20 @@
import QtQuick
import Quickshell
import Quickshell.Io
pragma Singleton
Singleton {
id: root
function write(text) {
action.command = ["sh", "-c", `echo ${text} | wl-copy -n`];
action.startDetached();
}
Process {
id: action
running: false
}
}

View File

Binary file not shown.

View File

Binary file not shown.

View File

@@ -0,0 +1,71 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Services
Scope {
id: root
property int count: 32
property int noiseReduction: 60
property string channels: "mono"
property string monoOption: "average"
property var config: ({
"general": {
"bars": count,
"framerate": 30,
"autosens": 1
},
"smoothing": {
"monstercat": 1,
"gravity": 1e+06,
"noise_reduction": noiseReduction
},
"output": {
"method": "raw",
"data_format": "ascii",
"ascii_max_range": 100,
"bit_format": "8bit",
"channels": channels,
"mono_option": monoOption
}
})
property var values: Array(count).fill(0)
Process {
id: process
stdinEnabled: true
running: !MusicManager.isAllPaused()
command: ["cava", "-p", "/dev/stdin"]
onExited: {
stdinEnabled = true;
values = Array(count).fill(0);
}
onStarted: {
for (const k in config) {
if (typeof config[k] !== "object") {
write(k + "=" + config[k] + "\n");
continue;
}
write("[" + k + "]\n");
const obj = config[k];
for (const k2 in obj) {
write(k2 + "=" + obj[k2] + "\n");
}
}
stdinEnabled = false;
values = Array(count).fill(0);
}
stdout: SplitParser {
onRead: (data) => {
root.values = data.slice(0, -1).split(";").map((v) => {
return parseInt(v, 10) / 100;
});
}
}
}
}

View File

@@ -0,0 +1,10 @@
import QtQuick
import Quickshell
import qs.Constants
pragma Singleton
Singleton {
id: root
readonly property var colorList: [Colors.lavender, Colors.blue, Colors.sapphire, Colors.sky, Colors.teal, Colors.green, Colors.yellow, Colors.peach]
}

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

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

@@ -0,0 +1,192 @@
function sha256(message) {
// SHA-256 constants
const K = [
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2
];
// Initial hash values
let h0 = 0x6a09e667;
let h1 = 0xbb67ae85;
let h2 = 0x3c6ef372;
let h3 = 0xa54ff53a;
let h4 = 0x510e527f;
let h5 = 0x9b05688c;
let h6 = 0x1f83d9ab;
let h7 = 0x5be0cd19;
// Convert string to UTF-8 bytes manually
const msgBytes = stringToUtf8Bytes(message);
const msgLength = msgBytes.length;
const bitLength = msgLength * 8;
// Calculate padding
// Message + 1 bit (0x80) + padding zeros + 8 bytes for length = multiple of 64 bytes
const totalBitsNeeded = bitLength + 1 + 64; // message bits + padding bit + 64-bit length
const totalBytesNeeded = Math.ceil(totalBitsNeeded / 8);
const paddedLength = Math.ceil(totalBytesNeeded / 64) * 64; // Round up to multiple of 64
const paddedMsg = new Array(paddedLength).fill(0);
// Copy original message
for (let i = 0; i < msgLength; i++) {
paddedMsg[i] = msgBytes[i];
}
// Add padding bit (0x80 = 10000000 in binary)
paddedMsg[msgLength] = 0x80;
// Add length as 64-bit big-endian integer at the end
// JavaScript numbers are not precise enough for 64-bit integers, so we handle high/low separately
const highBits = Math.floor(bitLength / 0x100000000);
const lowBits = bitLength % 0x100000000;
// Write 64-bit length in big-endian format
paddedMsg[paddedLength - 8] = (highBits >>> 24) & 0xFF;
paddedMsg[paddedLength - 7] = (highBits >>> 16) & 0xFF;
paddedMsg[paddedLength - 6] = (highBits >>> 8) & 0xFF;
paddedMsg[paddedLength - 5] = highBits & 0xFF;
paddedMsg[paddedLength - 4] = (lowBits >>> 24) & 0xFF;
paddedMsg[paddedLength - 3] = (lowBits >>> 16) & 0xFF;
paddedMsg[paddedLength - 2] = (lowBits >>> 8) & 0xFF;
paddedMsg[paddedLength - 1] = lowBits & 0xFF;
// Process message in 512-bit (64-byte) chunks
for (let chunk = 0; chunk < paddedLength; chunk += 64) {
const w = new Array(64);
// Break chunk into sixteen 32-bit big-endian words
for (let i = 0; i < 16; i++) {
const offset = chunk + i * 4;
w[i] = (paddedMsg[offset] << 24) |
(paddedMsg[offset + 1] << 16) |
(paddedMsg[offset + 2] << 8) |
paddedMsg[offset + 3];
// Ensure unsigned 32-bit
w[i] = w[i] >>> 0;
}
// Extend the sixteen 32-bit words into sixty-four 32-bit words
for (let i = 16; i < 64; i++) {
const s0 = rightRotate(w[i - 15], 7) ^ rightRotate(w[i - 15], 18) ^ (w[i - 15] >>> 3);
const s1 = rightRotate(w[i - 2], 17) ^ rightRotate(w[i - 2], 19) ^ (w[i - 2] >>> 10);
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0;
}
// Initialize working variables for this chunk
let a = h0, b = h1, c = h2, d = h3, e = h4, f = h5, g = h6, h = h7;
// Main loop
for (let i = 0; i < 64; i++) {
const S1 = rightRotate(e, 6) ^ rightRotate(e, 11) ^ rightRotate(e, 25);
const ch = (e & f) ^ (~e & g);
const temp1 = (h + S1 + ch + K[i] + w[i]) >>> 0;
const S0 = rightRotate(a, 2) ^ rightRotate(a, 13) ^ rightRotate(a, 22);
const maj = (a & b) ^ (a & c) ^ (b & c);
const temp2 = (S0 + maj) >>> 0;
h = g;
g = f;
f = e;
e = (d + temp1) >>> 0;
d = c;
c = b;
b = a;
a = (temp1 + temp2) >>> 0;
}
// Add this chunk's hash to result so far
h0 = (h0 + a) >>> 0;
h1 = (h1 + b) >>> 0;
h2 = (h2 + c) >>> 0;
h3 = (h3 + d) >>> 0;
h4 = (h4 + e) >>> 0;
h5 = (h5 + f) >>> 0;
h6 = (h6 + g) >>> 0;
h7 = (h7 + h) >>> 0;
}
// Produce the final hash value as a hex string
return [h0, h1, h2, h3, h4, h5, h6, h7]
.map(h => h.toString(16).padStart(8, '0'))
.join('');
}
function stringToUtf8Bytes(str) {
const bytes = [];
for (let i = 0; i < str.length; i++) {
let code = str.charCodeAt(i);
if (code < 0x80) {
// 1-byte character (ASCII)
bytes.push(code);
} else if (code < 0x800) {
// 2-byte character
bytes.push(0xC0 | (code >> 6));
bytes.push(0x80 | (code & 0x3F));
} else if (code < 0xD800 || code > 0xDFFF) {
// 3-byte character (not surrogate)
bytes.push(0xE0 | (code >> 12));
bytes.push(0x80 | ((code >> 6) & 0x3F));
bytes.push(0x80 | (code & 0x3F));
} else {
// 4-byte character (surrogate pair)
i++; // Move to next character
const code2 = str.charCodeAt(i);
const codePoint = 0x10000 + (((code & 0x3FF) << 10) | (code2 & 0x3FF));
bytes.push(0xF0 | (codePoint >> 18));
bytes.push(0x80 | ((codePoint >> 12) & 0x3F));
bytes.push(0x80 | ((codePoint >> 6) & 0x3F));
bytes.push(0x80 | (codePoint & 0x3F));
}
}
return bytes;
}
function rightRotate(value, amount) {
return ((value >>> amount) | (value << (32 - amount))) >>> 0;
}
// Test function to verify implementation
// function testSHA256() {
// const tests = [
// {
// input: "",
// expected:
// "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
// },
// {
// input: "Hello World",
// expected:
// "a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e",
// },
// {
// input: "abc",
// expected:
// "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
// },
// {
// input: "The quick brown fox jumps over the lazy dog",
// expected:
// "d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592",
// },
// ];
// console.log("Running SHA-256 tests:");
// tests.forEach((test, i) => {
// const result = Crypto.sha256(test.input);
// const passed = result === test.expected;
// console.log(`Test ${i + 1}: ${passed ? "PASS" : "FAIL"}`);
// if (!passed) {
// console.log(` Input: "${test.input}"`);
// console.log(` Expected: ${test.expected}`);
// console.log(` Got: ${result}`);
// }
// });
// }

16
config/quickshell/apply-color Executable file
View File

@@ -0,0 +1,16 @@
#!/bin/bash
path=$(dirname "$(readlink -f "$0")")
. "$path"/../../utils/apply-color-helper
if pgrep -x "quickshell" -u "$USER" >/dev/null; then
qs ipc call colors setPrimary ${colorHex} || {
log_error "Failed to send IPC command to quickshell"
exit 1
}
log_success "quickshell"
else
log_error "quickshell is not running. Cannot apply color."
exit 1
fi

View File

@@ -0,0 +1,57 @@
import QtQuick
import Quickshell
import Quickshell.Widgets
import qs.Constants
import qs.Modules.Bar
import qs.Modules.Misc
import qs.Modules.Panel
import qs.Services
ShellRoot {
id: root
Loader {
id: loader
active: CacheService.loaded && NukeKded6.done
sourceComponent: Item {
Notification {
id: notification
}
IPCService {
id: ipcService
}
Bar {
id: bar
}
Corners {
id: corners
}
CalendarPanel {
id: calendarPanel
objectName: "calendarPanel"
}
ControlCenterPanel {
id: controlCenterPanel
objectName: "controlCenterPanel"
}
NotificationHistoryPanel {
id: notificationHistoryPanel
objectName: "notificationHistoryPanel"
}
}
}
}