quickshell: should be everything I want now

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

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

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

View File

@@ -0,0 +1 @@
0

View File

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

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,61 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import qs.Constants
import qs.Modules.Panel.Misc
import qs.Noctalia
import qs.Services
import qs.Utils
// Unified system card: monitors CPU, temp, memory, disk
NBox {
id: root
compact: true
ColumnLayout {
id: content
anchors.fill: parent
anchors.margins: Style.marginXS
spacing: Style.marginS
MonitorSlider {
icon: "cpu-usage"
value: SystemStatService.cpuUsage
from: 0
to: 100
colorFill: Colors.teal
Layout.fillWidth: true
}
MonitorSlider {
icon: "memory"
value: SystemStatService.memPercent
from: 0
to: 100
colorFill: Colors.green
Layout.fillWidth: true
}
MonitorSlider {
icon: "cpu-temperature"
value: SystemStatService.cpuTemp
from: 0
to: 100
colorFill: Colors.yellow
Layout.fillWidth: true
}
MonitorSlider {
icon: "storage"
value: SystemStatService.diskPercent
from: 0
to: 100
colorFill: Colors.peach
Layout.fillWidth: true
}
}
}

View File

@@ -0,0 +1,175 @@
import QtQuick
import QtQuick.Effects
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Services.UPower
import Quickshell.Widgets
import qs.Constants
import qs.Noctalia
import qs.Services
import qs.Utils
ColumnLayout {
id: root
readonly property bool hasPP: PowerProfileService.available
spacing: Style.marginM
NBox {
id: whoamiBox
property string uptimeText: "--"
property string hostname: "--"
function updateSystemInfo() {
uptimeProcess.running = true;
hostnameProcess.running = true;
}
Layout.fillWidth: true
Layout.fillHeight: true
RowLayout {
id: content
spacing: root.spacing
anchors.fill: parent
anchors.margins: root.spacing
NImageCircled {
width: Style.baseWidgetSize * 1.5
height: Style.baseWidgetSize * 1.5
imagePath: Quickshell.shellDir + "/Assets/Images/Avatar.jpg"
fallbackIcon: "person"
borderColor: Color.mPrimary
borderWidth: Math.max(1, Style.borderM)
Layout.alignment: Qt.AlignVCenter
Layout.topMargin: Style.marginXS
}
ColumnLayout {
Layout.fillWidth: true
spacing: Style.marginXXS
NText {
text: `${Quickshell.env("USER") || "user"} @ ${whoamiBox.hostname}`
font.weight: Style.fontWeightBold
font.pointSize: Style.fontSizeL
font.capitalization: Font.Capitalize
}
NText {
text: "Uptime: " + whoamiBox.uptimeText
font.pointSize: Style.fontSizeM
color: Color.mOnSurfaceVariant
}
}
Item {
Layout.fillWidth: true
}
}
// ----------------------------------
// Uptime
Timer {
interval: 60000
repeat: true
running: true
onTriggered: uptimeProcess.running = true
}
Process {
id: uptimeProcess
command: ["cat", "/proc/uptime"]
running: true
stdout: StdioCollector {
onStreamFinished: {
var uptimeSeconds = parseFloat(this.text.trim().split(' ')[0]);
whoamiBox.uptimeText = Time.formatVagueHumanReadableDuration(uptimeSeconds);
uptimeProcess.running = false;
}
}
}
Process {
id: hostnameProcess
command: ["cat", "/etc/hostname"]
running: true
stdout: StdioCollector {
onStreamFinished: {
whoamiBox.hostname = this.text.trim();
hostnameProcess.running = false;
}
}
}
}
RowLayout {
id: utilitiesRow
Layout.fillWidth: true
// Performance
NIconButton {
implicitHeight: 32
implicitWidth: 32
icon: PowerProfileService.getIcon(PowerProfile.Performance)
enabled: hasPP
opacity: enabled ? Style.opacityFull : Style.opacityMedium
colorBgHover: Colors.red
colorBg: (enabled && PowerProfileService.profile === PowerProfile.Performance) ? Colors.red : Color.transparent
colorFg: (enabled && PowerProfileService.profile === PowerProfile.Performance) ? Color.mOnPrimary : Colors.red
onClicked: PowerProfileService.setProfile(PowerProfile.Performance)
}
// Balanced
NIconButton {
implicitHeight: 32
implicitWidth: 32
icon: PowerProfileService.getIcon(PowerProfile.Balanced)
enabled: hasPP
opacity: enabled ? Style.opacityFull : Style.opacityMedium
colorBgHover: Colors.blue
colorBg: (enabled && PowerProfileService.profile === PowerProfile.Balanced) ? Colors.blue : Color.transparent
colorFg: (enabled && PowerProfileService.profile === PowerProfile.Balanced) ? Color.mOnPrimary : Colors.blue
onClicked: PowerProfileService.setProfile(PowerProfile.Balanced)
}
// Eco
NIconButton {
implicitHeight: 32
implicitWidth: 32
icon: PowerProfileService.getIcon(PowerProfile.PowerSaver)
enabled: hasPP
opacity: enabled ? Style.opacityFull : Style.opacityMedium
colorBgHover: Colors.green
colorBg: (enabled && PowerProfileService.profile === PowerProfile.PowerSaver) ? Colors.green : Color.transparent
colorFg: (enabled && PowerProfileService.profile === PowerProfile.PowerSaver) ? Color.mOnPrimary : Colors.green
onClicked: PowerProfileService.setProfile(PowerProfile.PowerSaver)
}
Item {
Layout.fillWidth: true
}
// Lyrics Offset
NText {
text: `Lyrics Offset: ${LyricsService.offset >= 0 ? '+' : ''}${LyricsService.offset} ms`
Layout.alignment: Qt.AlignVCenter
}
}
}

View File

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

View File

@@ -0,0 +1,54 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import qs.Constants
import qs.Noctalia
Item {
id: root
property string icon: "volume-high"
property real value: 50
property real from: 0
property real to: 100
property color colorFill: Colors.primary
property color colorRest: Colors.surface0
implicitHeight: layout.implicitHeight
RowLayout {
id: layout
anchors.fill: parent
spacing: Style.marginS
NIcon {
id: iconItem
icon: root.icon
color: root.colorFill
}
Rectangle {
id: whole
Layout.fillWidth: true
color: root.colorRest
radius: height / 2
height: Style.baseWidgetSize * 0.3
Rectangle {
id: fill
width: Math.max(0, Math.min(whole.width, (root.value - root.from) / (root.to - root.from) * whole.width))
height: parent.height
color: root.colorFill
radius: height / 2
anchors.left: parent.left
}
}
}
}

View File

@@ -0,0 +1,28 @@
import Qt5Compat.GraphicalEffects
import QtQuick
import QtQuick.Layouts
import qs.Constants
import qs.Noctalia
// Rounded group container using the variant surface color.
// To be used in side panels and settings panes to group fields or buttons.
Rectangle {
id: root
property bool compact: false
implicitWidth: childrenRect.width
implicitHeight: childrenRect.height
color: compact ? Color.transparent : Color.mSurfaceVariant
radius: Style.radiusM
layer.enabled: !compact
layer.effect: DropShadow {
horizontalOffset: 6
verticalOffset: 6
radius: 8
samples: 12
color: Qt.rgba(0, 0, 0, 0.3)
}
}

View File

@@ -0,0 +1,53 @@
import QtQuick
import qs.Constants
import qs.Noctalia
Item {
id: root
property bool running: true
property color color: Color.mPrimary
property int size: Style.baseWidgetSize
property int strokeWidth: Style.borderL
property int duration: Style.animationSlow * 2
implicitWidth: size
implicitHeight: size
Canvas {
id: canvas
property real rotationAngle: 0
anchors.fill: parent
onPaint: {
var ctx = getContext("2d");
ctx.reset();
var centerX = width / 2;
var centerY = height / 2;
var radius = Math.min(width, height) / 2 - strokeWidth / 2;
ctx.strokeStyle = root.color;
ctx.lineWidth = Math.max(1, root.strokeWidth);
ctx.lineCap = "round";
// Draw arc with gap (270 degrees with 90 degree gap)
ctx.beginPath();
ctx.arc(centerX, centerY, radius, -Math.PI / 2 + rotationAngle, -Math.PI / 2 + rotationAngle + Math.PI * 1.5);
ctx.stroke();
}
onRotationAngleChanged: {
requestPaint();
}
NumberAnimation {
target: canvas
property: "rotationAngle"
running: root.running
from: 0
to: 2 * Math.PI
duration: root.duration
loops: Animation.Infinite
}
}
}

View File

