This commit is contained in:
2025-10-26 16:50:08 +01:00
parent f10af1ca02
commit 428de73f48
444 changed files with 254 additions and 67 deletions

View File

@@ -0,0 +1,3 @@
# some sensitive files
GeoInfoToken.txt
IpAliases.json

View File

@@ -0,0 +1 @@
0

View File

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

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
readonly property color mPrimary: Colors.primary
readonly property color mOnPrimary: Colors.base
readonly property color mSecondary: Colors.primary
readonly property color mOnSecondary: Colors.base
readonly property color mTertiary: Colors.primary
readonly property color mOnTertiary: Colors.base
readonly property color mError: Colors.red
readonly property color mOnError: Colors.base
readonly property color mSurface: Colors.base
readonly property color mOnSurface: Colors.text
readonly property color mSurfaceVariant: Colors.surface
readonly property color mOnSurfaceVariant: Colors.overlay1
readonly property color mOutline: Colors.primary
readonly property color mShadow: Colors.crust
readonly property color transparent: "transparent"
}

View File

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

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,109 @@
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: ""
readonly property string wifiOn: "󰖩"
readonly property string wifiOff: "󰖪"
readonly property string bluetoothOn: ""
readonly property string bluetoothOff: "󰂲"
// Tabler icons
// Expose the font family name for easy access
readonly property string fontFamily: currentFontLoader ? currentFontLoader.name : ""
readonly property string defaultIcon: TablerIcons.defaultIcon
readonly property var icons: TablerIcons.icons
readonly property var aliases: TablerIcons.aliases
readonly property string fontPath: "/Assets/Fonts/tabler/tabler-icons.ttf"
// 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
readonly property real fontSizeXXS: 8
readonly property real fontSizeXS: 9
readonly property real fontSizeS: 10
readonly property real fontSizeM: 11
readonly property real fontSizeL: 13
readonly property real fontSizeXL: 16
readonly property real fontSizeXXL: 18
readonly property real fontSizeXXXL: 24
// Font weight
readonly property int fontWeightRegular: 400
readonly property int fontWeightMedium: 500
readonly property int fontWeightSemiBold: 600
readonly property int fontWeightBold: 700
// Radii
readonly property int radiusXXS: 4
readonly property int radiusXS: 8
readonly property int radiusS: 12
readonly property int radiusM: 16
readonly property int radiusL: 20
//screen Radii
readonly property int screenRadius: 20
// Border
readonly property int borderS: 2
readonly property int borderM: 3
readonly property int borderL: 4
// Margins (for margins and spacing)
readonly property int marginXXS: 2
readonly property int marginXS: 4
readonly property int marginS: 8
readonly property int marginM: 12
readonly property int marginL: 16
readonly property int marginXL: 24
// Opacity
readonly property real opacityNone: 0
readonly property real opacityLight: 0.25
readonly property real opacityMedium: 0.5
readonly property real opacityHeavy: 0.75
readonly property real opacityAlmost: 0.95
readonly property real opacityFull: 1
// Animation duration (ms)
readonly property int animationFast: 150
readonly property int animationNormal: 300
readonly property int animationSlow: 450
readonly property int animationSlowest: 1000
// Delays
readonly property int tooltipDelay: 300
readonly property int tooltipDelayLong: 1200
readonly property int pillDelay: 500
// Settings widgets base size
readonly property real baseWidgetSize: 33
readonly property real sliderWidth: 200
// Bar Dimensions
readonly property real barHeight: 45
readonly property real capsuleHeight: 35
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,265 @@
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: {
Quickshell.execDetached(["rofi", "-show", "drun"]);
}
}
SymbolButton {
symbol: SettingsService.wifiEnabled ? Icons.wifiOn : Icons.wifiOff
buttonColor: Colors.rosewater
onClicked: {
PanelService.getPanel("wifiPanel")?.toggle(this)
}
}
SymbolButton {
symbol: BluetoothService.enabled ? Icons.bluetoothOn : Icons.bluetoothOff
buttonColor: Colors.blue
onClicked: {
PanelService.getPanel("bluetoothPanel")?.toggle(this)
}
onRightClicked: {
Quickshell.execDetached(["blueberry"]);
}
}
Item {
width: 5
}
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 {
}
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: {
Quickshell.execDetached(["wlogout"]);
}
}
}
}
}
}

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,82 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import qs.Constants
import qs.Services
Item {
id: root
property int displayIndex: 0
readonly property list<string> displayTexts: [IpService.countryCode, IpService.ip, IpService.alias]
readonly property string displayText: displayTexts[displayIndex]
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: displayText
font.pointSize: displayIndex === 0 ? 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(displayText);
SendNotification.show("Copied to clipboard", displayText);
} else if (mouse.button === Qt.RightButton){
let iter = 0;
do {
displayIndex = (displayIndex + 1) % displayTexts.length;
} while (!displayTexts[displayIndex] && iter++ < displayTexts.length);
} 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,62 @@
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
spacing: 0
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 pwvucontrol || pwvucontrol"]);
}
}

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,159 @@
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=")) {
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 Quickshell.Widgets
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
}
IconImage {
Layout.preferredWidth: Style.marginL
Layout.preferredHeight: Style.marginL
source: modelData?.icon ?? ""
visible: (modelData?.icon ?? "") !== ""
}
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,254 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Bluetooth
import Quickshell.Wayland
import qs.Constants
import qs.Modules.Panel.Misc
import qs.Noctalia
import qs.Services
NPanel {
id: root
preferredWidth: 380
preferredHeight: 500
panelContent: Rectangle {
color: Color.transparent
ColumnLayout {
anchors.fill: parent
anchors.margins: Style.marginL
spacing: Style.marginM
// HEADER
RowLayout {
Layout.fillWidth: true
spacing: Style.marginM
NIcon {
icon: "bluetooth"
pointSize: Style.fontSizeXXL
color: Color.mPrimary
}
NText {
text: "Bluetooth"
pointSize: Style.fontSizeL
font.weight: Style.fontWeightBold
color: Color.mOnSurface
Layout.fillWidth: true
}
NToggle {
id: bluetoothSwitch
checked: BluetoothService.enabled
onToggled: (checked) => {
return BluetoothService.setBluetoothEnabled(checked);
}
baseSize: Style.baseWidgetSize * 0.65
}
NIconButton {
enabled: BluetoothService.enabled
icon: BluetoothService.adapter && BluetoothService.adapter.discovering ? "stop" : "refresh"
baseSize: Style.baseWidgetSize * 0.8
onClicked: {
if (BluetoothService.adapter)
BluetoothService.adapter.discovering = !BluetoothService.adapter.discovering;
}
colorFg: Colors.green
colorBg: Color.transparent
colorFgHover: Colors.base
colorBgHover: Colors.green
}
NIconButton {
icon: "close"
baseSize: Style.baseWidgetSize * 0.8
onClicked: {
root.close();
}
colorFg: Colors.red
colorBg: Color.transparent
colorFgHover: Colors.base
colorBgHover: Colors.red
}
}
NDivider {
Layout.fillWidth: true
}
Rectangle {
visible: !(BluetoothService.adapter && BluetoothService.adapter.enabled)
Layout.fillWidth: true
Layout.fillHeight: true
color: Color.transparent
// Center the content within this rectangle
ColumnLayout {
anchors.centerIn: parent
spacing: Style.marginM
NIcon {
icon: "bluetooth-off"
pointSize: 64
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
NText {
text: "Bluetooth is turned off"
pointSize: Style.fontSizeL
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
NText {
text: "Enable Bluetooth"
pointSize: Style.fontSizeS
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
}
}
NScrollView {
visible: BluetoothService.adapter && BluetoothService.adapter.enabled
Layout.fillWidth: true
Layout.fillHeight: true
horizontalPolicy: ScrollBar.AlwaysOff
verticalPolicy: ScrollBar.AsNeeded
clip: true
contentWidth: availableWidth
ColumnLayout {
width: parent.width
spacing: Style.marginM
// Connected devices
BluetoothDevicesList {
property var items: {
if (!BluetoothService.adapter || !Bluetooth.devices)
return [];
var filtered = Bluetooth.devices.values.filter((dev) => {
return dev && !dev.blocked && dev.connected;
});
return BluetoothService.sortDevices(filtered);
}
label: "Connected Devices"
model: items
visible: items.length > 0
Layout.fillWidth: true
}
// Known devices
BluetoothDevicesList {
property var items: {
if (!BluetoothService.adapter || !Bluetooth.devices)
return [];
var filtered = Bluetooth.devices.values.filter((dev) => {
return dev && !dev.blocked && !dev.connected && (dev.paired || dev.trusted);
});
return BluetoothService.sortDevices(filtered);
}
label: "Known Devices"
tooltipText: "Connect/Disconnect Devices"
model: items
visible: items.length > 0
Layout.fillWidth: true
}
// Available devices
BluetoothDevicesList {
property var items: {
if (!BluetoothService.adapter || !Bluetooth.devices)
return [];
var filtered = Bluetooth.devices.values.filter((dev) => {
return dev && !dev.blocked && !dev.paired && !dev.trusted;
});
return BluetoothService.sortDevices(filtered);
}
label: "Available Devices"
model: items
visible: items.length > 0
Layout.fillWidth: true
}
// Fallback - No devices, scanning
ColumnLayout {
Layout.alignment: Qt.AlignHCenter
spacing: Style.marginM
visible: {
if (!BluetoothService.adapter || !BluetoothService.adapter.discovering || !Bluetooth.devices)
return false;
var availableCount = Bluetooth.devices.values.filter((dev) => {
return dev && !dev.paired && !dev.pairing && !dev.blocked && (dev.signalStrength === undefined || dev.signalStrength > 0);
}).length;
return (availableCount === 0);
}
RowLayout {
Layout.alignment: Qt.AlignHCenter
spacing: Style.marginXS
NIcon {
icon: "refresh"
pointSize: Style.fontSizeXXL * 1.5
color: Color.mPrimary
RotationAnimation on rotation {
running: true
loops: Animation.Infinite
from: 0
to: 360
duration: Style.animationSlow * 4
}
}
NText {
text: "Scanning..."
pointSize: Style.fontSizeL
color: Color.mOnSurface
}
}
NText {
text: "Pairing Mode"
pointSize: Style.fontSizeM
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
}
Item {
Layout.fillHeight: true
}
}
}
}
}
}

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,185 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Bluetooth
import Quickshell.Wayland
import qs.Constants
import qs.Noctalia
import qs.Services
ColumnLayout {
id: root
property string label: ""
property string tooltipText: ""
property var model: {
}
Layout.fillWidth: true
spacing: Style.marginM
NText {
text: root.label
pointSize: Style.fontSizeL
color: Color.mSecondary
font.weight: Style.fontWeightMedium
Layout.fillWidth: true
visible: root.model.length > 0
}
Repeater {
id: deviceList
Layout.fillWidth: true
model: root.model
visible: BluetoothService.adapter && BluetoothService.adapter.enabled
NBox {
id: device
readonly property bool canConnect: BluetoothService.canConnect(modelData)
readonly property bool canDisconnect: BluetoothService.canDisconnect(modelData)
readonly property bool isBusy: BluetoothService.isDeviceBusy(modelData)
function getContentColor(defaultColor = Color.mOnSurface) {
if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting)
return Color.mPrimary;
if (modelData.blocked)
return Color.mError;
return defaultColor;
}
Layout.fillWidth: true
Layout.preferredHeight: deviceLayout.implicitHeight + (Style.marginM * 2)
RowLayout {
id: deviceLayout
anchors.fill: parent
anchors.margins: Style.marginM
spacing: Style.marginM
Layout.alignment: Qt.AlignVCenter
// One device BT icon
NIcon {
icon: BluetoothService.getDeviceIcon(modelData)
pointSize: Style.fontSizeXXL
color: getContentColor(Color.mOnSurface)
Layout.alignment: Qt.AlignVCenter
}
ColumnLayout {
Layout.fillWidth: true
spacing: Style.marginXXS
// Device name
NText {
text: modelData.name || modelData.deviceName
pointSize: Style.fontSizeM
font.weight: Style.fontWeightMedium
elide: Text.ElideRight
color: getContentColor(Color.mOnSurface)
Layout.fillWidth: true
}
// Status
NText {
text: BluetoothService.getStatusString(modelData)
visible: text !== ""
pointSize: Style.fontSizeXS
color: getContentColor(Color.mOnSurfaceVariant)
}
// Signal Strength
RowLayout {
visible: modelData.signalStrength !== undefined
Layout.fillWidth: true
spacing: Style.marginXS
// Device signal strength - "Unknown" when not connected
NText {
text: BluetoothService.getSignalStrength(modelData)
pointSize: Style.fontSizeXS
color: getContentColor(Color.mOnSurfaceVariant)
}
NIcon {
visible: modelData.signalStrength > 0 && !modelData.pairing && !modelData.blocked
icon: BluetoothService.getSignalIcon(modelData)
pointSize: Style.fontSizeXS
color: getContentColor(Color.mOnSurface)
}
NText {
visible: modelData.signalStrength > 0 && !modelData.pairing && !modelData.blocked
text: (modelData.signalStrength !== undefined && modelData.signalStrength > 0) ? modelData.signalStrength + "%" : ""
pointSize: Style.fontSizeXS
color: getContentColor(Color.mOnSurface)
}
}
// Battery
NText {
visible: modelData.batteryAvailable
text: BluetoothService.getBattery(modelData)
pointSize: Style.fontSizeXS
color: getContentColor(Color.mOnSurfaceVariant)
}
}
// Spacer to push connect button to the right
Item {
Layout.fillWidth: true
}
// Call to action
NButton {
id: button
visible: (modelData.state !== BluetoothDeviceState.Connecting)
enabled: (canConnect || canDisconnect) && !isBusy
outlined: !button.hovered
fontSize: Style.fontSizeXS
fontWeight: Style.fontWeightMedium
backgroundColor: {
if (device.canDisconnect && !isBusy)
return Color.mError;
return Color.mPrimary;
}
text: {
if (modelData.pairing)
return "Pairing...";
if (modelData.blocked)
return "Blocked";
if (modelData.connected)
return "Disconnect";
return "Connect";
}
icon: (isBusy ? "busy" : null)
onClicked: {
if (modelData.connected)
BluetoothService.disconnectDevice(modelData);
else
BluetoothService.connectDeviceWithTrust(modelData);
}
onRightClicked: {
BluetoothService.forgetDevice(modelData);
}
}
}
}
}
}

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,633 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Wayland
import qs.Constants
import qs.Noctalia
import qs.Services
import qs.Utils
NPanel {
id: root
property string passwordSsid: ""
property string passwordInput: ""
property string expandedSsid: ""
preferredWidth: 400
preferredHeight: 500
onOpened: NetworkService.scan()
panelContent: Rectangle {
color: Color.transparent
ColumnLayout {
anchors.fill: parent
anchors.margins: Style.marginL
spacing: Style.marginM
// Header
RowLayout {
Layout.fillWidth: true
spacing: Style.marginM
NIcon {
icon: SettingsService.wifiEnabled ? "wifi" : "wifi-off"
pointSize: Style.fontSizeXXL
color: SettingsService.wifiEnabled ? Color.mPrimary : Color.mOnSurfaceVariant
}
NText {
text: "WiFi"
pointSize: Style.fontSizeL
font.weight: Style.fontWeightBold
color: Color.mOnSurface
Layout.fillWidth: true
}
NToggle {
id: wifiSwitch
checked: SettingsService.wifiEnabled
onToggled: (checked) => {
return NetworkService.setWifiEnabled(checked);
}
baseSize: Style.baseWidgetSize * 0.65
}
NIconButton {
icon: "refresh"
baseSize: Style.baseWidgetSize * 0.8
enabled: SettingsService.wifiEnabled && !NetworkService.scanning
onClicked: NetworkService.scan()
colorFg: Colors.green
colorBg: Color.transparent
colorFgHover: Colors.base
colorBgHover: Colors.green
}
NIconButton {
icon: "close"
baseSize: Style.baseWidgetSize * 0.8
onClicked: root.close()
colorFg: Colors.red
colorBg: Color.transparent
colorFgHover: Colors.base
colorBgHover: Colors.red
}
}
NDivider {
Layout.fillWidth: true
}
// Error message
Rectangle {
visible: NetworkService.lastError.length > 0
Layout.fillWidth: true
Layout.preferredHeight: errorRow.implicitHeight + (Style.marginM * 2)
color: Qt.rgba(Color.mError.r, Color.mError.g, Color.mError.b, 0.1)
radius: Style.radiusS
border.width: Math.max(1, Style.borderS)
border.color: Color.mError
RowLayout {
id: errorRow
anchors.fill: parent
anchors.margins: Style.marginM
spacing: Style.marginS
NIcon {
icon: "warning"
pointSize: Style.fontSizeL
color: Color.mError
}
NText {
text: NetworkService.lastError
color: Color.mError
pointSize: Style.fontSizeS
wrapMode: Text.Wrap
Layout.fillWidth: true
}
NIconButton {
icon: "close"
baseSize: Style.baseWidgetSize * 0.6
onClicked: NetworkService.lastError = ""
}
}
}
// Main content area
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
color: Color.transparent
// WiFi disabled state
ColumnLayout {
visible: !SettingsService.wifiEnabled
anchors.fill: parent
spacing: Style.marginM
Item {
Layout.fillHeight: true
}
NIcon {
icon: "wifi-off"
pointSize: 64
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
NText {
text: "Wi-Fi Disabled"
pointSize: Style.fontSizeL
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
NText {
text: "Please enable Wi-Fi to connect to a network."
pointSize: Style.fontSizeS
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
Item {
Layout.fillHeight: true
}
}
// Scanning state
ColumnLayout {
visible: SettingsService.wifiEnabled && NetworkService.scanning && Object.keys(NetworkService.networks).length === 0
anchors.fill: parent
spacing: Style.marginL
Item {
Layout.fillHeight: true
}
NBusyIndicator {
running: true
color: Color.mPrimary
size: Style.baseWidgetSize
Layout.alignment: Qt.AlignHCenter
}
NText {
text: "Searching for networks..."
pointSize: Style.fontSizeM
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
Item {
Layout.fillHeight: true
}
}
// Networks list container
NScrollView {
visible: SettingsService.wifiEnabled && (!NetworkService.scanning || Object.keys(NetworkService.networks).length > 0)
anchors.fill: parent
horizontalPolicy: ScrollBar.AlwaysOff
verticalPolicy: ScrollBar.AsNeeded
clip: true
ColumnLayout {
width: parent.width
spacing: Style.marginM
// Network list
Repeater {
model: {
if (!SettingsService.wifiEnabled)
return [];
const nets = Object.values(NetworkService.networks);
return nets.sort((a, b) => {
if (a.connected !== b.connected)
return b.connected - a.connected;
return b.signal - a.signal;
});
}
NBox {
Layout.fillWidth: true
implicitHeight: netColumn.implicitHeight + (Style.marginM * 2)
// Add opacity for operations in progress
opacity: (NetworkService.disconnectingFrom === modelData.ssid || NetworkService.forgettingNetwork === modelData.ssid) ? 0.6 : 1
ColumnLayout {
id: netColumn
width: parent.width - (Style.marginM * 2)
x: Style.marginM
y: Style.marginM
spacing: Style.marginS
// Main row
RowLayout {
Layout.fillWidth: true
spacing: Style.marginS
NIcon {
icon: NetworkService.signalIcon(modelData.signal)
pointSize: Style.fontSizeXXL
color: modelData.connected ? Color.mPrimary : Color.mOnSurface
}
ColumnLayout {
Layout.fillWidth: true
spacing: 2
NText {
text: modelData.ssid
pointSize: Style.fontSizeM
font.weight: modelData.connected ? Style.fontWeightBold : Style.fontWeightMedium
color: Color.mOnSurface
elide: Text.ElideRight
Layout.fillWidth: true
}
RowLayout {
spacing: Style.marginXS
NText {
text: `${modelData.signal}%`
pointSize: Style.fontSizeXXS
color: Color.mOnSurfaceVariant
}
NText {
text: "•"
pointSize: Style.fontSizeXXS
color: Color.mOnSurfaceVariant
}
NText {
text: NetworkService.isSecured(modelData.security) ? modelData.security : "Open"
pointSize: Style.fontSizeXXS
color: Color.mOnSurfaceVariant
}
Item {
Layout.preferredWidth: Style.marginXXS
}
// Update the status badges area (around line 237)
Rectangle {
visible: modelData.connected && NetworkService.disconnectingFrom !== modelData.ssid
color: Color.mPrimary
radius: height * 0.5
width: connectedText.implicitWidth + (Style.marginS * 2)
height: connectedText.implicitHeight + (Style.marginXXS * 2)
NText {
id: connectedText
anchors.centerIn: parent
text: "Connected"
pointSize: Style.fontSizeXXS
color: Color.mOnPrimary
}
}
Rectangle {
visible: NetworkService.disconnectingFrom === modelData.ssid
color: Color.mError
radius: height * 0.5
width: disconnectingText.implicitWidth + (Style.marginS * 2)
height: disconnectingText.implicitHeight + (Style.marginXXS * 2)
NText {
id: disconnectingText
anchors.centerIn: parent
text: "disconnecting"
pointSize: Style.fontSizeXXS
color: Color.mOnPrimary
}
}
Rectangle {
visible: NetworkService.forgettingNetwork === modelData.ssid
color: Color.mError
radius: height * 0.5
width: forgettingText.implicitWidth + (Style.marginS * 2)
height: forgettingText.implicitHeight + (Style.marginXXS * 2)
NText {
id: forgettingText
anchors.centerIn: parent
text: "forgetting"
pointSize: Style.fontSizeXXS
color: Color.mOnPrimary
}
}
Rectangle {
visible: modelData.cached && !modelData.connected && NetworkService.forgettingNetwork !== modelData.ssid && NetworkService.disconnectingFrom !== modelData.ssid
color: Color.transparent
border.color: Color.mOutline
border.width: Math.max(1, Style.borderS)
radius: height * 0.5
width: savedText.implicitWidth + (Style.marginS * 2)
height: savedText.implicitHeight + (Style.marginXXS * 2)
NText {
id: savedText
anchors.centerIn: parent
text: "saved"
pointSize: Style.fontSizeXXS
color: Color.mOnSurfaceVariant
}
}
}
}
// Action area
RowLayout {
spacing: Style.marginS
NBusyIndicator {
visible: NetworkService.connectingTo === modelData.ssid || NetworkService.disconnectingFrom === modelData.ssid || NetworkService.forgettingNetwork === modelData.ssid
running: visible
color: Color.mPrimary
size: Style.baseWidgetSize * 0.5
}
NIconButton {
visible: (modelData.existing || modelData.cached) && !modelData.connected && NetworkService.connectingTo !== modelData.ssid && NetworkService.forgettingNetwork !== modelData.ssid && NetworkService.disconnectingFrom !== modelData.ssid
icon: "trash"
baseSize: Style.baseWidgetSize * 0.8
onClicked: expandedSsid = expandedSsid === modelData.ssid ? "" : modelData.ssid
}
NButton {
visible: !modelData.connected && NetworkService.connectingTo !== modelData.ssid && passwordSsid !== modelData.ssid && NetworkService.forgettingNetwork !== modelData.ssid && NetworkService.disconnectingFrom !== modelData.ssid
text: {
if (modelData.existing || modelData.cached)
return "Connect";
if (!NetworkService.isSecured(modelData.security))
return "Connect";
return "Enter Password";
}
outlined: !hovered
fontSize: Style.fontSizeXS
enabled: !NetworkService.connecting
onClicked: {
if (modelData.existing || modelData.cached || !NetworkService.isSecured(modelData.security)) {
NetworkService.connect(modelData.ssid);
} else {
passwordSsid = modelData.ssid;
passwordInput = "";
expandedSsid = "";
}
}
}
NButton {
visible: modelData.connected && NetworkService.disconnectingFrom !== modelData.ssid
text: "Disconnect"
outlined: !hovered
fontSize: Style.fontSizeXS
backgroundColor: Color.mError
onClicked: NetworkService.disconnect(modelData.ssid)
}
}
}
// Password input
Rectangle {
visible: passwordSsid === modelData.ssid && NetworkService.disconnectingFrom !== modelData.ssid && NetworkService.forgettingNetwork !== modelData.ssid
Layout.fillWidth: true
height: passwordRow.implicitHeight + Style.marginS * 2
color: Color.mSurfaceVariant
border.color: Color.mOutline
border.width: Math.max(1, Style.borderS)
radius: Style.radiusS
RowLayout {
id: passwordRow
anchors.fill: parent
anchors.margins: Style.marginS
spacing: Style.marginM
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
radius: Style.radiusXS
color: Color.mSurface
border.color: pwdInput.activeFocus ? Color.mSecondary : Color.mOutline
border.width: Math.max(1, Style.borderS)
TextInput {
id: pwdInput
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: Style.marginS
text: passwordInput
font.family: Fonts.sans
font.pointSize: Style.fontSizeS
color: Color.mOnSurface
echoMode: TextInput.Password
selectByMouse: true
focus: visible
passwordCharacter: "●"
onTextChanged: passwordInput = text
onVisibleChanged: {
if (visible)
forceActiveFocus();
}
onAccepted: {
if (text && !NetworkService.connecting) {
NetworkService.connect(passwordSsid, text);
passwordSsid = "";
passwordInput = "";
}
}
NText {
visible: parent.text.length === 0
anchors.verticalCenter: parent.verticalCenter
text: "Enter Password"
color: Color.mOnSurfaceVariant
pointSize: Style.fontSizeS
}
}
}
NButton {
text: "Connect"
fontSize: Style.fontSizeXXS
enabled: passwordInput.length > 0 && !NetworkService.connecting
outlined: true
onClicked: {
NetworkService.connect(passwordSsid, passwordInput);
passwordSsid = "";
passwordInput = "";
}
}
NIconButton {
icon: "close"
baseSize: Style.baseWidgetSize * 0.8
onClicked: {
passwordSsid = "";
passwordInput = "";
}
}
}
}
// Forget network
Rectangle {
visible: expandedSsid === modelData.ssid && NetworkService.disconnectingFrom !== modelData.ssid && NetworkService.forgettingNetwork !== modelData.ssid
Layout.fillWidth: true
height: forgetRow.implicitHeight + Style.marginS * 2
color: Color.mSurfaceVariant
radius: Style.radiusS
border.width: Math.max(1, Style.borderS)
border.color: Color.mOutline
RowLayout {
id: forgetRow
anchors.fill: parent
anchors.margins: Style.marginS
spacing: Style.marginM
RowLayout {
NIcon {
icon: "trash"
pointSize: Style.fontSizeL
color: Color.mError
}
NText {
text: "Forget this network?"
pointSize: Style.fontSizeS
color: Color.mError
Layout.fillWidth: true
}
}
NButton {
id: forgetButton
text: "Forget"
fontSize: Style.fontSizeXXS
backgroundColor: Color.mError
outlined: forgetButton.hovered ? false : true
onClicked: {
NetworkService.forget(modelData.ssid);
expandedSsid = "";
}
}
NIconButton {
icon: "close"
baseSize: Style.baseWidgetSize * 0.8
onClicked: expandedSsid = ""
}
}
}
}
// Smooth opacity animation
Behavior on opacity {
NumberAnimation {
duration: Style.animationNormal
}
}
}
}
}
}
// Empty state when no networks
ColumnLayout {
visible: SettingsService.wifiEnabled && !NetworkService.scanning && Object.keys(NetworkService.networks).length === 0
anchors.fill: parent
spacing: Style.marginL
Item {
Layout.fillHeight: true
}
NIcon {
icon: "search"
pointSize: 64
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
NText {
text: "No networks found"
pointSize: Style.fontSizeL
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
NButton {
text: "Scan Again"
icon: "refresh"
Layout.alignment: Qt.AlignHCenter
onClicked: NetworkService.scan()
}
Item {
Layout.fillHeight: true
}
}
}
}
}
}

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

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,34 @@
import QtQuick
import QtQuick.Layouts
import qs.Constants
ColumnLayout {
id: root
property string label: ""
property string description: ""
property color labelColor: Color.mOnSurface
property color descriptionColor: Color.mOnSurfaceVariant
spacing: Style.marginXXS
Layout.fillWidth: true
NText {
text: label
pointSize: Style.fontSizeL
font.weight: Style.fontWeightBold
color: labelColor
visible: label !== ""
Layout.fillWidth: true
}
NText {
text: description
pointSize: Style.fontSizeS
color: descriptionColor
wrapMode: Text.WordWrap
visible: description !== ""
Layout.fillWidth: true
}
}

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.Templates as T
import qs.Constants
T.ScrollView {
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
property bool preventHorizontalScroll: horizontalPolicy === ScrollBar.AlwaysOff
property int boundsBehavior: Flickable.StopAtBounds
property int flickableDirection: Flickable.VerticalFlick
// Function to configure the underlying Flickable
function configureFlickable() {
// Find the internal Flickable (it's usually the first child)
for (var i = 0; i < children.length; i++) {
var child = children[i];
if (child.toString().indexOf("Flickable") !== -1) {
// Configure the flickable to prevent horizontal scrolling
child.boundsBehavior = root.boundsBehavior;
if (root.preventHorizontalScroll) {
child.flickableDirection = Flickable.VerticalFlick;
child.contentWidth = Qt.binding(() => {
return child.width;
});
} else {
child.flickableDirection = root.flickableDirection;
}
break;
}
}
}
implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset, contentWidth + leftPadding + rightPadding)
implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset, contentHeight + topPadding + bottomPadding)
// Configure the internal flickable when it becomes available
Component.onCompleted: {
configureFlickable();
}
// Watch for changes in horizontalPolicy
onHorizontalPolicyChanged: {
preventHorizontalScroll = (horizontalPolicy === ScrollBar.AlwaysOff);
configureFlickable();
}
ScrollBar.vertical: ScrollBar {
parent: root
x: root.mirrored ? 0 : root.width - width
y: root.topPadding
height: root.availableHeight
active: root.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 {
parent: root
x: root.leftPadding
y: root.height - height
width: root.availableWidth
active: root.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,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,90 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Constants
RowLayout {
id: root
property string label: ""
property string description: ""
property bool checked: false
property bool hovering: false
property int baseSize: Math.round(Style.baseWidgetSize * 0.8)
signal toggled(bool checked)
signal entered()
signal exited()
Layout.fillWidth: true
NLabel {
label: root.label
description: root.description
}
Rectangle {
id: switcher
implicitWidth: Math.round(root.baseSize * 0.85) * 2
implicitHeight: Math.round(root.baseSize * 0.5) * 2
radius: height * 0.5
color: root.checked ? Color.mPrimary : Color.mSurface
border.color: Color.mOutline
border.width: Math.max(1, Style.borderS)
Rectangle {
implicitWidth: Math.round(root.baseSize * 0.4) * 2
implicitHeight: Math.round(root.baseSize * 0.4) * 2
radius: height * 0.5
color: root.checked ? Color.mOnPrimary : Color.mPrimary
border.color: root.checked ? Color.mSurface : Color.mSurface
border.width: Math.max(1, Style.borderM)
anchors.verticalCenter: parent.verticalCenter
anchors.verticalCenterOffset: 0
x: root.checked ? switcher.width - width - 3 : 3
Behavior on x {
NumberAnimation {
duration: Style.animationFast
easing.type: Easing.OutCubic
}
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
onEntered: {
hovering = true;
root.entered();
}
onExited: {
hovering = false;
root.exited();
}
onClicked: {
root.toggled(!root.checked);
}
}
Behavior on color {
ColorAnimation {
duration: Style.animationFast
}
}
Behavior on border.color {
ColorAnimation {
duration: Style.animationFast
}
}
}
}

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,232 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Bluetooth
import qs.Utils
Singleton {
id: root
readonly property BluetoothAdapter adapter: Bluetooth.defaultAdapter
readonly property bool available: (adapter !== null)
readonly property bool enabled: adapter?.enabled ?? false
readonly property bool discovering: (adapter && adapter.discovering) ?? false
readonly property var devices: adapter ? adapter.devices : null
readonly property var pairedDevices: {
if (!adapter || !adapter.devices) {
return []
}
return adapter.devices.values.filter(dev => {
return dev && (dev.paired || dev.trusted)
})
}
readonly property var allDevicesWithBattery: {
if (!adapter || !adapter.devices) {
return []
}
return adapter.devices.values.filter(dev => {
return dev && dev.batteryAvailable && dev.battery > 0
})
}
function init() {
Logger.log("Bluetooth", "Service initialized")
}
Timer {
id: discoveryTimer
interval: 1000
repeat: false
onTriggered: adapter.discovering = true
}
Connections {
target: adapter
function onEnabledChanged() {
if (!adapter) {
Logger.warn("Bluetooth", "onEnabledChanged", "No adapter available")
return
}
Logger.debug("Bluetooth", "onEnableChanged", adapter.enabled)
if (adapter.enabled) {
discoveryTimer.running = true
}
}
}
function sortDevices(devices) {
return devices.sort((a, b) => {
var aName = a.name || a.deviceName || ""
var bName = b.name || b.deviceName || ""
var aHasRealName = aName.includes(" ") && aName.length > 3
var bHasRealName = bName.includes(" ") && bName.length > 3
if (aHasRealName && !bHasRealName)
return -1
if (!aHasRealName && bHasRealName)
return 1
var aSignal = (a.signalStrength !== undefined && a.signalStrength > 0) ? a.signalStrength : 0
var bSignal = (b.signalStrength !== undefined && b.signalStrength > 0) ? b.signalStrength : 0
return bSignal - aSignal
})
}
function getDeviceIcon(device) {
if (!device) {
return "bt-device-generic"
}
var name = (device.name || device.deviceName || "").toLowerCase()
var icon = (device.icon || "").toLowerCase()
if (icon.includes("headset") || icon.includes("audio") || name.includes("headphone") || name.includes("airpod") || name.includes("headset") || name.includes("arctis")) {
return "bt-device-headphones"
}
if (icon.includes("mouse") || name.includes("mouse")) {
return "bt-device-mouse"
}
if (icon.includes("keyboard") || name.includes("keyboard")) {
return "bt-device-keyboard"
}
if (icon.includes("phone") || name.includes("phone") || name.includes("iphone") || name.includes("android") || name.includes("samsung")) {
return "bt-device-phone"
}
if (icon.includes("watch") || name.includes("watch")) {
return "bt-device-watch"
}
if (icon.includes("speaker") || name.includes("speaker")) {
return "bt-device-speaker"
}
if (icon.includes("display") || name.includes("tv")) {
return "bt-device-tv"
}
return "bt-device-generic"
}
function canConnect(device) {
if (!device)
return false
/*
Paired
Means youve successfully exchanged keys with the device.
The devices remember each other and can authenticate without repeating the pairing process.
Example: once your headphones are paired, you dont need to type a PIN every time.
Hence, instead of !device.paired, should be device.connected
*/
return !device.connected && !device.pairing && !device.blocked
}
function canDisconnect(device) {
if (!device)
return false
return device.connected && !device.pairing && !device.blocked
}
function getStatusString(device) {
if (device.state === BluetoothDeviceState.Connecting) {
return "Connecting..."
}
if (device.pairing) {
return "Pairing..."
}
if (device.blocked) {
return "Blocked"
}
return ""
}
function getSignalStrength(device) {
if (!device || device.signalStrength === undefined || device.signalStrength <= 0) {
return "Signal: Unknown"
}
var signal = device.signalStrength
if (signal >= 80) {
return "Signal: Excellent"
}
if (signal >= 60) {
return "Signal: Good"
}
if (signal >= 40) {
return "Signal: Fair"
}
if (signal >= 20) {
return "Signal: Poor"
}
return "Signal: Very poor"
}
function getBattery(device) {
return `Battery: ${Math.round(device.battery * 100)}%`
}
function getSignalIcon(device) {
if (!device || device.signalStrength === undefined || device.signalStrength <= 0) {
return "antenna-bars-off"
}
var signal = device.signalStrength
if (signal >= 80) {
return "antenna-bars-5"
}
if (signal >= 60) {
return "antenna-bars-4"
}
if (signal >= 40) {
return "antenna-bars-3"
}
if (signal >= 20) {
return "antenna-bars-2"
}
return "antenna-bars-1"
}
function isDeviceBusy(device) {
if (!device) {
return false
}
return device.pairing || device.state === BluetoothDeviceState.Disconnecting || device.state === BluetoothDeviceState.Connecting
}
function connectDeviceWithTrust(device) {
if (!device) {
return
}
device.trusted = true
device.connect()
}
function disconnectDevice(device) {
if (!device) {
return
}
device.disconnect()
}
function forgetDevice(device) {
if (!device) {
return
}
device.trusted = false
device.forget()
}
function setBluetoothEnabled(state) {
if (!adapter) {
Logger.w("Bluetooth", "No adapter available")
return
}
Logger.i("Bluetooth", "SetBluetoothEnabled", state)
adapter.enabled = state
}
}

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(["sh", "-c", "brightnessctl -d $BRIGHTNESSCTL_DEVICE set "+ 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,34 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Utils
pragma Singleton
Singleton {
id: root
readonly property string cacheDir: Quickshell.env("HOME") + "/.cache/quickshell/"
// also create recording directory here
readonly property string recordingDir: Quickshell.env("HOME") + "/Videos/recordings/"
readonly property var cacheFiles: Object.freeze(["Location.json", "Ip.json", "Notifications.json", "LyricsOffset.txt", "Network.json"])
property bool loaded: false
readonly property string locationCacheFile: cacheDir + "Location.json"
readonly property string ipCacheFile: cacheDir + "Ip.json"
readonly property string notificationsCacheFile: cacheDir + "Notifications.json"
readonly property string lyricsOffsetCacheFile: cacheDir + "LyricsOffset.txt"
readonly property string networkCacheFile: cacheDir + "Network.json"
Process {
id: process
running: true
command: ["sh", "-c", `mkdir -p ${cacheDir} && mkdir -p ${recordingDir} && touch ${cacheDir + cacheFiles.join(` && touch ${cacheDir}`)}`]
onExited: (code, status) => {
if (code === 0)
loaded = true;
else
Logger.error("CacheService", `Failed to create cache files: ${command.join(" ")}`);
}
}
}

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,207 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Services
import qs.Utils
pragma Singleton
Singleton {
property alias ip: cacheFileAdapter.ip
readonly property string cacheFilePath: CacheService.ipCacheFile
readonly property string aliasFilePath: Qt.resolvedUrl("../Assets/Config/IpAliases.json")
property string countryCode: "N/A"
property string alias: ""
property real fetchInterval: 120 // in s
property real fetchTimeout: 10 // in s
readonly property string ipURL: "https://api.uyanide.com/ip"
readonly property string geoURL: "https://api.ipinfo.io/lite/"
property string geoURLToken: ""
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}${alias ? `\nAlias: ${alias}` : ""}`);
cacheFile.writeAdapter();
});
}
function refresh() {
fetchTimer.stop();
ip = "N/A";
fetchIP();
fetchTimer.start();
}
function updateAlias() {
if (!ip || ip === "N/A") {
alias = "";
return ;
}
alias = "";
for (let i = 0; i < aliasFileAdapter.aliases.length; i++) {
let entry = aliasFileAdapter.aliases[i];
if (entry.ip === ip) {
alias = entry.alias;
Logger.log("IpService", "Found alias for IP " + ip + ": " + alias);
break;
}
}
}
Component.onCompleted: () => {
return updateAlias();
}
onIpChanged: () => {
return updateAlias();
}
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");
if (geoURLToken[geoURLToken.length - 1] === "\n")
geoURLToken = geoURLToken.slice(0, -1);
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
}
}
FileView {
id: aliasFile
path: aliasFilePath
watchChanges: true
onLoaded: {
Logger.log("IpService", "Loaded IP aliases from file, total aliases: " + aliasFileAdapter.aliases.length);
}
JsonAdapter {
id: aliasFileAdapter
property var aliases: []
}
}
}

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,140 @@
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
readonly property int currentIndex: linesCount - linesAhead - 1
readonly property string offsetFile: CacheService.lyricsOffsetCacheFile
property int offset: 0 // in ms
readonly 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", `pkill -x spotify-lyrics -u $USER; spotify-lyrics 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");
// kinda ugly but works, meanwhile:
// listenProcess.signal(9)
// listenProcess.signal(15)
// listenProcess.running = false
// counting on exec() to terminate previous exec()
// all don't work
listenProcess.exec(["sh", "-c", `pkill -x spotify-lyrics -u $USER`]);
}
}
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,624 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Utils
import qs.Services
Singleton {
id: root
// Core state
property var networks: ({})
property bool scanning: false
property bool connecting: false
property string connectingTo: ""
property string lastError: ""
property bool ethernetConnected: false
property string disconnectingFrom: ""
property string forgettingNetwork: ""
property bool ignoreScanResults: false
property bool scanPending: false
// Persistent cache
property string cacheFile: CacheService.networkCacheFile
readonly property string cachedLastConnected: cacheAdapter.lastConnected
readonly property var cachedNetworks: cacheAdapter.knownNetworks
// Cache file handling
FileView {
id: cacheFileView
path: root.cacheFile
JsonAdapter {
id: cacheAdapter
property var knownNetworks: ({})
property string lastConnected: ""
}
onLoadFailed: {
cacheAdapter.knownNetworks = ({})
cacheAdapter.lastConnected = ""
}
}
Component.onCompleted: {
Logger.log("Network", "Service initialized")
syncWifiState()
scan()
}
// Save cache with debounce
Timer {
id: saveDebounce
interval: 1000
onTriggered: cacheFileView.writeAdapter()
}
function saveCache() {
saveDebounce.restart()
}
// Delayed scan timer
Timer {
id: delayedScanTimer
interval: 7000
onTriggered: scan()
}
// Ethernet check timer
// Always running every 30s
Timer {
id: ethernetCheckTimer
interval: 30000
running: true
repeat: true
onTriggered: ethernetStateProcess.running = true
}
// Core functions
function syncWifiState() {
wifiStateProcess.running = true
}
function setWifiEnabled(enabled) {
SettingsService.wifiEnabled = enabled
wifiStateEnableProcess.running = true
}
function scan() {
if (!SettingsService.wifiEnabled)
return
if (scanning) {
// Mark current scan results to be ignored and schedule a new scan
Logger.log("Network", "Scan already in progress, will ignore results and rescan")
ignoreScanResults = true
scanPending = true
return
}
scanning = true
lastError = ""
ignoreScanResults = false
// Get existing profiles first, then scan
profileCheckProcess.running = true
Logger.log("Network", "Wi-Fi scan in progress...")
}
function connect(ssid, password = "") {
if (connecting)
return
connecting = true
connectingTo = ssid
lastError = ""
// Check if we have a saved connection
if (networks[ssid]?.existing || cachedNetworks[ssid]) {
connectProcess.mode = "saved"
connectProcess.ssid = ssid
connectProcess.password = ""
} else {
connectProcess.mode = "new"
connectProcess.ssid = ssid
connectProcess.password = password
}
connectProcess.running = true
}
function disconnect(ssid) {
disconnectingFrom = ssid
disconnectProcess.ssid = ssid
disconnectProcess.running = true
}
function forget(ssid) {
forgettingNetwork = ssid
// Remove from cache
let known = cacheAdapter.knownNetworks
delete known[ssid]
cacheAdapter.knownNetworks = known
if (cacheAdapter.lastConnected === ssid) {
cacheAdapter.lastConnected = ""
}
saveCache()
// Remove from system
forgetProcess.ssid = ssid
forgetProcess.running = true
}
// Helper function to immediately update network status
function updateNetworkStatus(ssid, connected) {
let nets = networks
// Update all networks connected status
for (let key in nets) {
if (nets[key].connected && key !== ssid) {
nets[key].connected = false
}
}
// Update the target network if it exists
if (nets[ssid]) {
nets[ssid].connected = connected
nets[ssid].existing = true
nets[ssid].cached = true
} else if (connected) {
// Create a temporary entry if network doesn't exist yet
nets[ssid] = {
"ssid": ssid,
"security": "--",
"signal": 100,
"connected": true,
"existing": true,
"cached": true
}
}
// Trigger property change notification
networks = ({})
networks = nets
}
// Helper functions
function signalIcon(signal) {
if (signal >= 80)
return "wifi"
if (signal >= 50)
return "wifi-2"
if (signal >= 20)
return "wifi-1"
return "wifi-0"
}
function isSecured(security) {
return security && security !== "--" && security.trim() !== ""
}
// Processes
Process {
id: ethernetStateProcess
running: true
command: ["nmcli", "-t", "-f", "DEVICE,TYPE,STATE", "device"]
stdout: StdioCollector {
onStreamFinished: {
const connected = text.split("\n").some(line => {
const parts = line.split(":")
return parts[1] === "ethernet" && parts[2] === "connected"
})
if (root.ethernetConnected !== connected) {
root.ethernetConnected = connected
Logger.log("Network", "Ethernet connected:", root.ethernetConnected)
}
}
}
}
// Only check the state of the actual interface
// and update our setting to be in sync.
Process {
id: wifiStateProcess
running: false
command: ["nmcli", "radio", "wifi"]
stdout: StdioCollector {
onStreamFinished: {
const enabled = text.trim() === "enabled"
Logger.log("Network", "Wi-Fi adapter was detect as enabled:", enabled)
if (SettingsService.wifiEnabled !== enabled) {
SettingsService.wifiEnabled = enabled
}
}
}
}
// Process to enable/disable the Wi-Fi interface
Process {
id: wifiStateEnableProcess
running: false
command: ["nmcli", "radio", "wifi", SettingsService.wifiEnabled ? "on" : "off"]
stdout: StdioCollector {
onStreamFinished: {
Logger.log("Network", "Wi-Fi state change command executed.")
// Re-check the state to ensure it's in sync
syncWifiState()
}
}
stderr: StdioCollector {
onStreamFinished: {
if (text.trim()) {
Logger.warn("Network", "Error changing Wi-Fi state: " + text)
}
}
}
}
// Helper process to get existing profiles
Process {
id: profileCheckProcess
running: false
command: ["nmcli", "-t", "-f", "NAME", "connection", "show"]
stdout: StdioCollector {
onStreamFinished: {
if (root.ignoreScanResults) {
Logger.log("Network", "Ignoring profile check results (new scan requested)")
root.scanning = false
// Check if we need to start a new scan
if (root.scanPending) {
root.scanPending = false
delayedScanTimer.interval = 100
delayedScanTimer.restart()
}
return
}
const profiles = {}
const lines = text.split("\n").filter(l => l.trim())
for (const line of lines) {
profiles[line.trim()] = true
}
scanProcess.existingProfiles = profiles
scanProcess.running = true
}
}
}
Process {
id: scanProcess
running: false
command: ["nmcli", "-t", "-f", "SSID,SECURITY,SIGNAL,IN-USE", "device", "wifi", "list", "--rescan", "yes"]
property var existingProfiles: ({})
stdout: StdioCollector {
onStreamFinished: {
if (root.ignoreScanResults) {
Logger.log("Network", "Ignoring scan results (new scan requested)")
root.scanning = false
// Check if we need to start a new scan
if (root.scanPending) {
root.scanPending = false
delayedScanTimer.interval = 100
delayedScanTimer.restart()
}
return
}
// Process the scan results as before...
const lines = text.split("\n")
const networksMap = {}
for (var i = 0; i < lines.length; ++i) {
const line = lines[i].trim()
if (!line)
continue
// Parse from the end to handle SSIDs with colons
// Format is SSID:SECURITY:SIGNAL:IN-USE
// We know the last 3 fields, so everything else is SSID
const lastColonIdx = line.lastIndexOf(":")
if (lastColonIdx === -1) {
Logger.warn("Network", "Malformed nmcli output line:", line)
continue
}
const inUse = line.substring(lastColonIdx + 1)
const remainingLine = line.substring(0, lastColonIdx)
const secondLastColonIdx = remainingLine.lastIndexOf(":")
if (secondLastColonIdx === -1) {
Logger.warn("Network", "Malformed nmcli output line:", line)
continue
}
const signal = remainingLine.substring(secondLastColonIdx + 1)
const remainingLine2 = remainingLine.substring(0, secondLastColonIdx)
const thirdLastColonIdx = remainingLine2.lastIndexOf(":")
if (thirdLastColonIdx === -1) {
Logger.warn("Network", "Malformed nmcli output line:", line)
continue
}
const security = remainingLine2.substring(thirdLastColonIdx + 1)
const ssid = remainingLine2.substring(0, thirdLastColonIdx)
if (ssid) {
const signalInt = parseInt(signal) || 0
const connected = inUse === "*"
// Track connected network in cache
if (connected && cacheAdapter.lastConnected !== ssid) {
cacheAdapter.lastConnected = ssid
saveCache()
}
if (!networksMap[ssid]) {
networksMap[ssid] = {
"ssid": ssid,
"security": security || "--",
"signal": signalInt,
"connected": connected,
"existing": ssid in scanProcess.existingProfiles,
"cached": ssid in cacheAdapter.knownNetworks
}
} else {
// Keep the best signal for duplicate SSIDs
const existingNet = networksMap[ssid]
if (connected) {
existingNet.connected = true
}
if (signalInt > existingNet.signal) {
existingNet.signal = signalInt
existingNet.security = security || "--"
}
}
}
}
// Logging
const oldSSIDs = Object.keys(root.networks)
const newSSIDs = Object.keys(networksMap)
const newNetworks = newSSIDs.filter(ssid => !oldSSIDs.includes(ssid))
const lostNetworks = oldSSIDs.filter(ssid => !newSSIDs.includes(ssid))
if (newNetworks.length > 0 || lostNetworks.length > 0) {
if (newNetworks.length > 0) {
Logger.log("Network", "New Wi-Fi SSID discovered:", newNetworks.join(", "))
}
if (lostNetworks.length > 0) {
Logger.log("Network", "Wi-Fi SSID disappeared:", lostNetworks.join(", "))
}
Logger.log("Network", "Total Wi-Fi SSIDs:", Object.keys(networksMap).length)
}
Logger.log("Network", "Wi-Fi scan completed")
root.networks = networksMap
root.scanning = false
// Check if we need to start a new scan
if (root.scanPending) {
root.scanPending = false
delayedScanTimer.interval = 100
delayedScanTimer.restart()
}
}
}
stderr: StdioCollector {
onStreamFinished: {
root.scanning = false
if (text.trim()) {
Logger.warn("Network", "Scan error: " + text)
// If scan fails, retry
delayedScanTimer.interval = 5000
delayedScanTimer.restart()
}
}
}
}
Process {
id: connectProcess
property string mode: "new"
property string ssid: ""
property string password: ""
running: false
command: {
if (mode === "saved") {
return ["nmcli", "connection", "up", "id", ssid]
} else {
const cmd = ["nmcli", "device", "wifi", "connect", ssid]
if (password) {
cmd.push("password", password)
}
return cmd
}
}
stdout: StdioCollector {
onStreamFinished: {
// Check if the output actually indicates success
// nmcli outputs "Device '...' successfully activated" or "Connection successfully activated"
// on success. Empty output or other messages indicate failure.
const output = text.trim()
if (!output || (!output.includes("successfully activated") && !output.includes("Connection successfully"))) {
// No success message - likely an error occurred
// Don't update anything, let stderr handler deal with it
return
}
// Success - update cache
let known = cacheAdapter.knownNetworks
known[connectProcess.ssid] = {
"profileName": connectProcess.ssid,
"lastConnected": Date.now()
}
cacheAdapter.knownNetworks = known
cacheAdapter.lastConnected = connectProcess.ssid
saveCache()
// Immediately update the UI before scanning
root.updateNetworkStatus(connectProcess.ssid, true)
root.connecting = false
root.connectingTo = ""
Logger.log("Network", `Connected to network: '${connectProcess.ssid}'`)
ToastService.showNotice(I18n.tr("wifi.panel.title"), I18n.tr("toast.wifi.connected", {
"ssid": connectProcess.ssid
}))
// Still do a scan to get accurate signal and security info
delayedScanTimer.interval = 5000
delayedScanTimer.restart()
}
}
stderr: StdioCollector {
onStreamFinished: {
root.connecting = false
root.connectingTo = ""
if (text.trim()) {
// Parse common errors
if (text.includes("Secrets were required") || text.includes("no secrets provided")) {
root.lastError = "Incorrect password"
forget(connectProcess.ssid)
} else if (text.includes("No network with SSID")) {
root.lastError = "Network not found"
} else if (text.includes("Timeout")) {
root.lastError = "Connection timeout"
} else {
root.lastError = text.split("\n")[0].trim()
}
Logger.warn("Network", "Connect error: " + text)
}
}
}
}
Process {
id: disconnectProcess
property string ssid: ""
running: false
command: ["nmcli", "connection", "down", "id", ssid]
stdout: StdioCollector {
onStreamFinished: {
Logger.log("Network", `Disconnected from network: '${disconnectProcess.ssid}'`)
ToastService.showNotice(I18n.tr("wifi.panel.title"), I18n.tr("toast.wifi.disconnected", {
"ssid": disconnectProcess.ssid
}))
// Immediately update UI on successful disconnect
root.updateNetworkStatus(disconnectProcess.ssid, false)
root.disconnectingFrom = ""
// Do a scan to refresh the list
delayedScanTimer.interval = 1000
delayedScanTimer.restart()
}
}
stderr: StdioCollector {
onStreamFinished: {
root.disconnectingFrom = ""
if (text.trim()) {
Logger.warn("Network", "Disconnect error: " + text)
}
// Still trigger a scan even on error
delayedScanTimer.interval = 5000
delayedScanTimer.restart()
}
}
}
Process {
id: forgetProcess
property string ssid: ""
running: false
// Try multiple common profile name patterns
command: ["sh", "-c", `
ssid="$1"
deleted=false
# Try exact SSID match first
if nmcli connection delete id "$ssid" 2>/dev/null; then
echo "Deleted profile: $ssid"
deleted=true
fi
# Try "Auto <SSID>" pattern
if nmcli connection delete id "Auto $ssid" 2>/dev/null; then
echo "Deleted profile: Auto $ssid"
deleted=true
fi
# Try "<SSID> 1", "<SSID> 2", etc. patterns
for i in 1 2 3; do
if nmcli connection delete id "$ssid $i" 2>/dev/null; then
echo "Deleted profile: $ssid $i"
deleted=true
fi
done
if [ "$deleted" = "false" ]; then
echo "No profiles found for SSID: $ssid"
fi
`, "--", ssid]
stdout: StdioCollector {
onStreamFinished: {
Logger.log("Network", `Forget network: "${forgetProcess.ssid}"`)
Logger.log("Network", text.trim().replace(/[\r\n]/g, " "))
// Update both cached and existing status immediately
let nets = root.networks
if (nets[forgetProcess.ssid]) {
nets[forgetProcess.ssid].cached = false
nets[forgetProcess.ssid].existing = false
// Trigger property change
root.networks = ({})
root.networks = nets
}
root.forgettingNetwork = ""
// Scan to verify the profile is gone
delayedScanTimer.interval = 5000
delayedScanTimer.restart()
}
}
stderr: StdioCollector {
onStreamFinished: {
root.forgettingNetwork = ""
if (text.trim() && !text.includes("No profiles found")) {
Logger.warn("Network", "Forget error: " + text)
}
// Still Trigger a scan even on error
delayedScanTimer.interval = 5000
delayedScanTimer.restart()
}
}
}
}

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: ["sh", "-c", "which kquitapp6 && 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 {
readonly property string recordingDir: CacheService.recordingDir
property bool isRecording: false
property bool isStopping: false
readonly property string codec: "av1_nvenc"
readonly property string container: "mkv"
readonly property string pixelFormat: "p010le"
property string recordingDisplay: ""
readonly property int framerate: 60
readonly property var codecParams: Object.freeze(["preset=p5", "rc=vbr", "cq=18", "b:v=80M", "maxrate=120M", "bufsize=160M", "color_range=tv"])
readonly property var filterArgs: Object.freeze([])
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,41 @@
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 alias wifiEnabled: adapter.wifiEnabled
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"
property bool wifiEnabled: true
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,12 @@
import QtQuick
import Quickshell
pragma Singleton
Singleton {
id: root
function write(text) {
Quickshell.execDetached(["sh", "-c", `echo ${text} | wl-copy -n`]);
}
}

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}`);
// }
// });
// }

View File

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

View File

@@ -0,0 +1,69 @@
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"
}
WiFiPanel {
id: wifiPanel
objectName: "wifiPanel"
}
BluetoothPanel {
id: bluetoothPanel
objectName: "bluetoothPanel"
}
}
}
}