@@ -0,0 +1,122 @@
import QtQuick
import QtQuick.Layouts
import qs.Noctalia
import qs.Services
import qs.Utils
// Compact circular statistic display using Layout management
Rectangle {
id: root
property real value: 0 // 0..100 (or any range visually mapped)
property string icon: ""
property string suffix: "%"
// When nested inside a parent group (NBox), you can make it flat
property bool flat: false
// Scales the internal content (labels, gauge, icon) without changing the
// outer width/height footprint of the component
property real contentScale: 1
width: 68
height: 92
color: flat ? Color.transparent : Color.mSurface
radius: Style.radiusS
border.color: flat ? Color.transparent : Color.mSurfaceVariant
border.width: flat ? 0 : Math.max(1, Style.borderS)
// Repaint gauge when the bound value changes
onValueChanged: gauge.requestPaint()
ColumnLayout {
id: mainLayout
anchors.fill: parent
anchors.margins: Style.marginS * contentScale
spacing: 0
// Main gauge container
Item {
id: gaugeContainer
Layout.fillWidth: true
Layout.fillHeight: true
Layout.alignment: Qt.AlignCenter
Layout.preferredWidth: 68 * contentScale
Layout.preferredHeight: 68 * contentScale
Canvas {
// 390° (equivalent to 30°)
id: gauge
anchors.fill: parent
renderStrategy: Canvas.Cooperative
onPaint: {
const ctx = getContext("2d");
const w = width, h = height;
const cx = w / 2, cy = h / 2;
const r = Math.min(w, h) / 2 - 5 * contentScale;
// Rotated 90° to the right: gap at the bottom
// Start at 150° and end at 390° (30°) → bottom opening
const start = Math.PI * 5 / 6;
// 150°
const endBg = Math.PI * 13 / 6;
ctx.reset();
ctx.lineWidth = 6 * contentScale;
// Track uses surfaceVariant for stronger contrast
ctx.strokeStyle = Color.mSurface;
ctx.beginPath();
ctx.arc(cx, cy, r, start, endBg);
ctx.stroke();
// Value arc
const ratio = Math.max(0, Math.min(1, root.value / 100));
const end = start + (endBg - start) * ratio;
ctx.strokeStyle = Color.mPrimary;
ctx.beginPath();
ctx.arc(cx, cy, r, start, end);
ctx.stroke();
}
}
// Percent centered in the circle
NText {
id: valueLabel
anchors.centerIn: parent
anchors.verticalCenterOffset: -4 * contentScale
text: `${root.value}${root.suffix}`
pointSize: Style.fontSizeM * contentScale
font.weight: Style.fontWeightBold
color: Color.mOnSurface
horizontalAlignment: Text.AlignHCenter
}
// Tiny circular badge for the icon, positioned inside below the percentage
Rectangle {
id: iconBadge
width: iconText.implicitWidth + Style.marginXXS
height: width
radius: width / 2
color: Color.mPrimary
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: valueLabel.bottom
anchors.topMargin: 8 * contentScale
NIcon {
id: iconText
anchors.centerIn: parent
icon: root.icon
color: Color.mOnPrimary
pointSize: Style.fontSizeS
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
}
}

View File

@@ -0,0 +1,124 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Constants
import qs.Noctalia
import qs.Services
import qs.Utils
Popup {
id: root
property alias model: listView.model
property real itemHeight: 36
property real itemPadding: Style.marginM
signal triggered(string action)
// Helper function to open at mouse position
function openAt(x, y) {
root.x = x;
root.y = y;
root.open();
}
// Helper function to open at item
function openAtItem(item, mouseX, mouseY) {
var pos = item.mapToItem(root.parent, mouseX || 0, mouseY || 0);
openAt(pos.x, pos.y);
}
width: 180
padding: Style.marginS
onOpened: PanelService.willOpenPopup(root)
onClosed: PanelService.willClosePopup(root)
background: Rectangle {
color: Color.mSurfaceVariant
border.color: Color.mOutline
border.width: Math.max(1, Style.borderS)
radius: Style.radiusM
}
contentItem: NListView {
id: listView
implicitHeight: contentHeight
spacing: Style.marginXXS
interactive: contentHeight > root.height
delegate: ItemDelegate {
id: menuItem
// Store reference to the popup
property var popup: root
width: listView.width
height: modelData.visible !== false ? root.itemHeight : 0
visible: modelData.visible !== false
opacity: modelData.enabled !== false ? 1 : 0.5
enabled: modelData.enabled !== false
onClicked: {
if (enabled) {
popup.triggered(modelData.action || modelData.key || index.toString());
popup.close();
}
}
background: Rectangle {
color: menuItem.hovered && menuItem.enabled ? Color.mTertiary : Color.transparent
radius: Style.radiusS
Behavior on color {
ColorAnimation {
duration: Style.animationFast
}
}
}
contentItem: RowLayout {
spacing: Style.marginS
// Optional icon
NIcon {
visible: modelData.icon !== undefined
icon: modelData.icon || ""
pointSize: Style.fontSizeM
color: menuItem.hovered && menuItem.enabled ? Color.mOnTertiary : Color.mOnSurface
Layout.leftMargin: root.itemPadding
Behavior on color {
ColorAnimation {
duration: Style.animationFast
}
}
}
NText {
text: modelData.label || modelData.text || ""
pointSize: Style.fontSizeM
color: menuItem.hovered && menuItem.enabled ? Color.mOnTertiary : Color.mOnSurface
verticalAlignment: Text.AlignVCenter
Layout.fillWidth: true
Layout.leftMargin: modelData.icon === undefined ? root.itemPadding : 0
Behavior on color {
ColorAnimation {
duration: Style.animationFast
}
}
}
}
}
}
}

View File

@@ -0,0 +1,35 @@
import QtQuick
import Quickshell
import Quickshell.Widgets
import qs.Constants
Rectangle {
width: parent.width
height: Math.max(1, Style.borderS)
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop {
position: 0
color: Color.transparent
}
GradientStop {
position: 0.1
color: Color.mOutline
}
GradientStop {
position: 0.9
color: Color.mOutline
}
GradientStop {
position: 1
color: Color.transparent
}
}
}

View File

@@ -0,0 +1,28 @@
import QtQuick
import QtQuick.Layouts
import qs.Constants
import qs.Noctalia
Text {
id: root
property string icon: Icons.defaultIcon
property real pointSize: Style.fontSizeL
visible: (icon !== undefined) && (icon !== "")
text: {
if ((icon === undefined) || (icon === ""))
return "";
if (Icons.get(icon) === undefined) {
Logger.warn("Icon", `"${icon}"`, "doesn't exist in the icons font");
Logger.callStack();
return Icons.get(Icons.defaultIcon);
}
return Icons.get(icon);
}
font.family: Icons.fontFamily
font.pointSize: root.pointSize
color: Color.mOnSurface
verticalAlignment: Text.AlignVCenter
}

View File

@@ -0,0 +1,92 @@
import QtQuick
import Quickshell
import Quickshell.Widgets
import qs.Constants
import qs.Noctalia
Rectangle {
id: root
property real baseSize: Style.baseWidgetSize
property string icon
property bool enabled: true
property bool allowClickWhenDisabled: false
property bool hovering: false
property bool compact: false
property color colorBg: Color.mSurfaceVariant
property color colorFg: Color.mPrimary
property color colorBgHover: Color.mTertiary
property color colorFgHover: Color.mOnTertiary
property color colorBorder: Color.transparent
property color colorBorderHover: Color.transparent
signal entered()
signal exited()
signal clicked()
signal rightClicked()
signal middleClicked()
implicitWidth: Math.round(baseSize)
implicitHeight: Math.round(baseSize)
opacity: root.enabled ? Style.opacityFull : Style.opacityMedium
color: root.enabled && root.hovering ? colorBgHover : colorBg
radius: width * 0.5
border.color: root.enabled && root.hovering ? colorBorderHover : colorBorder
border.width: Math.max(1, Style.borderS)
NIcon {
icon: root.icon
pointSize: Math.max(1, root.compact ? root.width * 0.65 : root.width * 0.48)
color: root.enabled && root.hovering ? colorFgHover : colorFg
// Center horizontally
x: (root.width - width) / 2
// Center vertically accounting for font metrics
y: (root.height - height) / 2 + (height - contentHeight) / 2
Behavior on color {
ColorAnimation {
duration: Style.animationFast
easing.type: Easing.InOutQuad
}
}
}
MouseArea {
// Always enabled to allow hover/tooltip even when the button is disabled
enabled: true
anchors.fill: parent
cursorShape: root.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
hoverEnabled: true
onEntered: {
hovering = root.enabled ? true : false;
root.entered();
}
onExited: {
hovering = false;
root.exited();
}
onClicked: function(mouse) {
if (!root.enabled && !allowClickWhenDisabled)
return ;
if (mouse.button === Qt.LeftButton)
root.clicked();
else if (mouse.button === Qt.RightButton)
root.rightClicked();
else if (mouse.button === Qt.MiddleButton)
root.middleClicked();
}
}
Behavior on color {
ColorAnimation {
duration: Style.animationNormal
easing.type: Easing.InOutQuad
}
}
}

View File

@@ -0,0 +1,85 @@
import QtQuick
import QtQuick.Effects
import Quickshell
import Quickshell.Widgets
import qs.Constants
import qs.Noctalia
import qs.Services
Rectangle {
id: root
property string imagePath: ""
property color borderColor: Color.transparent
property real borderWidth: 0
property string fallbackIcon: ""
property real fallbackIconSize: Style.fontSizeXXL
color: Color.transparent
radius: parent.width * 0.5
anchors.margins: Style.marginXXS
Rectangle {
color: Color.transparent
anchors.fill: parent
Image {
id: img
anchors.fill: parent
source: imagePath
visible: false // Hide since we're using it as shader source
mipmap: true
smooth: true
asynchronous: true
antialiasing: true
fillMode: Image.PreserveAspectCrop
}
ShaderEffect {
property var source
property real imageOpacity: root.opacity
anchors.fill: parent
fragmentShader: Qt.resolvedUrl(Quickshell.shellDir + "/Shaders/qsb/circled_image.frag.qsb")
supportsAtlasTextures: false
blending: true
source: ShaderEffectSource {
sourceItem: img
hideSource: true
live: true
recursive: false
format: ShaderEffectSource.RGBA
}
}
// Fallback icon
Loader {
active: fallbackIcon !== undefined && fallbackIcon !== "" && (imagePath === undefined || imagePath === "")
anchors.centerIn: parent
sourceComponent: NIcon {
anchors.centerIn: parent
icon: fallbackIcon
pointSize: fallbackIconSize
z: 0
}
}
}
// Border
Rectangle {
anchors.fill: parent
radius: parent.radius
color: Color.transparent
border.color: parent.borderColor
border.width: parent.borderWidth
antialiasing: true
z: 10
}
}

View File

@@ -0,0 +1,103 @@
import QtQuick
import QtQuick.Effects
import Quickshell
import Quickshell.Widgets
import qs.Constants
import qs.Noctalia
Rectangle {
id: root
property string imagePath: ""
property color borderColor: Color.transparent
property real borderWidth: 0
property real imageRadius: width * 0.5
property string fallbackIcon: ""
property real fallbackIconSize: Style.fontSizeXXL
property real scaledRadius: imageRadius
signal statusChanged(int status)
color: Color.transparent
radius: scaledRadius
anchors.margins: Style.marginXXS
Rectangle {
color: Color.transparent
anchors.fill: parent
Image {
id: img
anchors.fill: parent
source: imagePath
visible: false // Hide since we're using it as shader source
mipmap: true
smooth: true
asynchronous: true
antialiasing: true
fillMode: Image.PreserveAspectCrop
onStatusChanged: root.statusChanged(status)
}
ShaderEffect {
property var source
// Use custom property names to avoid conflicts with final properties
property real itemWidth: root.width
property real itemHeight: root.height
property real cornerRadius: root.radius
property real imageOpacity: root.opacity
anchors.fill: parent
fragmentShader: Qt.resolvedUrl(Quickshell.shellDir + "/Shaders/qsb/rounded_image.frag.qsb")
// Qt6 specific properties - ensure proper blending
supportsAtlasTextures: false
blending: true
// Make sure the background is transparent
Rectangle {
id: background
anchors.fill: parent
color: Color.transparent
z: -1
}
source: ShaderEffectSource {
sourceItem: img
hideSource: true
live: true
recursive: false
format: ShaderEffectSource.RGBA
}
}
// Fallback icon
Loader {
active: fallbackIcon !== undefined && fallbackIcon !== "" && (imagePath === undefined || imagePath === "")
anchors.centerIn: parent
sourceComponent: NIcon {
anchors.centerIn: parent
icon: fallbackIcon
pointSize: fallbackIconSize
z: 0
}
}
}
// Border
Rectangle {
anchors.fill: parent
radius: parent.radius
color: Color.transparent
border.color: parent.borderColor
border.width: parent.borderWidth
antialiasing: true
z: 10
}
}

View File

@@ -0,0 +1,217 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Templates as T
import qs.Constants
import qs.Noctalia
Item {
id: root
property color handleColor: Qt.alpha(Color.mTertiary, 0.8)
property color handleHoverColor: handleColor
property color handlePressedColor: handleColor
property color trackColor: Color.transparent
property real handleWidth: 6
property real handleRadius: Style.radiusM
property int verticalPolicy: ScrollBar.AsNeeded
property int horizontalPolicy: ScrollBar.AsNeeded
// Forward ListView properties
property alias model: listView.model
property alias delegate: listView.delegate
property alias spacing: listView.spacing
property alias orientation: listView.orientation
property alias currentIndex: listView.currentIndex
property alias count: listView.count
property alias contentHeight: listView.contentHeight
property alias contentWidth: listView.contentWidth
property alias contentY: listView.contentY
property alias contentX: listView.contentX
property alias currentItem: listView.currentItem
property alias highlightItem: listView.highlightItem
property alias headerItem: listView.headerItem
property alias footerItem: listView.footerItem
property alias section: listView.section
property alias highlightFollowsCurrentItem: listView.highlightFollowsCurrentItem
property alias highlightMoveDuration: listView.highlightMoveDuration
property alias highlightMoveVelocity: listView.highlightMoveVelocity
property alias preferredHighlightBegin: listView.preferredHighlightBegin
property alias preferredHighlightEnd: listView.preferredHighlightEnd
property alias highlightRangeMode: listView.highlightRangeMode
property alias snapMode: listView.snapMode
property alias keyNavigationWraps: listView.keyNavigationWraps
property alias cacheBuffer: listView.cacheBuffer
property alias displayMarginBeginning: listView.displayMarginBeginning
property alias displayMarginEnd: listView.displayMarginEnd
property alias layoutDirection: listView.layoutDirection
property alias effectiveLayoutDirection: listView.effectiveLayoutDirection
property alias verticalLayoutDirection: listView.verticalLayoutDirection
property alias boundsBehavior: listView.boundsBehavior
property alias flickableDirection: listView.flickableDirection
property alias interactive: listView.interactive
property alias moving: listView.moving
property alias flicking: listView.flicking
property alias dragging: listView.dragging
property alias horizontalVelocity: listView.horizontalVelocity
property alias verticalVelocity: listView.verticalVelocity
// Forward ListView methods
function positionViewAtIndex(index, mode) {
listView.positionViewAtIndex(index, mode);
}
function positionViewAtBeginning() {
listView.positionViewAtBeginning();
}
function positionViewAtEnd() {
listView.positionViewAtEnd();
}
function forceLayout() {
listView.forceLayout();
}
function cancelFlick() {
listView.cancelFlick();
}
function flick(xVelocity, yVelocity) {
listView.flick(xVelocity, yVelocity);
}
function incrementCurrentIndex() {
listView.incrementCurrentIndex();
}
function decrementCurrentIndex() {
listView.decrementCurrentIndex();
}
function indexAt(x, y) {
return listView.indexAt(x, y);
}
function itemAt(x, y) {
return listView.itemAt(x, y);
}
function itemAtIndex(index) {
return listView.itemAtIndex(index);
}
// Set reasonable implicit sizes for Layout usage
implicitWidth: 200
implicitHeight: 200
ListView {
id: listView
anchors.fill: parent
// Enable clipping to keep content within bounds
clip: true
// Enable flickable for smooth scrolling
boundsBehavior: Flickable.StopAtBounds
ScrollBar.vertical: ScrollBar {
parent: listView
x: listView.mirrored ? 0 : listView.width - width
y: 0
height: listView.height
active: listView.ScrollBar.horizontal.active
policy: root.verticalPolicy
contentItem: Rectangle {
implicitWidth: root.handleWidth
implicitHeight: 100
radius: root.handleRadius
color: parent.pressed ? root.handlePressedColor : parent.hovered ? root.handleHoverColor : root.handleColor
opacity: parent.policy === ScrollBar.AlwaysOn || parent.active ? 1 : 0
Behavior on opacity {
NumberAnimation {
duration: Style.animationFast
}
}
Behavior on color {
ColorAnimation {
duration: Style.animationFast
}
}
}
background: Rectangle {
implicitWidth: root.handleWidth
implicitHeight: 100
color: root.trackColor
opacity: parent.policy === ScrollBar.AlwaysOn || parent.active ? 0.3 : 0
radius: root.handleRadius / 2
Behavior on opacity {
NumberAnimation {
duration: Style.animationFast
}
}
}
}
ScrollBar.horizontal: ScrollBar {
id: horizontalScrollBar
parent: listView
x: 0
y: listView.height - height
width: listView.width
active: listView.ScrollBar.vertical.active
policy: root.horizontalPolicy
contentItem: Rectangle {
implicitWidth: 100
implicitHeight: root.handleWidth
radius: root.handleRadius
color: parent.pressed ? root.handlePressedColor : parent.hovered ? root.handleHoverColor : root.handleColor
opacity: parent.policy === ScrollBar.AlwaysOn || parent.active ? 1 : 0
Behavior on opacity {
NumberAnimation {
duration: Style.animationFast
}
}
Behavior on color {
ColorAnimation {
duration: Style.animationFast
}
}
}
background: Rectangle {
implicitWidth: 100
implicitHeight: root.handleWidth
color: root.trackColor
opacity: parent.policy === ScrollBar.AlwaysOn || parent.active ? 0.3 : 0
radius: root.handleRadius / 2
Behavior on opacity {
NumberAnimation {
duration: Style.animationFast
}
}
}
}
}
}

View File

@@ -0,0 +1,459 @@
import QtQuick
import Quickshell
import Quickshell.Wayland
import qs.Noctalia
import qs.Services
import qs.Constants
import qs.Utils
Loader {
id: root
property ShellScreen screen
property Component panelContent: null
property real preferredWidth: 700
property real preferredHeight: 900
property real preferredWidthRatio
property real preferredHeightRatio
property color panelBackgroundColor: Color.mSurface
property bool draggable: false
property var buttonItem: null
property string buttonName: ""
property bool panelAnchorHorizontalCenter: false
property bool panelAnchorVerticalCenter: false
property bool panelAnchorTop: false
property bool panelAnchorBottom: false
property bool panelAnchorLeft: false
property bool panelAnchorRight: false
property bool isMasked: false
// Properties to support positioning relative to the opener (button)
property bool useButtonPosition: false
property point buttonPosition: Qt.point(0, 0)
property int buttonWidth: 0
property int buttonHeight: 0
property bool panelKeyboardFocus: false
property bool backgroundClickEnabled: true
// Animation properties
readonly property real originalScale: 0.7
readonly property real originalOpacity: 0.0
property real scaleValue: originalScale
property real opacityValue: originalOpacity
property real dimmingOpacity: 0
signal opened
signal closed
active: false
asynchronous: true
Component.onCompleted: {
PanelService.registerPanel(root)
}
// -----------------------------------------
// Functions to control background click behavior
function disableBackgroundClick() {
backgroundClickEnabled = false
}
function enableBackgroundClick() {
// Add a small delay to prevent immediate close after drag release
enableBackgroundClickTimer.restart()
}
Timer {
id: enableBackgroundClickTimer
interval: 100
repeat: false
onTriggered: backgroundClickEnabled = true
}
// -----------------------------------------
function toggle(buttonItem, buttonName) {
if (!active) {
open(buttonItem, buttonName)
} else {
close()
}
}
// -----------------------------------------
function open(buttonItem, buttonName) {
root.buttonItem = buttonItem
root.buttonName = buttonName || ""
setPosition()
PanelService.willOpenPanel(root)
backgroundClickEnabled = true
active = true
root.opened()
}
// -----------------------------------------
function close() {
dimmingOpacity = 0
scaleValue = originalScale
opacityValue = originalOpacity
root.closed()
active = false
useButtonPosition = false
backgroundClickEnabled = true
PanelService.closedPanel(root)
}
// -----------------------------------------
function setPosition() {
// If we have a button name, we are landing here from an IPC call.
// IPC calls have no idead on which screen they panel will spawn.
// Resolve the button name to a proper button item now that we have a screen.
if (buttonName !== "" && root.screen !== null) {
buttonItem = BarService.lookupWidget(buttonName, root.screen.name)
}
// Get the button position if provided
if (buttonItem !== undefined && buttonItem !== null) {
useButtonPosition = true
var itemPos = buttonItem.mapToItem(null, 0, 0)
buttonPosition = Qt.point(itemPos.x, itemPos.y)
buttonWidth = buttonItem.width
buttonHeight = buttonItem.height
} else {
useButtonPosition = false
}
}
// -----------------------------------------
sourceComponent: Component {
PanelWindow {
id: panelWindow
readonly property bool isVertical: false
readonly property bool barIsVisible: (screen !== null)
readonly property real verticalBarWidth: Math.round(Style.barHeight)
Component.onCompleted: {
Logger.log("NPanel", "Opened", root.objectName)
dimmingOpacity = Style.opacityHeavy
}
Connections {
target: panelWindow
function onScreenChanged() {
root.screen = screen
// If called from IPC always reposition if screen is updated
if (buttonName) {
setPosition()
}
// Logger.log("NPanel", "OnScreenChanged", root.screen.name)
}
}
visible: true
color: Qt.alpha(Color.mShadow, dimmingOpacity)
WlrLayershell.exclusionMode: ExclusionMode.Ignore
WlrLayershell.namespace: "noctalia-panel"
WlrLayershell.keyboardFocus: root.panelKeyboardFocus ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None
mask: root.isMasked ? maskRegion : null
Region {
id: maskRegion
}
Behavior on color {
ColorAnimation {
duration: Style.animationNormal
}
}
anchors.top: true
anchors.left: true
anchors.right: true
anchors.bottom: true
// Close any panel with Esc without requiring focus
Shortcut {
sequences: ["Escape"]
enabled: root.active
onActivated: root.close()
context: Qt.WindowShortcut
}
// Clicking outside of the rectangle to close
MouseArea {
anchors.fill: parent
enabled: root.backgroundClickEnabled
onClicked: root.close()
}
// The actual panel's content
Rectangle {
id: panelBackground
color: panelBackgroundColor
radius: Style.radiusL
border.color: Color.mOutline
border.width: Math.max(1, Style.borderS )
// Dragging support
property bool draggable: root.draggable
property bool isDragged: false
property real manualX: 0
property real manualY: 0
width: {
var w
if (preferredWidthRatio !== undefined) {
w = Math.round(Math.max(screen?.width * preferredWidthRatio, preferredWidth) )
} else {
w = preferredWidth
}
// Clamp width so it is never bigger than the screen
return Math.min(w, screen?.width - Style.marginL * 2)
}
height: {
var h
if (preferredHeightRatio !== undefined) {
h = Math.round(Math.max(screen?.height * preferredHeightRatio, preferredHeight) )
} else {
h = preferredHeight
}
// Clamp width so it is never bigger than the screen
return Math.min(h, screen?.height - Style.barHeight - Style.marginL * 2)
}
scale: root.scaleValue
opacity: root.isMasked ? 0 : root.opacityValue
x: isDragged ? manualX : calculatedX
y: isDragged ? manualY : calculatedY
// ---------------------------------------------
// Does not account for corners are they are negligible and helps keep the code clean.
// ---------------------------------------------
property real marginTop: {
if (!barIsVisible) {
return 0
}
return (Style.barHeight + Style.marginS)
}
property real marginBottom: {
if (!barIsVisible) {
return 0
}
return Style.marginS
}
property real marginLeft: {
if (!barIsVisible) {
return 0
}
return Style.marginS
}
property real marginRight: {
if (!barIsVisible) {
return 0
}
return Style.marginS
}
// ---------------------------------------------
property int calculatedX: {
// Priority to fixed anchoring
if (panelAnchorHorizontalCenter) {
// Center horizontally but respect bar margins
var centerX = Math.round((panelWindow.width - panelBackground.width) / 2)
var minX = marginLeft
var maxX = panelWindow.width - panelBackground.width - marginRight
return Math.round(Math.max(minX, Math.min(centerX, maxX)))
} else if (panelAnchorLeft) {
return marginLeft
} else if (panelAnchorRight) {
return Math.round(panelWindow.width - panelBackground.width - marginRight)
}
// No fixed anchoring
if (isVertical) {
// Vertical bar
if (barPosition === "right") {
// To the left of the right bar
return Math.round(panelWindow.width - panelBackground.width - marginRight)
} else {
// To the right of the left bar
return marginLeft
}
} else {
// Horizontal bar
if (root.useButtonPosition) {
// Position panel relative to button
var targetX = buttonPosition.x + (buttonWidth / 2) - (panelBackground.width / 2)
// Keep panel within screen bounds
var maxX = panelWindow.width - panelBackground.width - marginRight
var minX = marginLeft
return Math.round(Math.max(minX, Math.min(targetX, maxX)))
} else {
// Fallback to center horizontally
return Math.round((panelWindow.width - panelBackground.width) / 2)
}
}
}
// ---------------------------------------------
property int calculatedY: {
// Priority to fixed anchoring
if (panelAnchorVerticalCenter) {
// Center vertically but respect bar margins
var centerY = Math.round((panelWindow.height - panelBackground.height) / 2)
var minY = marginTop
var maxY = panelWindow.height - panelBackground.height - marginBottom
return Math.round(Math.max(minY, Math.min(centerY, maxY)))
} else if (panelAnchorTop) {
return marginTop
} else if (panelAnchorBottom) {
return Math.round(panelWindow.height - panelBackground.height - marginBottom)
}
// No fixed anchoring
if (isVertical) {
// Vertical bar
if (useButtonPosition) {
// Position panel relative to button
var targetY = buttonPosition.y + (buttonHeight / 2) - (panelBackground.height / 2)
// Keep panel within screen bounds
var maxY = panelWindow.height - panelBackground.height - marginBottom
var minY = marginTop
return Math.round(Math.max(minY, Math.min(targetY, maxY)))
} else {
// Fallback to center vertically
return Math.round((panelWindow.height - panelBackground.height) / 2)
}
} else {
return marginTop
}
}
// Animate in when component is completed
Component.onCompleted: {
root.scaleValue = 1.0
root.opacityValue = 1.0
}
// Reset drag position when panel closes
Connections {
target: root
function onClosed() {
panelBackground.isDragged = false
}
}
// Prevent closing when clicking in the panel bg
MouseArea {
anchors.fill: parent
}
// Animation behaviors
Behavior on scale {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.OutExpo
}
}
Behavior on opacity {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.OutQuad
}
}
Loader {
id: panelContentLoader
anchors.fill: parent
sourceComponent: root.panelContent
}
// Handle drag move on the whole panel area
DragHandler {
id: dragHandler
target: null
enabled: panelBackground.draggable
property real dragStartX: 0
property real dragStartY: 0
onActiveChanged: {
if (active) {
// Capture current position into manual coordinates BEFORE toggling isDragged
panelBackground.manualX = panelBackground.x
panelBackground.manualY = panelBackground.y
dragStartX = panelBackground.x
dragStartY = panelBackground.y
panelBackground.isDragged = true
if (root.enableBackgroundClick)
root.disableBackgroundClick()
} else {
// Keep isDragged true so we continue using the manual x/y after release
if (root.enableBackgroundClick)
root.enableBackgroundClick()
}
}
onTranslationChanged: {
// Proposed new coordinates from fixed drag origin
var nx = dragStartX + translation.x
var ny = dragStartY + translation.y
// Calculate gaps so we never overlap the bar on any side
var baseGap = Style.marginS
var floatExtraH = Settings.data.bar.floating ? Settings.data.bar.marginHorizontal * 2 * Style.marginXL : 0
var floatExtraV = Settings.data.bar.floating ? Settings.data.bar.marginVertical * 2 * Style.marginXL : 0
var insetLeft = baseGap + ((barIsVisible && barPosition === "left") ? (Style.barHeight + floatExtraH) : 0)
var insetRight = baseGap + ((barIsVisible && barPosition === "right") ? (Style.barHeight + floatExtraH) : 0)
var insetTop = baseGap + ((barIsVisible && barPosition === "top") ? (Style.barHeight + floatExtraV) : 0)
var insetBottom = baseGap + ((barIsVisible && barPosition === "bottom") ? (Style.barHeight + floatExtraV) : 0)
// Clamp within screen bounds accounting for insets
var maxX = panelWindow.width - panelBackground.width - insetRight
var minX = insetLeft
var maxY = panelWindow.height - panelBackground.height - insetBottom
var minY = insetTop
panelBackground.manualX = Math.round(Math.max(minX, Math.min(nx, maxX)))
panelBackground.manualY = Math.round(Math.max(minY, Math.min(ny, maxY)))
}
}
// Drag indicator border
Rectangle {
anchors.fill: parent
anchors.margins: 0
color: Color.transparent
border.color: Color.mPrimary
border.width: Math.max(2, Style.borderL )
radius: parent.radius
visible: panelBackground.isDragged && dragHandler.active
opacity: 0.8
z: 3000
// Subtle glow effect
Rectangle {
anchors.fill: parent
anchors.margins: 0
color: Color.transparent
border.color: Color.mPrimary
border.width: Math.max(1, Style.borderS )
radius: parent.radius
opacity: 0.3
}
}
}
}
}
}

View File

@@ -0,0 +1,152 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Effects
import qs.Constants
import qs.Noctalia
import qs.Services
import qs.Utils
Slider {
id: root
property var cutoutColor: Color.mSurface
property bool snapAlways: true
property real heightRatio: 0.7
readonly property real knobDiameter: Math.round((Style.baseWidgetSize * heightRatio) / 2) * 2
readonly property real trackHeight: Math.round((knobDiameter * 0.4) / 2) * 2
readonly property real cutoutExtra: Math.round((Style.baseWidgetSize * 0.1) / 2) * 2
padding: cutoutExtra / 2
snapMode: snapAlways ? Slider.SnapAlways : Slider.SnapOnRelease
implicitHeight: Math.max(trackHeight, knobDiameter)
background: Rectangle {
x: root.leftPadding
y: root.topPadding + root.availableHeight / 2 - height / 2
implicitWidth: Style.sliderWidth
implicitHeight: trackHeight
width: root.availableWidth
height: implicitHeight
radius: height / 2
color: Qt.alpha(Color.mSurface, 0.5)
border.color: Qt.alpha(Color.mOutline, 0.5)
border.width: Math.max(1, Style.borderS)
// A container composite shape that puts a semicircle on the end
Item {
id: activeTrackContainer
width: root.visualPosition * parent.width
height: parent.height
// The rounded end cap made from a rounded rectangle
Rectangle {
width: parent.height
height: parent.height
radius: width / 2
color: Qt.darker(Color.mPrimary, 1.2) //starting color of gradient
}
// The main rectangle
Rectangle {
x: parent.height / 2
width: parent.width - x // Fills the rest of the container
height: parent.height
radius: 0
// Animated gradient fill
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop {
position: 0
color: Qt.darker(Color.mPrimary, 1.2)
Behavior on color {
ColorAnimation {
duration: 300
}
}
}
GradientStop {
position: 0.5
color: Color.mPrimary
SequentialAnimation on position {
loops: Animation.Infinite
NumberAnimation {
from: 0.3
to: 0.7
duration: 2000
easing.type: Easing.InOutSine
}
NumberAnimation {
from: 0.7
to: 0.3
duration: 2000
easing.type: Easing.InOutSine
}
}
}
GradientStop {
position: 1
color: Qt.lighter(Color.mPrimary, 1.2)
}
}
}
}
// Circular cutout
Rectangle {
id: knobCutout
implicitWidth: knobDiameter + cutoutExtra
implicitHeight: knobDiameter + cutoutExtra
radius: width / 2
color: root.cutoutColor !== undefined ? root.cutoutColor : Color.mSurface
x: root.leftPadding + root.visualPosition * (root.availableWidth - root.knobDiameter) - cutoutExtra
anchors.verticalCenter: parent.verticalCenter
}
}
handle: Item {
implicitWidth: knobDiameter
implicitHeight: knobDiameter
x: root.leftPadding + root.visualPosition * (root.availableWidth - width)
anchors.verticalCenter: parent.verticalCenter
Rectangle {
id: knob
implicitWidth: knobDiameter
implicitHeight: knobDiameter
radius: width / 2
color: root.pressed ? Color.mTertiary : Color.mSurface
border.color: Color.mPrimary
border.width: Math.max(1, Style.borderL)
anchors.centerIn: parent
Behavior on color {
ColorAnimation {
duration: Style.animationFast
}
}
}
}
}

View File

@@ -0,0 +1,20 @@
import QtQuick
import QtQuick.Layouts
import qs.Constants
import qs.Noctalia
Text {
id: root
property string family: Fonts.primary
property real pointSize: Style.fontSizeM
property real fontScale: 1
font.family: root.family
font.weight: Style.fontWeightMedium
font.pointSize: root.pointSize * fontScale
color: Color.mOnSurface
elide: Text.ElideRight
wrapMode: Text.NoWrap
verticalAlignment: Text.AlignVCenter
}

View File

@@ -4,6 +4,7 @@ import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.Pipewire
import qs.Utils
Singleton {
id: root
@@ -60,6 +61,7 @@ Singleton {
function onMutedChanged() {
root._muted = (sink?.audio.muted ?? true)
Logger.log("AudioService", "OnMuteChanged:", root._muted)
}
}
@@ -76,6 +78,7 @@ Singleton {
function onMutedChanged() {
root._inputMuted = (source?.audio.muted ?? true)
Logger.log("AudioService", "OnInputMuteChanged:", root._inputMuted)
}
}
@@ -93,7 +96,7 @@ Singleton {
sink.audio.muted = false
sink.audio.volume = Math.max(0, Math.min(1.0, newVolume))
} else {
console.warn("No sink available")
Logger.warn("AudioService", "No sink available")
}
}
@@ -101,7 +104,7 @@ Singleton {
if (sink?.ready && sink?.audio) {
sink.audio.muted = muted
} else {
console.warn("No sink available")
Logger.warn("AudioService", "No sink available")
}
}
@@ -111,7 +114,7 @@ Singleton {
source.audio.muted = false
source.audio.volume = Math.max(0, Math.min(1.0, newVolume))
} else {
console.warn("No source available")
Logger.warn("AudioService", "No source available")
}
}
@@ -119,7 +122,7 @@ Singleton {
if (source?.ready && source?.audio) {
source.audio.muted = muted
} else {
console.warn("No source available")
Logger.warn("AudioService", "No source available")
}
}

View File

@@ -3,6 +3,7 @@ pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Utils
Singleton {
id: root
@@ -44,6 +45,10 @@ Singleton {
reloadableId: "brightness"
Component.onCompleted: {
Logger.log("Brightness", "Service started")
}
onMonitorsChanged: {
ddcMonitors = []
ddcProc.running = true
@@ -80,7 +85,7 @@ Singleton {
var ddcModel = ddcModelMatch ? ddcModelMatch.length > 0 : false
var model = modelMatch ? modelMatch[1] : "Unknown"
var bus = busMatch ? busMatch[1] : "Unknown"
console.log("Detected DDC Monitor:", model, "on bus", bus, "is DDC:", !ddcModel)
Logger.log("Brigthness", "Detected DDC Monitor:", model, "on bus", bus, "is DDC:", !ddcModel)
return {
"model": model,
"busNum": bus,
@@ -188,7 +193,7 @@ Singleton {
var val = parseInt(dataText)
if (!isNaN(val)) {
monitor.brightness = val / 101
console.log("Apple display brightness:", monitor.brightness)
Logger.log("Brightness", "Apple display brightness:", monitor.brightness)
}
} else if (monitor.isDdc) {
var parts = dataText.split(" ")
@@ -197,7 +202,7 @@ Singleton {
var max = parseInt(parts[4])
if (!isNaN(current) && !isNaN(max) && max > 0) {
monitor.brightness = current / max
console.log("DDC brightness:", monitor.brightness)
Logger.log("Brightness", "DDC brightness:", current + "/" + max + " =", monitor.brightness)
}
}
} else {
@@ -213,8 +218,8 @@ Singleton {
if (!isNaN(current) && !isNaN(max) && max > 0) {
monitor.maxBrightness = max
monitor.brightness = current / max
console.log("Internal brightness:", monitor.brightness)
console.log("Using backlight device:", monitor.backlightDevice)
Logger.log("Brightness", "Internal brightness:", current + "/" + max + " =", monitor.brightness)
Logger.log("Brightness", "Using backlight device:", monitor.backlightDevice)
}
}
}

View File

@@ -2,6 +2,7 @@ import QtQuick
import Quickshell
import Quickshell.Io
import qs.Services
import qs.Utils
pragma Singleton
Singleton {
@@ -136,10 +137,10 @@ Singleton {
if (isInhibited)
isInhibited = false;
console.log("Inhibitor process exited with code:", exitCode, "status:", exitStatus);
Logger.log("Caffeine", "Inhibitor process exited with code:", exitCode, "status:", exitStatus);
}
onStarted: function() {
console.log("Inhibitor process started with strategy:", root.strategy);
Logger.log("Caffeine", "Inhibitor process started with PID:", inhibitorProcess.processId);
}
}

View File

@@ -0,0 +1,35 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Constants
Item {
IpcHandler {
function setPrimary(color: color) {
SettingsService.primaryColor = color;
}
target: "colors"
}
IpcHandler {
function toggleCalendar() {
calendarPanel.toggle();
}
function toggleControlCenter() {
controlCenterPanel.toggle();
}
target: "panels"
}
IpcHandler {
function toggleBarLyrics() {
SettingsService.showLyricsBar = !SettingsService.showLyricsBar;
}
target: "lyrics"
}
}

View File

@@ -1,6 +1,7 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Utils
pragma Singleton
Singleton {
@@ -9,28 +10,93 @@ Singleton {
property real fetchInterval: 30 // in s
property real fetchTimeout: 10 // in s
property string ipURL: "https://api.uyanide.com/ip"
property string geoURL: "curl https://api.ipinfo.io/lite/"
property string geoURL: "https://api.ipinfo.io/lite/"
property string geoURLToken: ""
function fetchIP() {
if (fetchIPProcess.running) {
console.warn("Fetch IP process is still running, skipping fetchIP");
return ;
}
fetchIPProcess.running = true;
const xhr = new XMLHttpRequest();
xhr.timeout = fetchTimeout * 1000;
xhr.onreadystatechange = function() {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status === 200) {
try {
const response = JSON.parse(xhr.responseText);
if (response && response.ip) {
let newIP = response.ip;
Logger.log("IpService", "Fetched IP: " + newIP);
if (newIP !== ip) {
ip = newIP;
fetchGeoInfo(); // Fetch geo info only if IP has changed
}
} else {
ip = "N/A";
countryCode = "N/A";
Logger.error("IpService", "IP response does not contain 'ip' field");
}
} catch (e) {
ip = "N/A";
countryCode = "N/A";
Logger.error("IpService", "Failed to parse IP response: " + e);
}
} else {
ip = "N/A";
countryCode = "N/A";
Logger.error("IpService", "Failed to fetch IP, status: " + xhr.status);
}
}
};
xhr.ontimeout = function() {
ip = "N/A";
countryCode = "N/A";
Logger.error("IpService", "Fetch IP request timed out");
};
xhr.open("GET", ipURL);
xhr.send();
}
function fetchGeoInfo() {
if (fetchGeoProcess.running) {
console.warn("Fetch geo process is still running, skipping fetchGeoInfo");
return ;
}
if (!ip || ip === "N/A") {
countryCode = "N/A";
return ;
}
fetchGeoProcess.command = ["sh", "-c", `curl -L -m ${fetchTimeout.toString()} ${geoURL}${ip}${geoURLToken ? "?token=" + geoURLToken : ""}`];
fetchGeoProcess.running = true;
const xhr = new XMLHttpRequest();
xhr.timeout = fetchTimeout * 1000;
xhr.onreadystatechange = function() {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status === 200) {
try {
const response = JSON.parse(xhr.responseText);
if (response && response.country) {
let newCountryCode = response.country_code;
Logger.log("IpService", "Fetched country code: " + newCountryCode);
if (newCountryCode !== countryCode) {
countryCode = newCountryCode;
SendNotification.show("New IP", `IP: ${ip}\nCountry: ${newCountryCode}`);
}
} else {
countryCode = "N/A";
Logger.error("IpService", "Geo response does not contain 'country' field");
}
} catch (e) {
countryCode = "N/A";
Logger.error("IpService", "Failed to parse geo response: " + e);
}
} else {
countryCode = "N/A";
Logger.error("IpService", "Failed to fetch geo info, status: " + xhr.status);
}
}
};
xhr.ontimeout = function() {
countryCode = "N/A";
Logger.error("IpService", "Fetch geo info request timed out");
};
let url = geoURL + ip;
if (geoURLToken)
url += "?token=" + geoURLToken;
xhr.open("GET", url);
xhr.send();
}
function refresh() {
@@ -46,11 +112,11 @@ Singleton {
FileView {
id: tokenFile
path: Qt.resolvedUrl("../Assets/Ip/token.txt")
path: Qt.resolvedUrl("../Assets/Config/GeoInfoToken.txt")
onLoaded: {
geoURLToken = tokenFile.text();
if (!geoURLToken)
console.warn("No token found for geoIP service, assuming none is required");
Logger.warn("IpService", "No token found for geoIP service, assuming none is required");
fetchIP();
fetchTimer.start();
@@ -68,64 +134,4 @@ Singleton {
}
}
Process {
id: fetchIPProcess
command: ["sh", "-c", `curl -L -m ${fetchTimeout.toString()} ${ipURL}`]
running: false
stdout: SplitParser {
splitMarker: ""
onRead: (data) => {
let newIP = "";
try {
const response = JSON.parse(data);
if (response && response.ip) {
newIP = response.ip;
console.log("Fetched IP: " + newIP);
}
} catch (e) {
console.error("Failed to parse IP response: " + e);
}
if (newIP && newIP !== ip) {
ip = newIP;
fetchGeoInfo();
} else if (!newIP) {
ip = "N/A";
countryCode = "N/A";
}
}
}
}
Process {
id: fetchGeoProcess
command: []
running: false
stdout: SplitParser {
splitMarker: ""
onRead: (data) => {
let newCountryCode = "";
try {
const response = JSON.parse(data);
if (response && response.country) {
newCountryCode = response.country_code;
console.log("Fetched country code: " + newCountryCode);
SendNotification.show("New IP", `IP: ${ip}\nCountry: ${newCountryCode}`);
}
} catch (e) {
console.error("Failed to parse geo response: " + e);
}
if (newCountryCode && newCountryCode !== countryCode)
countryCode = newCountryCode;
else if (!newCountryCode)
countryCode = "N/A";
}
}
}
}

View File

@@ -0,0 +1,323 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Constants
import qs.Utils
pragma Singleton
// Weather logic and caching with stable UI properties
Singleton {
//console.log(JSON.stringify(weatherData))
id: root
property string locationName: "Munich"
property string locationFile: Qt.resolvedUrl("../Assets/Config/Location.json")
property int weatherUpdateFrequency: 30 * 60 // 30 minutes expressed in seconds
property bool isFetchingWeather: false
readonly property alias data: adapter // Used to access via LocationService.data.xxx from outside, best to use "adapter" inside the service.
// Stable UI properties - only updated when location is fully resolved
property bool coordinatesReady: false
property string stableLatitude: ""
property string stableLongitude: ""
property string stableName: ""
// Helper property for UI components (outside JsonAdapter to avoid binding loops)
readonly property string displayCoordinates: {
if (!root.coordinatesReady || root.stableLatitude === "" || root.stableLongitude === "")
return "";
const lat = parseFloat(root.stableLatitude).toFixed(4);
const lon = parseFloat(root.stableLongitude).toFixed(4);
return `${lat}, ${lon}`;
}
// --------------------------------
function init() {
// does nothing but ensure the singleton is created
// do not remove
Logger.log("Location", "Service started");
}
// --------------------------------
function resetWeather() {
Logger.log("Location", "Resetting weather data");
// Mark as changing to prevent UI updates
root.coordinatesReady = false;
// Reset stable properties
root.stableLatitude = "";
root.stableLongitude = "";
root.stableName = "";
// Reset core data
adapter.latitude = "";
adapter.longitude = "";
adapter.name = "";
adapter.weatherLastFetch = 0;
adapter.weather = null;
// Try to fetch immediately
updateWeather();
}
// --------------------------------
function updateWeather() {
if (isFetchingWeather) {
Logger.warn("Location", "Weather is still fetching");
return ;
}
if ((adapter.weatherLastFetch === "") || (adapter.weather === null) || (adapter.latitude === "") || (adapter.longitude === "") || (adapter.name !== root.locationName) || (Time.timestamp >= adapter.weatherLastFetch + weatherUpdateFrequency))
getFreshWeather();
}
// --------------------------------
function getFreshWeather() {
isFetchingWeather = true;
// Check if location name has changed
const locationChanged = data.name !== root.locationName;
if (locationChanged) {
root.coordinatesReady = false;
Logger.log("Location", "Location changed from", adapter.name, "to", root.locationName);
}
if ((adapter.latitude === "") || (adapter.longitude === "") || locationChanged)
_geocodeLocation(root.locationName, function(latitude, longitude, name, country) {
Logger.log("Location", "Geocoded", root.locationName, "to:", latitude, "/", longitude);
// Save location name
adapter.name = root.locationName;
// Save GPS coordinates
adapter.latitude = latitude.toString();
adapter.longitude = longitude.toString();
root.stableName = `${name}, ${country}`;
_fetchWeather(latitude, longitude, errorCallback);
}, errorCallback);
else
_fetchWeather(adapter.latitude, adapter.longitude, errorCallback);
}
// --------------------------------
function _geocodeLocation(locationName, callback, errorCallback) {
Logger.log("Location", "Geocoding location name");
var geoUrl = "https://assets.noctalia.dev/geocode.php?city=" + encodeURIComponent(locationName) + "&language=en&format=json";
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function() {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status === 200) {
try {
var geoData = JSON.parse(xhr.responseText);
if (geoData.lat != null)
callback(geoData.lat, geoData.lng, geoData.name, geoData.country);
else
errorCallback("Location", "could not resolve location name");
} catch (e) {
errorCallback("Location", "Failed to parse geocoding data: " + e);
}
} else {
errorCallback("Location", "Geocoding error: " + xhr.status);
}
}
};
xhr.open("GET", geoUrl);
xhr.send();
}
// --------------------------------
function _fetchWeather(latitude, longitude, errorCallback) {
Logger.log("Location", "Fetching weather from api.open-meteo.com");
var url = "https://api.open-meteo.com/v1/forecast?latitude=" + latitude + "&longitude=" + longitude + "&current_weather=true&current=relativehumidity_2m,surface_pressure&daily=temperature_2m_max,temperature_2m_min,weathercode&timezone=auto";
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function() {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status === 200) {
try {
var weatherData = JSON.parse(xhr.responseText);
// Save core data
data.weather = weatherData;
data.weatherLastFetch = Time.timestamp;
// Update stable display values only when complete and successful
root.stableLatitude = data.latitude = weatherData.latitude.toString();
root.stableLongitude = data.longitude = weatherData.longitude.toString();
root.coordinatesReady = true;
isFetchingWeather = false;
Logger.log("Location", "Cached weather to disk - stable coordinates updated");
} catch (e) {
errorCallback("Location", "Failed to parse weather data");
}
} else {
errorCallback("Location", "Weather fetch error: " + xhr.status);
}
}
};
xhr.open("GET", url);
xhr.send();
}
// --------------------------------
function errorCallback(module, message) {
Logger.error(module, message);
isFetchingWeather = false;
}
// --------------------------------
function weatherSymbolFromCode(code) {
if (code === 0)
return "weather-sun";
if (code === 1 || code === 2)
return "weather-cloud-sun";
if (code === 3)
return "weather-cloud";
if (code >= 45 && code <= 48)
return "weather-cloud-haze";
if (code >= 51 && code <= 67)
return "weather-cloud-rain";
if (code >= 71 && code <= 77)
return "weather-cloud-snow";
if (code >= 71 && code <= 77)
return "weather-cloud-snow";
if (code >= 85 && code <= 86)
return "weather-cloud-snow";
if (code >= 95 && code <= 99)
return "weather-cloud-lightning";
return "weather-cloud";
}
function weatherColorFromCode(code) {
// Clear sky - bright yellow
if (code === 0)
return Colors.yellow;
// Mainly clear/Partly cloudy - soft peach/rosewater tones
if (code === 1 || code === 2)
return Colors.peach;
// Overcast - neutral sky blue
if (code === 3)
return Colors.sky;
// Fog - soft lavender/muted tone
if (code >= 45 && code <= 48)
return Colors.lavender;
// Drizzle - light blue/sapphire
if (code >= 51 && code <= 67)
return Colors.sapphire;
// Snow - cool teal
if (code >= 71 && code <= 77)
return Colors.teal;
// Rain showers - deeper blue
if (code >= 80 && code <= 82)
return Colors.blue;
// Snow showers - teal
if (code >= 85 && code <= 86)
return Colors.teal;
// Thunderstorm - dramatic mauve/pink
if (code >= 95 && code <= 99)
return Colors.mauve;
// Default - sky blue
return Colors.sky;
}
// --------------------------------
function weatherDescriptionFromCode(code) {
if (code === 0)
return "Clear sky";
if (code === 1)
return "Mainly clear";
if (code === 2)
return "Partly cloudy";
if (code === 3)
return "Overcast";
if (code === 45 || code === 48)
return "Fog";
if (code >= 51 && code <= 67)
return "Drizzle";
if (code >= 71 && code <= 77)
return "Snow";
if (code >= 80 && code <= 82)
return "Rain showers";
if (code >= 95 && code <= 99)
return "Thunderstorm";
return "Unknown";
}
// --------------------------------
function celsiusToFahrenheit(celsius) {
return 32 + celsius * 1.8;
}
FileView {
id: locationFileView
path: locationFile
printErrors: false
onAdapterUpdated: saveTimer.start()
onLoaded: {
Logger.log("Location", "Loaded cached data");
// Initialize stable properties on load
if (adapter.latitude !== "" && adapter.longitude !== "" && adapter.weatherLastFetch > 0) {
root.stableLatitude = adapter.latitude;
root.stableLongitude = adapter.longitude;
root.stableName = adapter.name;
root.coordinatesReady = true;
Logger.log("Location", "Coordinates ready");
}
updateWeather();
}
onLoadFailed: function(error) {
updateWeather();
}
JsonAdapter {
id: adapter
// Core data properties
property string latitude: ""
property string longitude: ""
property string name: ""
property int weatherLastFetch: 0
property var weather: null
}
}
// Every 20s check if we need to fetch new weather
Timer {
id: updateTimer
interval: 20 * 1000
running: true
repeat: true
onTriggered: {
updateWeather();
}
}
Timer {
id: saveTimer
running: false
interval: 1000
onTriggered: locationFileView.writeAdapter()
}
}

View File

@@ -0,0 +1,144 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Services
import qs.Utils
pragma Singleton
Singleton {
property int linesCount: 3
property int linesAhead: linesCount / 2
property int currentIndex: linesCount - linesAhead - 1
property string offsetFile: Qt.resolvedUrl("../Assets/Config/LyricsOffset.txt")
property int offset: 0 // in ms
property int offsetStep: 500 // in ms
property int referenceCount: 0
// with linesCount=3 and linesAhead=1, lyrics will be like:
// line 1
// line 2 <- current line
// line 3
property var lyrics: Array(linesCount).fill(" ")
function startSyncing() {
referenceCount++;
Logger.log("LyricsService", "Reference count:", referenceCount);
if (referenceCount === 1) {
Logger.log("LyricsService", "Starting lyrics syncing");
// fill lyrics with empty lines
lyrics = Array(linesCount).fill(" ");
listenProcess.exec(["sh", "-c", `sl-wrap listen -l ${linesCount} -a ${linesAhead} -f ${offsetFile.slice(7)}`]);
}
}
function stopSyncing() {
referenceCount--;
Logger.log("LyricsService", "Reference count:", referenceCount);
if (referenceCount <= 0) {
Logger.log("LyricsService", "Stopping lyrics syncing");
// Execute again to stop
// kinda ugly but works, but meanwhile:
// listenProcess.signal(9)
// listenProcess.signal(15)
// listenProcess.running = false
// counts on exec() to terminate previous exec()
// all don't work
listenProcess.exec(["sh", "-c", `sl-wrap trackid`]);
}
}
function writeOffset() {
offsetFileView.setText(String(offset));
}
function increaseOffset() {
offset += offsetStep;
}
function decreaseOffset() {
offset -= offsetStep;
}
function resetOffset() {
offset = 0;
}
function clearCache() {
action.command = ["sh", "-c", "spotify-lyrics clear $(spotify-lyrics trackid)"];
action.startDetached();
}
function showLyricsText() {
action.command = ["sh", "-c", "ghostty -e sh -c 'spotify-lyrics fetch | less'"];
action.startDetached();
}
onOffsetChanged: {
if (SettingsService.showLyricsBar)
SendNotification.show("Lyrics Offset Changed", `Current offset: ${offset} ms`, 1000);
writeOffset();
}
Process {
id: listenProcess
running: false
stdout: SplitParser {
splitMarker: ""
onRead: (data) => {
lyrics = data.split("\n").slice(0, linesCount);
if (lyrics.length < linesCount) {
// fill with empty lines if not enough
for (let i = lyrics.length; i < linesCount; i++) {
lyrics[i] = " ";
}
}
}
}
}
Process {
id: action
running: false
}
FileView {
id: offsetFileView
path: offsetFile
watchChanges: false
onLoaded: {
try {
const fileContents = text();
if (fileContents.length > 0) {
const val = parseInt(fileContents);
if (!isNaN(val)) {
offset = val;
Logger.log("LyricsService", "Loaded offset:", offset);
} else {
offset = 0;
writeOffset();
}
} else {
offset = 0;
writeOffset();
}
} catch (e) {
Logger.log("LyricsService", "Error reading offset file:", e);
}
}
onLoadFailed: {
Logger.log("LyricsService", "Error loading offset file:", errorString);
}
onSaveFailed: {
Logger.log("LyricsService", "Error saving offset file:", errorString);
}
onSaved: {
Logger.log("LyricsService", "Offset file saved.");
}
}
}

View File

@@ -2,6 +2,7 @@ import QtQuick
import Quickshell
import Quickshell.Services.Mpris
import qs.Modules.Misc
import qs.Utils
pragma Singleton
Singleton {
@@ -11,6 +12,7 @@ Singleton {
property var currentPlayer: null
property real currentPosition: 0
property bool isPlaying: currentPlayer ? currentPlayer.isPlaying : false
property int selectedPlayerIndex: -1
property string trackTitle: currentPlayer ? (currentPlayer.trackTitle || "Unknown Track") : ""
property string trackArtist: currentPlayer ? (currentPlayer.trackArtist || "Unknown Artist") : ""
property string trackAlbum: currentPlayer ? (currentPlayer.trackAlbum || "Unknown Album") : ""
@@ -59,11 +61,25 @@ Singleton {
// Updates currentPlayer and currentPosition
function updateCurrentPlayer() {
// Use selected player if index is valid
if (selectedPlayerIndex >= 0) {
let availablePlayers = getAvailablePlayers();
if (selectedPlayerIndex < availablePlayers.length) {
currentPlayer = availablePlayers[selectedPlayerIndex];
currentPosition = currentPlayer.position;
Logger.log("MusicManager", "Current player set by index:", currentPlayer ? currentPlayer.identity : "None");
return ;
} else {
selectedPlayerIndex = -1; // Reset if index is out of range
}
}
// Otherwise, find active player
let newPlayer = findActivePlayer();
if (newPlayer !== currentPlayer) {
currentPlayer = newPlayer;
currentPosition = currentPlayer ? currentPlayer.position : 0;
}
Logger.log("MusicManager", "Current player updated:", currentPlayer ? currentPlayer.identity : "None");
}
// Player control functions

View File

@@ -1,6 +1,7 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Utils
pragma Singleton
pragma ComponentBehavior: Bound
@@ -66,7 +67,7 @@ Singleton {
});
root.workspaces = workspacesList;
} catch (e) {
console.error("Failed to parse workspaces:", e, line);
Logger.error("Niri", "Failed to parse workspaces:", e, line);
}
}
}
@@ -102,7 +103,7 @@ Singleton {
}
root.windows = windowsMap;
} catch (e) {
console.error("Error parsing windows event:", e);
Logger.error("Niri", "Error parsing windows event:", e);
}
} else if (event.WorkspaceActivated) {
workspaceProcess.running = true;
@@ -120,13 +121,13 @@ Singleton {
root.focusedWindowId = -1;
}
} catch (e) {
console.error("Error parsing window focus event:", e);
Logger.error("Niri", "Error parsing window focus event:", e);
}
} else if (event.OverviewOpenedOrClosed) {
try {
root.inOverview = event.OverviewOpenedOrClosed.is_open === true;
} catch (e) {
console.error("Error parsing overview state:", e);
Logger.error("Niri", "Error parsing overview state:", e);
}
} else if (event.WindowOpenedOrChanged) {
try {
@@ -161,7 +162,7 @@ Singleton {
}
} catch (e) {
console.error("Error parsing window opened/changed event:", e);
Logger.error("Niri", "Error parsing window opened/changed event:", e);
}
} else if (event.windowClosed) {
try {
@@ -170,11 +171,11 @@ Singleton {
delete root.windows[closedId];
}
} catch (e) {
console.error("Error parsing window closed event:", e);
Logger.error("Niri", "Error parsing window closed event:", e);
}
}
} catch (e) {
console.error("Error parsing event stream:", e, data);
Logger.error("Niri", "Error parsing event stream:", e, data);
}
}
}

View File

@@ -0,0 +1,88 @@
import QtQuick
import Quickshell
import Quickshell.Services.UPower
import qs.Services
import qs.Utils
pragma Singleton
Singleton {
id: root
readonly property var powerProfiles: PowerProfiles
readonly property bool available: powerProfiles && powerProfiles.hasPerformanceProfile
property int profile: powerProfiles ? powerProfiles.profile : PowerProfile.Balanced
function getName(p) {
if (!available)
return "Unknown";
const prof = (p !== undefined) ? p : profile;
switch (prof) {
case PowerProfile.Performance:
return "Performance";
case PowerProfile.Balanced:
return "Balanced";
case PowerProfile.PowerSaver:
return "Power saver";
default:
return "Unknown";
}
}
function getIcon(p) {
if (!available)
return "balanced";
const prof = (p !== undefined) ? p : profile;
switch (prof) {
case PowerProfile.Performance:
return "performance";
case PowerProfile.Balanced:
return "balanced";
case PowerProfile.PowerSaver:
return "powersaver";
default:
return "balanced";
}
}
function setProfile(p) {
if (!available)
return ;
try {
powerProfiles.profile = p;
} catch (e) {
Logger.error("PowerProfileService", "Failed to set profile:", e);
}
}
function cycleProfile() {
if (!available)
return ;
const current = powerProfiles.profile;
if (current === PowerProfile.Performance)
setProfile(PowerProfile.PowerSaver);
else if (current === PowerProfile.Balanced)
setProfile(PowerProfile.Performance);
else if (current === PowerProfile.PowerSaver)
setProfile(PowerProfile.Balanced);
}
Connections {
function onProfileChanged() {
root.profile = powerProfiles.profile;
// Only show toast if we have a valid profile name (not "Unknown")
const profileName = root.getName();
if (profileName !== "Unknown")
ToastService.showNotice(I18n.tr("toast.power-profile.changed"), I18n.tr("toast.power-profile.profile-name", {
"profile": profileName
}));
}
target: powerProfiles
}
}

View File

@@ -6,7 +6,7 @@ pragma Singleton
Singleton {
id: root
function show(title, message, icon = "", urgency = "normal", timeout = 5000) {
function show(title, message, timeout = 5000, icon = "", urgency = "normal") {
if (icon)
action.command = ["notify-send", "-u", urgency, "-i", icon, "-t", timeout.toString(), title, message];
else

View File

@@ -0,0 +1,35 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Constants
import qs.Services
pragma Singleton
Singleton {
property alias primaryColor: adapter.primaryColor
property alias showLyricsBar: adapter.showLyricsBar
property string settingsFilePath: Qt.resolvedUrl("../Assets/Config/Settings.json")
FileView {
id: settingsFile
path: settingsFilePath
watchChanges: true
onFileChanged: reload()
JsonAdapter {
id: adapter
property string primaryColor: "#89b4fa"
property bool showLyricsBar: false
}
}
Connections {
target: adapter
onPrimaryColorChanged: settingsFile.writeAdapter()
onShowLyricsBarChanged: settingsFile.writeAdapter()
}
}

View File

@@ -2,6 +2,7 @@ import Qt.labs.folderlistmodel
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Utils
pragma Singleton
Singleton {
@@ -222,7 +223,7 @@ Singleton {
}
root.cpuTemp = Math.round(sum / root.intelTempValues.length);
} else {
console.warn("No temperature sensors found for coretemp");
Logger.warn("SystemStatService", "No temperature sensors found for coretemp");
root.cpuTemp = 0;
}
return ;
@@ -328,7 +329,7 @@ Singleton {
function checkNext() {
if (currentIndex >= 16) {
// Check up to hwmon10
console.warn("No supported temperature sensor found");
Logger.warn("SystemStatService", "No supported temperature sensor found");
return ;
}
cpuTempNameReader.path = `/sys/class/hwmon/hwmon${currentIndex}/name`;
@@ -341,7 +342,7 @@ Singleton {
if (root.supportedTempCpuSensorNames.includes(name)) {
root.cpuTempSensorName = name;
root.cpuTempHwmonPath = `/sys/class/hwmon/hwmon${currentIndex}`;
console.log(`Found ${root.cpuTempSensorName} CPU thermal sensor at ${root.cpuTempHwmonPath}`);
Logger.log("SystemStatService", `Found ${root.cpuTempSensorName} CPU thermal sensor at ${root.cpuTempHwmonPath}`);
} else {
currentIndex++;
Qt.callLater(() => {
@@ -370,7 +371,6 @@ Singleton {
if (root.cpuTempSensorName === "coretemp") {
// For Intel, collect all temperature values
const temp = parseInt(data) / 1000;
//console.log(temp, cpuTempReader.path)
root.intelTempValues.push(temp);
Qt.callLater(() => {
// Qt.callLater is mandatory

View File

@@ -5,6 +5,7 @@ import QtQuick
import Quickshell
import Quickshell.Io
import qs.Services
import qs.Utils
Singleton {
id: root
@@ -40,7 +41,7 @@ Singleton {
try {
Quickshell.execDetached(["niri", "msg", "action", "focus-workspace", workspaceId.toString()]);
} catch (e) {
console.error("Error switching Niri workspace:", e);
Logger.error("WorkspaceManager", "Error switching Niri workspace:", e);
}
}

View File

Binary file not shown.

View File

Binary file not shown.

View File

@@ -23,7 +23,9 @@ Scope {
},
"output": {
"method": "raw",
"bit_format": 8,
"data_format": "ascii",
"ascii_max_range": 100,
"bit_format": "8bit",
"channels": channels,
"mono_option": monoOption
}
@@ -33,14 +35,11 @@ Scope {
Process {
id: process
property int index: 0
stdinEnabled: true
running: !MusicManager.isAllPaused()
command: ["cava", "-p", "/dev/stdin"]
onExited: {
stdinEnabled = true;
index = 0;
values = Array(count).fill(0);
}
onStarted: {
@@ -56,23 +55,14 @@ Scope {
}
}
stdinEnabled = false;
values = Array(count).fill(0);
}
stdout: SplitParser {
splitMarker: ""
onRead: (data) => {
const newValues = Array(count).fill(0);
for (let i = 0; i < values.length; i++) {
newValues[i] = values[i];
}
if (process.index + data.length > count)
process.index = 0;
for (let i = 0; i < data.length; i += 1) {
newValues[i + process.index] = Math.min(data.charCodeAt(i), 128) / 128;
}
process.index += data.length;
values = newValues;
root.values = data.slice(0, -1).split(";").map((v) => {
return parseInt(v, 10) / 100;
});
}
}

View File

@@ -0,0 +1,58 @@
pragma Singleton
import Quickshell
import qs.Utils
Singleton {
id: root
function _formatMessage(...args) {
var t = Time.getFormattedTimestamp()
if (args.length > 1) {
const maxLength = 14
var module = args.shift().substring(0, maxLength).padStart(maxLength, " ")
return `\x1b[36m[${t}]\x1b[0m \x1b[35m${module}\x1b[0m ` + args.join(" ")
} else {
return `[\x1b[36m[${t}]\x1b[0m ` + args.join(" ")
}
}
function _getStackTrace() {
try {
throw new Error("Stack trace")
} catch (e) {
return e.stack
}
}
function log(...args) {
var msg = _formatMessage(...args)
console.log(msg)
}
function warn(...args) {
var msg = _formatMessage(...args)
console.warn(msg)
}
function error(...args) {
var msg = _formatMessage(...args)
console.error(msg)
}
function callStack() {
var stack = _getStackTrace()
Logger.log("Debug", "--------------------------")
Logger.log("Debug", "Current call stack")
// Split the stack into lines and log each one
var stackLines = stack.split('\n')
for (var i = 0; i < stackLines.length; i++) {
var line = stackLines[i].trim() // Remove leading/trailing whitespace
if (line.length > 0) {
// Only log non-empty lines
Logger.log("Debug", `- ${line}`)
}
}
Logger.log("Debug", "--------------------------")
}
}

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

@@ -0,0 +1,84 @@
import QtQuick
import Quickshell
pragma Singleton
Singleton {
id: root
// Current date
property var date: new Date()
// Returns a Unix Timestamp (in seconds)
readonly property int timestamp: {
return Math.floor(date / 1000);
}
// Formats a Date object into a YYYYMMDD-HHMMSS string.
function getFormattedTimestamp(date) {
if (!date)
date = new Date();
const year = date.getFullYear();
// getMonth() is zero-based, so we add 1
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}${month}${day}-${hours}${minutes}${seconds}`;
}
// Format an easy to read approximate duration ex: 4h32m
// Used to display the time remaining on the Battery widget, computer uptime, etc..
function formatVagueHumanReadableDuration(totalSeconds) {
if (typeof totalSeconds !== 'number' || totalSeconds < 0)
return '0s';
// Floor the input to handle decimal seconds
totalSeconds = Math.floor(totalSeconds);
const days = Math.floor(totalSeconds / 86400);
const hours = Math.floor((totalSeconds % 86400) / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const parts = [];
if (days)
parts.push(`${days}d`);
if (hours)
parts.push(`${hours}h`);
if (minutes)
parts.push(`${minutes}m`);
// Only show seconds if no hours and no minutes
if (!hours && !minutes)
parts.push(`${seconds}s`);
return parts.join('');
}
// Format a date into
function formatRelativeTime(date) {
if (!date)
return "";
const diff = Date.now() - date.getTime();
if (diff < 60000)
return "now";
if (diff < 3.6e+06)
return `${Math.floor(diff / 60000)}m ago`;
if (diff < 8.64e+07)
return `${Math.floor(diff / 3600000)}h ago`;
return `${Math.floor(diff / 86400000)}d ago`;
}
Timer {
interval: 1000
repeat: true
running: true
onTriggered: root.date = new Date()
}
}

View File

@@ -4,10 +4,16 @@ import Quickshell.Widgets
import qs.Constants
import qs.Modules.Bar
import qs.Modules.Misc
import qs.Modules.Panel
import qs.Services
Scope {
id: root
IPCService {
id: ipcService
}
Bar {
id: bar
@@ -20,4 +26,16 @@ Scope {
shell: root
}
CalendarPanel {
id: calendarPanel
objectName: "calendarPanel"
}
ControlCenterPanel {
id: controlCenterPanel
objectName: "controlCenterPanel"
}
}